ReviewOS

stacks/stx

SPA nav: fragmentNeedsRuntime misses bare directives/interpolation → silent dead hydration on runtime-less pages

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

Summary

fragmentNeedsRuntime (the #1809 handoff gate) fails to detect a fragment that needs the runtime when its reactivity is expressed as bare directives / interpolation on plain elements — so on a runtime-less page, no full-load handoff happens and the fragment's hydration dies silently.

The gate

packages/router/src/client.ts:

function fragmentNeedsRuntime(frag){
  return frag.indexOf('__stx_setup_')!==-1
    ||frag.indexOf('data-stx-scope=')!==-1
    ||frag.indexOf('data-stx-reactive')!==-1
    ||/=\s*window\.stx\s*;/.test(frag);
}

It keys on scope/setup/reactive markers only.

Repro

fragmentNeedsRuntime('<div @click="x()">{{ y }}</div>')  // → false  (MISS)

A fragment whose only dynamic bits are @click (to a global handler), , x-model, :attr, :if, etc. — with no data-stx-scope= / __stx_setup_ / data-stx-reactive in the emitted HTML — is reported as not needing the runtime.

Impact

When window.stx hasn't loaded yet (you landed on a static page first) and you SPA-navigate to such a fragment, fragmentNeedsRuntime returns false → no handoff to a full load → the runtime never initializes → the directives/interpolation never bind, with no console error. This is the same failure class #1809 fixed, just an uncovered input shape.

Suggested fix

Extend the sniff to also detect bare directives/interpolation:

  • {{ (interpolation)
  • @-prefixed event handlers (@click, @input, …)
  • :-prefixed bindings on attributes
  • x- directives (x-model, x-class, x-if, …)

Or, more robustly: when window.stx is absent, hand off to a full load whenever the fragment contains any dynamic marker, rather than enumerating a subset.

10 days ago

Fixed in 42128d8.

The gate hole is real, but the example in the report isn't the shape that hits it.

<div @click="x()"></div> with no client script never reaches the browser as written. processEventDirectives consumes the handler into id="__stx_evt_0" and processExpressions evaluates the mustache server-side, so the page renders as <div id="__stx_evt_0"></div> and ships no runtime at all. fragmentNeedsRuntime returning false there is correct — that fragment needs nothing. (Separately: a scriptless @click being dropped on full load too is its own bug, filed below.)

The shape that does slip through is any : / x- binding on a page with no client script anywhere:

<script server>
const error = 'Invalid credentials'
</script>
<main>
  <p :if="error">{{ error }}</p>
  <input x-model="email">
  <button :disabled="isLoading">Sign in</button>
</main>

That's a <script server>-only login form — a real, common shape, not a contrivance. All four markers come from a client script, so a page without one emits none of them; whatever root marker it does get (data-stx-auto) lands on <body>, which the fragment excludes by construction. Meanwhile the server does ship it the runtime.

The symptom is worse than dead handlers: the server stamps x-cloak on those elements and only the runtime removes it, so the server-rendered "Invalid credentials" lands in the DOM at display:none for the life of the page — with no console error.

Same hole caught a second shape nobody had noticed: x-data on the routed container, whose scope attribute is hoisted out of the fragment body into the X-STX-Container-Attrs header.

The fix isn't a wider sniff — it's not guessing. The server is the thing that decided to inject a runtime, so it now reports that in X-STX-Runtime, carried into the fragment marker as rt=1/rt=0 so it survives the prefetch cache. It's keyed on the same data-stx-runtime marker injectSignalsRuntime writes, so the two can't drift apart. Both shapes above are covered without either being enumerated.

The markup sniff stays as the fallback for a server too old to declare it, and it is broadened as suggested — but anchored on attribute positions and run past <pre>/<code>/<script>/<style> first. That matters: the naive version full-reloads every hop through the docs, on exactly the content-heavy sites that have no runtime to begin with. Measured over the 339 rendered docs pages, raw {{ matches 53; after stripping inert regions the whole broadened sniff matches 9, of which 3 are pre-existing matches of the current four-marker sniff (pages that document its marker names). Net new cost: 6/339.

Tests: packages/router/test/client-fragment-runtime-gate.test.ts (13, incl. the stuck-x-cloak counterfactual and the docs/xlink:href/10:00 false-positive guards) and packages/bun-plugin/test/serve-fragment-runtime-header.test.ts (6, end-to-end against a real serve()). Full suite green.

Three adjacent bugs found while verifying this — filing separately, none blocking:

  1. A scriptless @click is dropped entirely, on first load as well as SPA nav — prepareDirectiveContext sets __stx_sfc_mode unconditionally, so the binding is parked on context.__stx_event_bindings and only re-emitted while transforming a client script. No script, no consumer.
  2. A page with a <script client> containing only plain functions emits window.stx.mount(...) with no runtime injected → TypeError on load. Dead on full navigation too, so the gate can't help it.
  3. hasSignalsSyntax matches directive syntax inside escaped <pre><code> samples, so a docs page quoting :if= ships the full ~159KB runtime it never uses. The new header faithfully reports that as true; the over-shipping is upstream of it.

Sign in to comment on this issue.