ReviewOS

stacks/stx

Hydration relay: taxonomy of literal-{⁠{ }} regressions + ranked hardening plan (deep-dive results)

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

Companion to #1769/#1771/#1772 — results of a systematic investigation into why literal keeps reaching users despite 8 fixes (c571492, 4c5fb05, d411bf8, 409a2e0, bcd6f2c, da91c34, 8ebb172, 20509da).

Why it recurs

Preserved moustaches are the framework's de-facto error UI: any failure in the detect → extract → bundle → ship → rebind relay collapses into the same visible symptom. The preserve-vs-interpolate decision is re-derived at ~7 sites across 4 phases (expressions.ts, utils.ts, includes.ts, variable-extractor.ts, client-script-bundler.ts, signals.ts) — each regression fix patched one site's heuristic; no invariant anywhere asserts 'zero literal moustaches after hydration'.

The SPA whole-scope class (component renders all-dead: literal moustaches + stuck x-show)

Server-side there is NO divergence (fragments render through the same processDirectives, serve.ts:1544, extracted post-render :2294-2406). The failures are client-side lifecycle:

  • D1 (prime suspect, #1700 recurrence): doFragSwap script hash-dedup (packages/router/src/client.ts:401-415) skips a scope script on REVISIT — nav-away already disposed the scope (client.ts:339 → signals.ts:5118) — when the script fails the charAt(0) exemption sniff (client.ts:412). f348390 widened the sniff; it remains one emitted-shape change from regressing.
  • D2: if (!scopeVars) return in the stx:load walk (signals.ts:~5229) silently abandons the whole subtree.
  • D3: container __stx_scope guard skips processElement for the next fragment when not cleaned (signals.ts:~5246/5113).
  • D4: one top-level throw in a shared emitted script unregisters all later components in it.
  • D5: x-if-detached scope roots missed by cleanupContainer → stale __mounted blocks remount.

Ranked hardening plan

  1. Post-hydration invariant sweep (end of stx:load + DOMContentLoaded): scan container for leftover {{ text nodes and unregistered [data-stx-scope]; console.error with scopeId + force-fallback processElement. Converts every future upstream miss into one loud, self-healing event.
  2. Replace dedup heuristic with a contract: emit data-stx-run="always|once" on generated scripts; key the client.ts skip off the attribute (extraction regex at client.ts:345 must keep attributes).
  3. Make the three silent skips loud (signals.ts ~5229, ~5246; eval catch ~1670 — log once per expression).
  4. Per-component registration isolation (own try/catch or own script element per component in client-script-bundler.ts) — kills D4.
  5. Single preservation gate: compute preserve-vs-interpolate once, stamp it, have all seven sites consume it.

App-side, ts-analytics now runs a revisit-inclusive WebView sweep + a dev canary asserting the post-hydration invariant per navigation — happy to share either as a reference implementation for (1).

9 days ago

Field capture (the ts-analytics dev canary fired on a real session):

[canary] dead bindings after spa-nav | literal moustaches:  | unregistered scopes: none | path: /dashboard
  at doSwap (dashboard:9927)

The discriminating bit: unregistered scopes: none — every data-stx-scope in the swapped DOM has an entry in window.stx._scopes, yet the fragment's moustache text nodes were never bound. That rules OUT the D1→D2 chain (scope-loss → silent abandon) for this occurrence and points at the D5/D3 family instead: a surviving scope with __mounted: true (or the container __stx_scope guard) causing the rebind pass to skip fresh text nodes in the swapped subtree. i.e. the scope object outlives the swap, the new DOM arrives with preserved , and hydration declines to re-process because it believes this scope is already mounted.

Repro conditions on our side: repeated SPA hops to /dashboard within one long stateful session (compare mode on); single-visit walks stay clean. Happy to run any instrumented build against the same session pattern.

9 days ago

Items 1–4 landed in 5a80a17b0a. Item 5 is filed separately as #1800 with the analysis — closing this as the plan is now either done or tracked.

1. Post-hydration invariant sweep — runs at the end of both DOMContentLoaded and stx:load. A data-stx-scope element that is unregistered, or registered and never processed, is reported with its scope id and recovered by processing it with the ambient scope — so D2 and D3 are repaired, not just named. Leftover is reported but never auto-fixed (without a scope there is nothing to interpolate against). Quiet by construction: silent on pages with no signals, deferred islands excluded, and <pre>/<code>/<template>/<script>/<style>/<textarea>/[data-stx-ignore] plus anything not connected to the document are skipped. Never throws, repairs each element once.

2. data-stx-run="always|once" replaces the charAt(0) sniff — D1. Emitters declare intent; unstamped scripts fall back to the sniff so older servers are unaffected. The attribute survives fragment extraction and is carried onto the inert placeholder.

3. Expression failures — rather than logging at throw time, which would fire on every legitimate first-pass miss and train people to ignore the console, failures are recorded and cleared the moment the same expression succeeds. Only expressions still broken when hydration finishes are reported. An is not a function error prints the signal-call hint, which is the most common silent failure of all.

4. Per-factory isolation — D4 confirmed at component-client-factories.ts:63: repeated factories share one <script>, so one throw dropped every later component in the prelude. Each definition now has its own try/catch and names the factory that failed.

Not in the plan, but worth recording: this work tripped the same trap three times. The runtime is authored as a template literal, so a backtick inside a // comment terminates it. That is not a TypeScript error — the file compiles, tests import fine, and the breakage only appears when something executes the generated string. With several test files loading it at once it surfaced as a Bun segfault, which reads as a toolchain problem rather than a typo. test/signals/runtime-parses.test.ts now parses both builds and points at the offending line in signals.ts.

Sign in to comment on this issue.