Bug
When a scoped component renders two reactive :if chains as direct siblings (no non-chain element between them), the runtime fails to pick a single branch in each chain. All branches in both chains stay visible at steady state. Adding x-cloak (which the post-#1736 SSR auto-stamps anyway) doesn't help — the runtime ends up removing x-cloak from every branch, not just the active one.
Reproducer
<script client>
const isAuthenticated = derived<boolean>(() => true)
const commentsLoaded = state<boolean>(true)
const comments = state<any[]>([{ id: 1, body: 'hi' }])
</script>
<section data-stx-scope="reproducer">
<h2>Comments ({{ comments().length }})</h2>
<!-- Chain A: composer / sign-in -->
<div :if="isAuthenticated()">composer</div>
<div :else>sign in</div>
<!-- Chain B: loading / empty / list -->
<ul :if="!commentsLoaded()">skeleton</ul>
<div :else-if="comments().length === 0">empty</div>
<ul :else>list</ul>
</section>Expected at steady state (authenticated, loaded, one comment):
composervisible,sign inremoved from DOMlistvisible,skeleton+emptyremoved from DOM
Actual:
composer,sign in,skeleton,empty,listall visible- All five have had their
x-cloakremoved by the runtime - Every branch's children have been processed
- No console errors
What I verified
Working from pantry/@stacksjs/stx at commit-equivalent of main today:
- SSR output is structurally correct. Five direct-sibling children under
<section data-stx-scope>with:if/:else/:if/:else-if/:elsein that exact order, each auto-stamped withx-cloak(post-#1736). - The runtime has
findIfChain,bindIfChain,getElseAttrInfo(persignals.ts:2393-2540). [x-cloak] { display: none !important }is inline in the SSR<head>(post-#1736). Verified —<style data-stx-cloak>lands before<style data-crosswind>.- Manually simulating
findIfChainagainst the served DOM (happy-dom +nextElementSibling) returns:- From composer's
<div :if>: chain length 2 (composer + sign-in:else) - From loading's
<ul :if>: chain length 3 (loading + empty:else-if+ list:else)
- From composer's
So the chain detection logic gives the right answer when I run it in isolation. But the runtime ends up showing every branch — as if bindIfChain never ran and each conditional element was processed independently, with each :else / :else-if falling into the orphan-fallthrough branch:
// signals.ts ~1535
if (!el.__stx_chain_member) {
var orphanElse = getElseAttrInfo(el)
if (orphanElse) {
console.warn('[STX] ' + orphanElse.name + ' has no matching preceding x-if/:if/@if sibling; rendering as a plain element.')
el.removeAttribute(orphanElse.name)
}
}The console warning does not fire though — so it's not the orphan path either.
Suspected root cause
bindIfChain is being called for the first chain (composer + sign-in) but its setup loop completes synchronously:
chain.forEach(function(b) {
b.el.__stx_if_bound = true
b.el.__stx_chain_member = true
b.placeholder = document.createComment('stx-if-chain')
parent.insertBefore(b.placeholder, b.el)
b.el.removeAttribute(b.attr)
b.el.remove()
b.childrenProcessed = false
})After this, the parent's child iteration continues. The snapshot Array.from(el.childNodes) taken BEFORE chain setup still contains the now-detached sign-in <div :else>. When processElement reaches it:
if (el.__stx_chain_member && !el.isConnected) return…should return early. So far so good.
But by the time iteration reaches the LOADING chain's <ul :if> (the third item in the original sibling list), the previous chain's setup has done several parent.insertBefore / remove() operations. The original DOM order between the chains has been disturbed — the picked branch from chain A was re-inserted at chain A's placeholder.nextSibling, and chain A's other branch is detached. When the runtime now calls findIfChain from the loading <ul>, loading.nextElementSibling walks through whatever the runtime's mutations left in place. If chain A's reinserted branch landed AFTER the loading <ul> for any reason (timing, placeholder insertion order), chain B's findIfChain would walk through it, fail to detect :else-if, return chain length 1, and the chain logic for B would never fire.
That would leave chain B's :else-if and :else as orphans — except the orphan warning doesn't fire either, which contradicts the theory.
So either:
- Chain B's findIfChain returns length > 1 but
bindIfChainfor B is being skipped because of state pollution from chain A (e.g.__stx_if_boundwas somehow set on chain B's head incorrectly), OR - The setTimeout-deferred child processing in chain A is re-entering processElement on chain A's picked branch in a way that hits chain B's elements as descendants (mistaking them for chain A's children), removing their
x-cloakand stripping their:else/:else-ifattributes, OR - There's a scope-walking bug specific to two chains under the same
data-stx-scopethat we haven't isolated yet.
The third feels most likely given the symptom (all branches visible, no orphan warnings, no console errors). The runtime appears to be processing every element through the "active branch" code path.
Reproduces in
- bench-review project, every
:if/:else+:if/:else-if/:elsesibling pair under a scoped component (Comments.stxis the example we noticed) - Symptom appears regardless of whether the data is loaded — same flash of all branches on hard-reload, same steady-state with all visible
What would help
If bindIfChain could log when it's actually called vs. when it's skipped — a console.log('[stx] bindIfChain entry: chain=', chain.map(c => c.attr)) would distinguish "chain not detected" from "chain detected but mishandled". Happy to instrument locally if there's a preferred way to feed the trace back upstream.
Related
- #1734 — introduced the chain runtime
- #1736 — added SSR x-cloak auto-stamp; verified working for the OUTER bench-header chain on the same page, so the issue is specifically about chains under nested scopes (or two chains in the same scope?)
- #1733 — retry-without-unwrap; not relevant here (all expressions use call-syntax)
Happy to share the full reproducer page + the rendered HTML I'm working from if useful.