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:
- Hard-reload
/— home (layouts/soon) renders. No header. - Click
<StxLink to="/reviews">— SPA nav. - The router detects layout group
soon→default, does the full body swap (stx-router/dist/index.js:423). - New
BenchHeaderscope IIFE runs in the new DOM, registers inwindow.stx._scopes. - Router fires
stx:load(index.js:581). _handleStxLoadruns (signals.js:3979) → finds the BenchHeader scope-id in the new DOM but the scope-walk loop atsignals.js:4057only scanscontainer.querySelectorAll('[data-stx-scope]')wherecontaineris<main>. The new BenchHeader is in<body>outside<main>and is silently skipped.- BenchHeader's
:ifdirectives never getbindIfattached. Both<div :if="!isAuthenticated">and<div :if="isAuthenticated">stay in the DOM with raw:ifattributes. 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 groupsoon) anddefault.stx(layout groupdefault) stx-routerdoing the full body swap when the group changes
🤖 Filed via Claude Code