fix: SPA navigation full-loads on layout-shell change (studio AppLayout bug)#90
Merged
Conversation
… bug) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a shell signature (shellId) to FlatRoute, computed in makeFlat from the layout-ancestor chain: leaves under the same layout chain share an 'L:' sig (fast in-place swap on nav), standalone leaves get a unique 'S:' sig (full load on cross-shell nav). routeIdent = Component.name || path || '?'. Task 1 of the SPA shell-signature fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New inject-shell-meta.ts mirrors inject-generator.ts (find </head>, splice <meta name="brust-shell" content="…"> with a presence guard, no-op on empty). Thread shellId through RenderBranchStreamingArgs and inject at BOTH the buffering site and the streaming first-chunk prepend; routes.ts render branch passes renderFlat.shellId. Task 2 of the SPA shell-signature fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native routes render via napiRenderJinja, not the React stream injector, so the shell signature must bake at emit. Add insertShellMeta to generator.ts (anchors after the viewport meta, same as insertGeneratorMeta), thread FlatRoute.shellId through NativeRouteEmitOpts (never recomputed — identical sig to the React path), bake it after the generator meta, and include shellId in the incremental dev memo hash. The signature lands in the layout's BrustPage head (the shell doc); fragment children with no own head correctly get nothing. Task 3 of the SPA shell-signature fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
navigationBranch payload gains shell: flat.shellId; PagePayload gains shell? (round-trips through the page cache automatically). bootstrap.ts reads the boot document's <meta name="brust-shell"> into currentShell and full-loads when a nav payload's shell differs (shellChanged: both-present guard so old payloads / stale addons / first nav never over-full-load). Same guard in attemptClientFallback (SSG static-host path). currentShell is NOT mutated on a same-shell swap (the head meta persists); a full load re-seeds it at boot. Fixes the studio bug: login/logout/editor cross-layout nav now renders the correct shell; same-layout sibling nav stays a fast in-place swap. Task 4 of the SPA shell-signature fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integration tests (tests/jinja-route.test.ts, fixture app): assert the native layout-chain document head carries L:NativeOutletLayout, the React standalone document head carries S:HelloWorld, and the nav payloads carry matching shell sigs that differ across the layout boundary (server contract; the client full-load is unit-tested in bootstrap.test.ts). Docs: architecture.md SPA-nav section + example/docs navigation.md — navigating across a layout boundary triggers a full document load (correct shell); same-layout sibling nav stays a fast in-place swap. Addon rebuilt (bun run build:debug); no Rust source changed (cargo brust-core 211/0 untouched). Pre-existing exempt failure: cli-build 'stylesheet immediately before </head>' (fails identically on origin/main — generator meta already sits in that region; not introduced here). Task 5 of the SPA shell-signature fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… doc routeIdent collision Review finding 8a: a React notFound() on the SPA-nav path renders the catch-all but the payload stamped the MATCHED route's shellId — the next hop would compare against the wrong currentShell. Track renderedShellId through the catch-all swap (and unset it for the default-404 full-doc body). Finding 9 (routeIdent Component.name collision) documented as a known limitation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aitthi
added a commit
that referenced
this pull request
Jun 13, 2026
SPA navigation full-loads on layout-shell change (PR #90) — fixes the studio AppLayout-not-rendering-without-reload bug across login/logout/editor boundaries. Same-layout sibling nav stays a fast swap. No runtime API change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug (consumer-reported, root-caused)
In ketshopweb-engine studio, SPA
navigate()across a layout boundary kept the wrong shell: login (/login→/) showed the page withoutAppLayoutchrome; logout (/→/login) keptAppLayoutaround the login card; editor open/close had the same defect. Only a manual full reload fixed it.Root cause:
navigationBranchships the destination's inner-<main>content and the client swaps it into the current page's<main>, with no comparison of the source vs destination layout chain. TheisFullDocumentPayload/no-<main>guards don't catch it because both a layout route and a standalone route can render a<main>(the studio login's<main>lives inside its SSR'd island).The fix — per-route shell signature
FlatRoute.shellId(computed inmakeFlat): leaves under one layout shareL:<Ancestors>; a standalone route gets a uniqueS:<Leaf>.<meta name="brust-shell">— baked at emit for native routes (insertShellMeta), injected at render for React routes (injectShellMeta, both the buffering and streaming-prepend sites) — mirroring the generator-meta dual-path precedent. Also added to the nav payload{ html, title, store, shell }(round-trips through the page cache automatically).shelldiffers, does an authoritative full document load (correct shell) instead of the swap. Guarded both-present, so old cached payloads / a stale addon fall through to the existing swap — never an extra full-load. popstate + prefetch-cache + SSG-fallback paths covered.Net: cross-layout navigation full-loads (correct chrome); same-layout sibling navigation stays a fast in-place swap. No Rust change.
Studio trace:
/login=S:LoginPage,/=L:AppLayout,/editor/{id}=S:EditorPageShell./login↔/,/↔/editor,/login↔/editornow full-load (correct shell); future/↔/settings(bothL:AppLayout) stays a fast swap.Review fix folded in
A combined review found one MEDIUM (8a): a React
notFound()on the nav path rendered the catch-all but stamped the matched route'sshellId— fixed by trackingrenderedShellIdthrough the catch-all swap. routeIdent component-name collision (finding 9) documented as a known limitation.Tests
+19 across routes/inject-shell-meta/stream/native-emit/bootstrap/page-cache + integration (
jinja-route: layout vs standalone shellId in both head meta and nav payload). Fullbun test1121 pass / 1 pre-existing fail (cli-build stylesheet-before-</head>, on main). biome green. cargo untouched (211/0).Spec + plan in
docs/superpowers/{specs,plans}/2026-06-13-spa-shell-signature*.🤖 Generated with Claude Code