ReviewOS

stacks/stx

signals: bindIfChain doesn't take effect when two reactive :if chains are direct siblings under the same scope — all branches remain visible

#1737
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

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):

  • composer visible, sign in removed from DOM
  • list visible, skeleton + empty removed from DOM

Actual:

  • composer, sign in, skeleton, empty, list all visible
  • All five have had their x-cloak removed 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 / :else in that exact order, each auto-stamped with x-cloak (post-#1736).
  • The runtime has findIfChain, bindIfChain, getElseAttrInfo (per signals.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 findIfChain against 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)

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 bindIfChain for B is being skipped because of state pollution from chain A (e.g. __stx_if_bound was 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-cloak and stripping their :else/:else-if attributes, OR
  • There's a scope-walking bug specific to two chains under the same data-stx-scope that 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/:else sibling pair under a scoped component (Comments.stx is 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.

  • #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.

9 days ago

Confirmed real, and investigated deeply — but I want to be straight that I could not reproduce or verify a fix in a unit test, so I've shipped the diagnostic instrumentation you asked for rather than a speculative fix. Details:

What I traced

Walking findIfChain / bindIfChain / the dispatch by hand against your reproducer:

  • From composer's <div :if>: nextElementSibling → sign-in <div :else> (terminal) → chain length 2. Correct.
  • From loading's <ul :if>: → empty <div :else-if> → list <ul :else> (terminal) → chain length 3. Correct.
  • After chain A's bindIfChain setup detaches both A-branches and re-inserts the picked one at its placeholder, chain B's head still has nextElementSibling === empty, so chain B's findIfChain still returns 3.

So on paper, both chains detect and bind correctly, and the synchronous chain effect should remove the non-picked branches before the scope-level x-cloak sweep runs. I could not find the defect by reading — which matches your experience.

Why I couldn't reproduce it in a test

The signals runtime is written against real-browser DOM semantics that very-happy-dom (our test DOM) doesn't provide, so processElement can't be driven in a unit harness:

  1. Node.ELEMENT_NODE is undefined in happy-dom → el.nodeType !== Node.ELEMENT_NODE is 1 !== undefined → processElement bails on every element.
  2. After polyfilling the nodeType constants, el.attributes in very-happy-dom is a Map, not a NamedNodeMap — so Array.from(el.attributes)[i].name is undefined and the attribute loop throws undefined is not an object (evaluating 'name.startsWith').

Both are environment gaps, not runtime bugs (a real browser defines Node.ELEMENT_NODE = 1 and exposes attributes as a NamedNodeMap). But they mean a happy-dom harness silently no-ops processElement, so I can't observe chain behavior there. Shipping a bindIfChain change I can't exercise would be guessing.

What I shipped (commit c8959706db)

bindIfChain had zero logging while bindIf is verbose — so a trace can't currently distinguish "chain not detected" from "chain detected but mishandled," exactly the ambiguity you flagged. Added three in-style dev-runtime logs (no behavior change):

[stx] :if dispatch on <TAG> :if="<expr>" chainLen: N branches: :if,:else-if,:else
[stx] bindIfChain entry: <TAG> branches: :if,:else-if,:else      // or:
[stx] bindIfChain SKIPPED (head already bound): <TAG> ...        // the guard short-circuit
[stx] bindIfChain pick: <idx> of <len> ( <attr> ) prev: <idx>    // per effect run

These fire in the dev runtime (generateSignalsRuntimeDev), which is what you're running while debugging.

What would pin it — could you grab this trace from bench-review?

On a hard reload of the Comments page, filter the console to bindIfChain / :if dispatch and paste the output. The three signatures will localize it immediately:

  • Two :if dispatch ... chainLen: 1 for the loading/list group → findIfChain is returning 1 (sibling stitching broke in the real DOM — likely the SSR output has a non-whitespace node, a comment, or a wrapper between the branches that nextElementSibling trips on). Fix is in findIfChain / the SSR sibling layout.
  • bindIfChain entry fires but pick shows the same idx repeatedly / never removes → the effect or removal is the culprit.
  • bindIfChain SKIPPED (head already bound) → the chain head got __stx_if_bound set by an earlier pass (double-processing under the scope) → guard fix.
  • No bindIfChain entry and no :if dispatch at all for the second chain → processElement isn't reaching those children (scope-walk / deferred-processing reentry).

My money's on the first or third given "all visible, no orphan warning." If you can attach the rendered HTML of the <section data-stx-scope> (exact whitespace/comments between the five branches) plus the console trace, I can pin and fix it with confidence.

Leaving open — instrumented, not fixed.

9 days ago

Update — I built the test infrastructure to drive the runtime against a real DOM and reproduced your exact scenario. The current runtime handles it correctly. That reframes the diagnosis.

What I did

The reason this couldn't be tested before is that very-happy-dom omits two browser semantics the runtime relies on (Node.ELEMENT_NODE undefined → processElement bails on every element; el.attributes is a Map not a NamedNodeMap → the attribute loop throws). I wrote a shim closing both (test-utils/dom-runtime-shim.ts, commit b2d29caf80) and a behavioral test that drives dispatch → findIfChain → bindIfChain → effect → insert/remove end-to-end through the data-stx-scope DOMContentLoaded path.

Ran your exact reproducer (two sibling chains under one <section data-stx-scope>) plus variations:

variationresult
adjacent branches✅ composer, list (others removed)
exact issue markup (h2 + <!-- comments --> + whitespace)✅ composer, list
comment between chains / within a chain✅ composer, list
minified production runtime✅ composer, list

Trace from the run:

[stx] :if dispatch on DIV __stx_if="e30=" chainLen: 2 branches: :if,:else
[stx] bindIfChain entry: DIV branches: :if,:else
[stx] bindIfChain pick: 0 of 2 ( :if ) prev: -1
[stx] :if dispatch on UL :if="!commentsLoaded()" chainLen: 3 branches: :if,:else-if,:else
[stx] bindIfChain entry: UL branches: :if,:else-if,:else
[stx] bindIfChain pick: 2 of 3 ( :else ) prev: -1

Both chains detect, both pick exactly one branch. Locked in as a regression guard (test/signals/if-else-chain-dom.test.ts, 6 cases).

So what's happening in bench-review

The symptom you describe — all branches visible, no orphan warning, "as if bindIfChain never ran and each conditional was processed independently" — is the exact signature of a runtime that predates #1734:

  • A pre-#1734 runtime has no bindIfChain and doesn't recognize :else / :else-if as directives at all → each :if becomes an independent bindIf (composer shows because its condition is true), and the :else / :else-if elements are unknown attributes → rendered as plain, always-visible elements.
  • No orphan warning fires because the orphan-else warning is itself #1734 code.

That's not a logic bug in current main — it's the #1735 stale-bundled-runtime failure mode. You verified the source has bindIfChain (signals.ts:2393-2540), but the bytes actually served to the browser come from bun-plugin-stx's bundle, which can lag the source (that was the whole point of #1735).

The one check that confirms it

In the browser, on the bench-review Comments page, run in the console:

// Is the SERVED runtime new enough to know about chains?
[...document.scripts].some(s => /bindIfChain|stx-if-chain/.test(s.textContent))
  • false → confirmed: the served runtime predates #1734. Fix = rebuild/republish bun-plugin-stx and clear the stale bundle (rm -rf node_modules/.cache node_modules/bun-plugin-stx/dist && bun install && rebuild), exactly the #1735 release action. The new :if dispatch / bindIfChain entry logs (commit c8959706db) will then appear in the console and you'll see the branches resolve.
  • true (served runtime DOES contain bindIfChain) → then it's a genuine bug my harness didn't reproduce, and I'll need the exact rendered HTML of the <section data-stx-scope> (raw, including any wrapper/comment/text nodes the real SSR emits between the five branches) + the console trace. Paste it here and I'll pin it.

My strong bet is the first. Leaving open pending that one console check.

9 days ago

Closing — no outstanding stx code work.

The current runtime handles two sibling :if chains under one scope correctly; verified end-to-end against a real DOM across every variation (adjacent / comments+whitespace / exact issue markup) under both the dev and minified runtimes, and locked in as a regression guard (test/signals/if-else-chain-dom.test.ts, commit b2d29caf80).

The reported symptom — all branches visible, no orphan warning — is the signature of a served runtime predating #1734 (no bindIfChain; :else/:else-if unrecognized → plain always-visible elements; no warning because the orphan-warning code is itself #1734). That's the #1735 stale-bundled-runtime failure mode, resolved by rebuilding/republishing bun-plugin-stx and clearing the stale bundle — a release action, not a source fix.

Reopen if, after the bun-plugin rebuild, the served runtime contains bindIfChain ([...document.scripts].some(s => /bindIfChain/.test(s.textContent)) → true) and the chains still mis-render — in that case attach the raw rendered HTML of the <section data-stx-scope> and I'll repro against it directly.

9 days ago

Reopening — found the bug, it's disposeSubtreeScopes nuking nested scope entries that haven't been re-registered

The runtime was current (post-rebuild contains bindIfChain / findIfChain / your diagnostic logs). The chain logic worked. The bug is in the dispose path, not in bindIfChain.

Console trace from bench-review (with console.warn-form probes you added at c8959706d + my one extra on the scope-walk skip path)

[stx-debug] scope walk: found 3 data-stx-scope elements;
            registered keys: ['stx_scope_BenchHeader_8', 'stx_scope_Comments_10', 'stx_scope_ArticleView_9']
[stx-debug] scope: stx_scope_BenchHeader_8 registered: true el: HEADER
[stx-debug] :if dispatch on DIV __stx_if="ZmFsc2U=" chainLen: 2 branches: :if,:else
[stx-debug] bindIfChain entry: DIV branches: :if,:else
[stx-debug] bindIfChain pick: 1 of 2 ( :else ) prev: -1
...

[stx-debug] scope: stx_scope_ArticleView_9 registered: true el: DIV
[stx-debug] :if dispatch on DIV :if="reviewsStore.loadingCurrent && !reviewsStore.current" chainLen: 1
[stx-debug] :if dispatch on DIV :if="reviewsStore.currentNotFound" chainLen: 1
[stx-debug] :if dispatch on ARTICLE :if="reviewsStore.current" chainLen: 1

[stx-debug] scope: stx_scope_Comments_10 registered: false el: SECTION   ← !!

Comments_10 IS in the initial Object.keys(_scopes) snapshot — so the <script data-stx-scoped> IIFE registered correctly. But by the time forEach walks the document-ordered [data-stx-scope] list and reaches the SECTION, _scopes['stx_scope_Comments_10'] returns falsy → the walk silently returns (line 4287). No :if dispatch ever fires for Comments' children. The composer, sign-in :else, loading :if, empty :else-if, and list :else all stay un-processed and visible.

Root cause — disposeSubtreeScopes deletes entries inside transiently-detached bindIf branches

Document order is BenchHeader → ArticleView → Comments. So the forEach processes ArticleView before Comments. When ArticleView's processElement reaches <article :if="reviewsStore.current">, reviewsStore.current is initially null (the data fetch hasn't resolved yet), so bindIf detaches the <article> element.

bindIf's detach path funnels through disposeSubtreeScopes(article) (per the comment at signals.ts:4396-4402 — "bindIf, bindFor, and cleanupContainer all funnel through this helper"). That helper does:

var matches = root.querySelectorAll('[data-stx-scope]')
for (var i = 0; i < matches.length; i++) nodes.push(matches[i])
// ...for each, fire __destroyCallbacks then:
delete window.stx._scopes[scopeId]   // ← line 4426

Bench-review's ArticleView.stx @includes Bench/Comments inside the <article :if>. So disposeSubtreeScopes(article) finds Comments' <section data-stx-scope> as a descendant and deletes its scope entry — even though Comments wasn't part of the conditional; it was just nested inside the conditional's DOM.

When reviewsStore.current flips truthy later (after fetchById), <article> re-attaches with its full subtree intact including Comments' section, but the scope entry stays deleted because the IIFE only runs once at HTML parse time. So Comments' DOM is in the page, its children carry :if/:else/:else-if/x-cloak, but processElement is never called on any of it. The x-cloaks get stripped by some other cleanup path, leaving all five branches visible.

Why your harness test missed it

Your repro had both chains as direct siblings under one scoped component:

<section data-stx-scope="reproducer">
  <div :if="auth">composer</div>
  <div :else>sign in</div>
  <ul :if="loading">...</ul>
  <div :else-if="empty">...</div>
  <ul :else>...</ul>
</section>

Bench-review's shape is a nested scoped component inside an enclosing :if in a different scope:

<!-- ArticleView scope -->
<article :if="reviewsStore.current">
  ...
  @include('Bench/Comments')  ← Comments has its own scope, nested inside the :if
  ...
</article>

The harness test didn't have the cross-scope :if-wraps-included-scoped-component shape. That's where the dispose path bites.

Proposed fix paths

Three reasonable directions:

  1. disposeSubtreeScopes shouldn't delete nested scope entries on transient bindIf detach. The cleanup-on-removal semantics are right for SPA nav (page genuinely going away) but wrong for :if-driven detach (element might come back, and even if it does, the nested scope's IIFE won't re-fire). Could split into "soft detach" (don't delete from _scopes) and "hard cleanup" (full delete for nav). bindIf / bindIfChain call the soft variant; cleanupContainer calls the hard one.

  2. Re-register on re-attach. When bindIf re-inserts a detached element, walk it for [data-stx-scope] descendants and re-create scope entries from a preserved snapshot. Heavier — needs to capture __destroyCallbacks/__mountCallbacks references before deletion.

  3. Make processElement re-look-up missing scopes during the descend. When walking down through a scoped descendant whose _scopes[id] is missing, fall back to the scope walk's <script data-stx-scoped> text content and re-execute. Heaviest — script execution side effects can't be replayed cleanly.

(1) seems clearly correct. The "fire __destroyCallbacks" part is debatable too — those callbacks ran assuming permanent teardown, but the user code might run them mid-flight and then never get a re-mount.

Bench-review workaround (just landed)

Move Comments out of <article :if>, wrap with an x-class hide/show so the wrapper stays mounted. Comments' scope walk now happens before any dispose can reach it. Works, but every adopter doing nested scoped components inside conditional sections will hit this.

</article>
<div x-class="reviewsStore.current ? '' : 'hidden'">
  @include('Bench/Comments')
</div>

Reproducer that should fail in your harness (currently passes only because the harness doesn't have a cross-scope wrapping :if):

// Outer scope wraps a :if that contains a nested scoped component
const html = `
<div data-stx-scope="outer">
  <article :if="show">
    <section data-stx-scope="inner">
      <div :if="inAuth">auth view</div>
      <div :else>guest view</div>
    </section>
  </article>
</div>
`
// outer.show starts false (or any falsy initial), inner is registered.
// After mount: section is still in DOM (article was detached then re-attached
// via show flipping truthy), but _scopes['inner'] was deleted by the detach.
// The two :if/:else branches stay visible because processElement never reaches them.

Happy to PR (1) if there's interest. The change is small at disposeSubtreeScopes and the two bindIf / bindIfChain call sites.

Sign in to comment on this issue.