Skip to content

fix: SPA navigation full-loads on layout-shell change (studio AppLayout bug)#90

Merged
Aitthi merged 8 commits into
mainfrom
feat/spa-shell-signature
Jun 13, 2026
Merged

fix: SPA navigation full-loads on layout-shell change (studio AppLayout bug)#90
Aitthi merged 8 commits into
mainfrom
feat/spa-shell-signature

Conversation

@Aitthi

@Aitthi Aitthi commented Jun 13, 2026

Copy link
Copy Markdown
Member

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 without AppLayout chrome; logout (//login) kept AppLayout around the login card; editor open/close had the same defect. Only a manual full reload fixed it.

Root cause: navigationBranch ships 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. The isFullDocumentPayload/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 in makeFlat): leaves under one layout share L:<Ancestors>; a standalone route gets a unique S:<Leaf>.
  • Stamped into the document head as <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).
  • Client: reads the boot document's signature once; when a nav payload's shell differs, 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/editor now full-load (correct shell); future //settings (both L: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's shellId — fixed by tracking renderedShellId through 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). Full bun test 1121 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

Aitthi and others added 8 commits June 13, 2026 10:23
… 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 Aitthi merged commit 30c35d8 into main Jun 13, 2026
7 checks passed
@Aitthi Aitthi deleted the feat/spa-shell-signature branch June 13, 2026 03:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant