ReviewOS

stacks/stx

Layout-level scopes don't re-bind after a full-body-swap SPA nav — `_handleStxLoad` only walks container

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

Summary

When SPA navigation crosses a layout-group boundary, stx-router does a "full body swap" (replaces document.body.innerHTML). The new layout's scoped components (header, footer, sidebar) get dropped into the DOM and their IIFEs run and register scope vars — but _handleStxLoad in signals.js only walks container.querySelectorAll('[data-stx-scope]') to re-process bindings.

Any layout-level scope that sits in <body> outside <main> (i.e. the SPA container) never gets bindIf / bindShow / event-handler bindings re-applied. The fresh DOM has the raw :if / :show / @click attributes still on the elements, and they stay inert until the next hard reload.

Result: mutually-exclusive :if siblings render side-by-side in the new layout's nav. Visible symptom: the navbar shows both "Log in / Sign up" AND "Log out" simultaneously after navigating from a layouts/soon page to a layouts/default page.

This is related to but distinct from #1667 — that one was about layouts not swapping at all. The swap itself works now; it's the post-swap rebind that's broken.

Reproduction

Two pages, two layout groups:

{{-- resources/views/index.stx --}}
@extends('layouts/soon')
@section('content')
  <p>Home (coming soon layout)</p>
@endsection
{{-- resources/views/reviews/index.stx --}}
@extends('layouts/default')
@section('content')
  <p>Reviews</p>
@endsection
{{-- resources/layouts/default.stx --}}
<!doctype html>
<html>
  <body>
    @include('Bench/BenchHeader')  {{-- has :if="!isAuthenticated" / :if="isAuthenticated" --}}
    <main>
      @yield('content')
    </main>
  </body>
</html>
{{-- resources/components/Bench/BenchHeader.stx --}}
<script client>
  const auth = useStore('auth')
  const isAuthenticated = auth.isAuthenticated
</script>

<header>
  <div :if="!isAuthenticated">
    <a href="/login">Log in</a>
    <a href="/register">Sign up</a>
  </div>
  <div :if="isAuthenticated">
    <button @click="auth.logout()">Log out</button>
  </div>
</header>

Then:

  1. Hard-reload / — home (layouts/soon) renders. No header.
  2. Click <StxLink to="/reviews"> — SPA nav.
  3. The router detects layout group soon → default, does the full body swap (stx-router/dist/index.js:423).
  4. New BenchHeader scope IIFE runs in the new DOM, registers in window.stx._scopes.
  5. Router fires stx:load (index.js:581).
  6. _handleStxLoad runs (signals.js:3979) → finds the BenchHeader scope-id in the new DOM but the scope-walk loop at signals.js:4057 only scans container.querySelectorAll('[data-stx-scope]') where container is <main>. The new BenchHeader is in <body> outside <main> and is silently skipped.
  7. BenchHeader's :if directives never get bindIf attached. Both <div :if="!isAuthenticated"> and <div :if="isAuthenticated"> stay in the DOM with raw :if attributes. Visible result: both "Log in / Sign up" and "Log out" render at the same time.

Workaround: hard-reload — DOMContentLoaded path at signals.js:3795 walks document.querySelectorAll('[data-stx-scope]') (full document, not just container), so the bindings come up correctly.

Root cause

packages/stx/src/signals.ts, _handleStxLoad:

// Apply scope from [data-stx-scope] elements and process them
container.querySelectorAll('[data-stx-scope]').forEach(function(el) {
  var scopeId = el.getAttribute('data-stx-scope');
  var scopeVars = window.stx._scopes && window.stx._scopes[scopeId];
  if (!scopeVars) return;
  Object.assign(componentScope, scopeVars);
  var disposeEffects = trackEffects(function() { processElement(el, componentScope); });
  el.__stx_disposers = disposeEffects;
});

And again at signals.js:4087:

container.querySelectorAll('[data-stx-scope]').forEach(function(el) { /* fire mount cbs */ });

Both should scan the full <body> after a layout swap, not just the container.

Suggested fix

Either:

(A) Always walk the full body. Re-binding scopes that already have intact bindings is safe — bindIf, bindShow, and the event-binder all use per-element guard flags (__stx_if_bound, __stx_evt_*) that short-circuit a second pass.

-container.querySelectorAll('[data-stx-scope]').forEach(...)
+document.body.querySelectorAll('[data-stx-scope]').forEach(...)

(B) Track whether this stx:load is a layout swap, and only widen the scope walk in that case. Stricter but more code. The router already knows; pass a flag through the event.

Option A is what I'm running locally and it works without any regressions across same-layout SPA nav, layout-group SPA nav, and hard reload. The guard flags do their job.

Local patch

-    container.querySelectorAll('[data-stx-scope]').forEach(function(el) {
+    // Walk document.body, not just `container`. The router does a full-
+    // body swap when the destination has a different layout group (see
+    // stx-router/index.js:423), which drops a new BenchHeader (and
+    // other layout-level scoped components) into <body> but OUTSIDE
+    // <main>. If we only scan `container`, those layout scopes never
+    // get re-bound — :if/:show/@click on them stay raw and the new DOM
+    // shows both sides of a mutually-exclusive :if at once.
+    document.body.querySelectorAll('[data-stx-scope]').forEach(function(el) {
       var scopeId = el.getAttribute('data-stx-scope');
       var scopeVars = window.stx._scopes && window.stx._scopes[scopeId];
       if (!scopeVars) return;
       Object.assign(componentScope, scopeVars);
       var disposeEffects = trackEffects(function() { processElement(el, componentScope); });
       el.__stx_disposers = disposeEffects;
     });

(Patched against pantry/@stacksjs/stx/dist/signals.js ~ line 4057.)

Environment

  • @stacksjs/stx@0.2.64 (pantry)
  • Two layouts in resources/layouts/: soon.stx (layout group soon) and default.stx (layout group default)
  • stx-router doing the full body swap when the group changes

🤖 Filed via Claude Code

10 days ago

Shipped in commit 1899048537 (fix: address seven reported issues across stx, bun-plugin, components).

Implements option (A) from the spec — _handleStxLoad now walks document.body.querySelectorAll('[data-stx-scope]') instead of the container in both the scope-rebind loop and the mount-callback loop in packages/stx/src/signals.ts. Two new guards keep the wider walk idempotent for persistent layout scopes across same-layout SPA navs:

  • el.__stx_disposers — set by the first bind pass; the rebind loop early-returns when present so we don't redo trackEffects(processElement) on an already-bound scope.
  • scopeVars.__mounted — set the first time a scope's __mountCallbacks fire (both in the initial mount path and in _handleStxLoad), so onMount doesn't re-fire on every navigation.

Net effect on the reproduction: after a layouts/soon → layouts/default SPA nav, the new BenchHeader's :if/:show/@click get bound on the full-body swap; the mutually-exclusive :if siblings now render exclusively, and @click="auth.logout()" fires.

Regression coverage: packages/stx/test/edge-cases/issue-fixes.test.ts describe block #1697 — layout scope rebind walks document.body (added in 8db98be), 4 tests, all passing on main.

Sign in to comment on this issue.