ReviewOS

stacks/stx

SPA navigation falls back to a full page load from any page without a <script client> — router reads window.stx._latestSetup unguarded

#1809
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Verified on published stx-router@0.2.153 and on main (0dcb6fbd0e). Chromium via CDP.

Symptom

Clicking an StxLink on a page that has no <script client> does a full document load instead of a fragment swap, and logs:

[router] fetch error: TypeError: Cannot read properties of undefined (reading '_latestSetup')
    at doFragSwap (/_stx/router.js:4:12710)
    at completeFragSwap (/_stx/router.js:4:12934)

Silent in practice: the page still arrives, so the only symptoms are a lost SPA navigation and a console error nobody is looking at.

Cause

The router loads on every page. The signals runtime does not — it is only injected on pages that have a <script client>. packages/router/src/client.ts:663 (and again at :996) then read window.stx._latestSetup with no guard:

log('[router] scripts done. _latestSetup:', !!window.stx._latestSetup);

Measured directly in the browser, four pages of the same app:

pagehas <script client>window.stxwindow.stxRouter_latestSetup
/featuresnoundefinedobjectn/a
/comparenoundefinedobjectn/a
/pricingyesobjectobjectfunction
/yesobjectobjectfunction

The middle column is the point: the router is live and intercepting clicks on /features, then reaches into an object that was never created. The throw is caught by the fetch path, which falls back to location.href.

Reproduction

Two marketing pages sharing a layout, one with a <script client> and one without. Click an internal StxLink from each.

navigationsource has runtimeresult
/ → /pricingyesspa
/pricing → /featuresyesspa
/ → /compare/fathomyesspa
/features → /features/goalsnoreload
/compare → /compare/fathomnoreload
/use-cases → /use-cases/saasnoreload

It is source-dependent, not target-dependent: /compare/fathom is a clean SPA swap from / and a full reload from /compare. Deterministic — 3/3 runs on the failing pair.

Scope

Content pages have no client script by design, so the router is disabled on exactly the pages that most want prefetch and fast navigation. In the app I hit this on, 23 of 25 marketing pages are affected.

It also means converting anchors to StxLink buys nothing on those pages — the link is intercepted, then the swap aborts and reloads anyway.

Where I stopped

I did not determine whether "inject the runtime only when a client script exists" is deliberate. Two candidate fixes, and which is right depends on that:

  • Guard the read — window.stx && window.stx._latestSetup. Minimal, but leaves the router doing fragment swaps with no signals runtime present, which may have its own consequences I have not explored.
  • Inject the runtime whenever the router is enabled. Costs bytes on otherwise-static pages, but the router arguably cannot function without it.

Unrelated to #1801 — that commit never touched _latestSetup, and this reproduces identically on published 0.2.153 with no fork symlinks in place.

10 days ago

Fixed in 0068c36d5e + 5c1a630ab8.

Confirmed the cause and narrowed it: there is exactly one unguarded read, at client.ts:663 — the :996 reference in the report is a comment. Every functional window.stx use in the router (_cleanupContainer, .router) was already guarded, which answers your open question: guarding the read is right, shipping the runtime everywhere is not.

Worth noting why it fired for everyone: log() is debug-gated, but its arguments are evaluated at the call site regardless, so the throw was never actually conditional on debug. (Same shape as a bindIf debug log that was aborting hydration — fixed in 295d5904bb.)

Guarding alone would have been a regression, though, so the second commit adds a hand-off. A fragment never carries the signals runtime — the server strips the runtime IIFE and the router filters it out of everything it re-executes — so a runtime-free page swapping in a fragment that needs one produces a silently dead page: setup defined but never invoked, and every x-cloak element stuck at display:none against the cloak style that ships on every page. Today's hard reload at least fetches the destination's runtime. /features → /pricing in your app is exactly that transition.

The discriminator is deliberately narrow, because the obvious markers are all unusable: every fragment inlines the head styles (cloak rule included), the appearance bootstrap emits a data-stx-scoped script on pages with no reactivity, and the server prepends a _latestSetup=null clear script to every fragment. Keying off any of them would disable SPA navigation everywhere — there's a test pinning that.

8 tests; 3 fail without the guard, 1 more without the hand-off.

Sign in to comment on this issue.