ReviewOS

stacks/stx

Page emits window.stx.mount() with no runtime injected: hasSignalsSyntax misses @event=, arbitrary :attr= and useStore, and its data-stx-scoped fallback runs too early to ever match

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

A page can emit window.stx.mount(...) while the signals runtime is never injected, so the client block dies on its first line with TypeError: Cannot read properties of undefined (reading 'mount') and every handler it defines is lost.

This is live in a real app today, on exactly one route, which is what makes it worth reporting — the gate is narrow enough that most pages accidentally pass it.

Two decisions, one of which is wrong

hasSignalsSyntax(template) (packages/stx/src/signal-processing.ts:203-229) decides whether the runtime is needed. The client-script pass separately decides to emit stx.mount(). Nothing reconciles them.

The gate's 24 patterns cover @if/@for/@show/@model/@bind:/@class/@style, x-data, the core signal APIs, useId, useReactiveProp, and:

/\s:(?:if|else-if|else|for|show|text|html|model)\s*=/,  // colon-form reactive directives

So it does not match:

  • @click= or any event handler. The @ patterns listed are directive forms; event binding is absent entirely.
  • Any :attr= outside that fixed list — :data-t=, :value=, :disabled=, :class=, :href=. Arbitrary attribute binding is the common case and none of it counts.
  • Every reactive composable except two. useStore, useQuery, useFetch, useMutation, useColorMode, useLocalStorage … only useId and useReactiveProp are listed, out of 71 runtime globals.

Live repro

A page whose client block uses a store and whose template uses an event handler and one bound attribute — nothing exotic:

<script client>
const themeStore = useStore('theme')
const theme = themeStore.theme
function toggleTheme(): void { themeStore.toggle() }
function logout() { … }
</script>

<html-root :data-t="theme()">
  <button @click="toggleTheme">…</button>
  <button @click="logout">Log out</button>
</html-root>

Served output:

$ curl -s http://localhost:3100/projects | grep -c 'data-stx-runtime'
0
$ curl -s http://localhost:3100/projects | grep -c 'stx\.mount('
1
<main>
<script data-stx-scoped data-stx-run="always">
window.stx.mount(function() {

Browser console:

TypeError: Cannot read properties of undefined (reading 'mount')
    at http://localhost:3100/projects:47:12

The theme toggle and the Log out button are both dead — every function in the block is defined inside the mount callback, so the throw on line 1 takes all of them.

Across the app's eight routes, seven inject the runtime and one does not; the one that does not is the only page whose template contains no directive from the gate's fixed list.

The data-stx-scoped safety net cannot fire

The pattern list ends with:

/data-stx-scoped/, // client scripts need the signals runtime

which looks like it should catch exactly this case. It cannot: the gate runs at signal-processing.ts:1565 (if (!hasSignalsSyntax(template)) return template), which is before the client-script pass emits data-stx-scoped. At gate time the attribute does not exist in the template.

The page above proves it empirically — its served HTML does contain <script data-stx-scoped …>, and the runtime was still not injected. So that pattern is dead code as ordered, and its presence makes the gate look safer than it is.

Suggested direction

Two independent things to fix, and I would do both:

  1. Widen the gate, but not by extending the hand-written list again — that is the same drift #1804 fixed for STX_AUTO_IMPORTS and #1819 reports for the auto-mount regex. Event handlers and arbitrary :attr= bindings are structural (/\s@[a-z]/, /\s:[a-z][\w-]*\s*=/), and the composable half should derive from STX_RUNTIME_GLOBALS rather than naming two of seventy-one.

  2. Make the contradiction impossible rather than unlikely. Whatever decides to emit stx.mount() should be the same thing that decides the runtime is needed — or, failing that, emitting stx.mount() should force runtime injection. A page that mounts without a runtime is never correct, so it should not be representable. Even a guarded if (window.stx) window.stx.mount(…) would degrade to "no reactivity" instead of "every handler on the page is gone", though it would trade a loud failure for a silent one.

A test asserting that any template producing data-stx-scoped also gets the runtime tag would pin this.

  • #1809 — fragment navigation into a page whose runtime the current page lacks; same family.
  • #1819 — the auto-mount regex, the other hand-maintained list that has drifted from STX_RUNTIME_GLOBALS.
  • #1804 — the fix that established deriving these lists from the runtime.
9 days ago

Fixed in 44a7c7b393, together with #1819 — the two gates now share one derived source, so they can't disagree again.

All three gaps you identified are closed:

  • Event handlers — the @ patterns really were directive-only; event binding was absent entirely. Now any @x= counts.
  • Arbitrary :attr= — was a fixed list of seven; now any colon-bound attribute with a value.
  • Composables — was useId and useReactiveProp out of 71; now every reactive runtime global, from the same derived list the auto-mount test uses.

I paid particular attention to the risk on the other side, since widening a gate that decides whether ~150KB of runtime ships is not free. There are tests pinning that a genuinely static page still gates false, including one whose only colon is an email address in body text.

Your point that the data-stx-scoped fallback runs too early to ever match is correct and now moot — the gate no longer depends on it to catch these cases.

9 days ago

Reopening — the fix is present but unreachable on the @event= path, so this shape is still live.

hasSignalsSyntax gained /\s@[a-z][\w.:-]*\s*=/i for exactly this case. The problem is when it runs. processEventDirectives (process.ts:1501) consumes every @event= attribute into id="__stx_evt_N" before processSignals (process.ts:1562) is called, and processSignals gates on hasSignalsSyntax(template) against that already-stripped output (signal-processing.ts:1587). The pattern can never match.

Isolated:

hasSignalsSyntax(original)    = true
hasSignalsSyntax(afterEvents) = false

...where afterEvents differs only in @click="doThing()" → id="__stx_evt_0".

End-to-end through processDirectives, a page whose client script defines only plain functions:

<script client>
function doThing() { console.log('hi') }
</script>
<main><button @click="doThing()">go</button></main>
ships runtime?           false
calls window.stx.mount?  true
snippet: <script data-stx-scoped data-stx-run="always"> window.stx.mount(function() { 'use strict'; function doThing() { ...

So the page still dies on its first client line with TypeError: Cannot read properties of undefined (reading 'mount').

The :attr= half of the original fix does work — colon bindings survive to the gate, which is why the two halves behave differently and why this looked fixed. Only the @-only page is still broken.

Fix is presumably to evaluate the runtime decision against the pre-event-processing template (or to have processEventDirectives record that it consumed handlers), rather than to widen the pattern further.

Found while verifying #1827; not a blocker for that fix, which handles the SPA-navigation side. This one is broken on full page load too, so no router change can rescue it.

9 days ago

Fixed in c5e681b — by taking the second of the two directions in the report ("make the contradiction impossible rather than unlikely"), because the first one cannot work.

Why widening the gate could never close this. The gate reads a template processEventDirectives has already rewritten. Isolated:

hasSignalsSyntax(original)    = true
hasSignalsSyntax(afterEvents) = false

...where the two differ only in @click="doThing()" → id="__stx_evt_0". So the /\s@[a-z]...=/ pattern added for this issue is unreachable on the @event= path no matter how many more are added. The :attr= half of the fix does work — colon bindings survive to the gate — which is exactly why this looked fixed and was not.

And as the report noted, the data-stx-scoped safety net is dead code for the same reason: the gate runs before the pass that emits that attribute.

The fix. The question is now asked of the finished output, after both decisions have been made, where no processing order can hide the answer:

if (!pageShipsSignalsRuntime(output) && outputNeedsSignalsRuntime(output))
  output = await injectSignalsRuntime(output, opts)

Keyed on an unguarded reach for window.stx, not on the presence of a client script. That distinction matters more than it looks. The obvious invariant — the one suggested here, "any template producing data-stx-scoped also gets the runtime tag" — would have pulled ~159KB onto every static page that sets a theme, because the appearance bootstrap emits a self-contained data-stx-scoped script on pages with no reactivity at all. So the predicate flags what actually throws:

window.stx.mount(function() {})        // flagged
var { state } = window.stx;            // flagged
if (!window.stx._scopes) …             // flagged

var { state } = window.stx || window;  // safe, ignored
var s = window.stx || {};              // safe, ignored
window.stx = window.stx || {};         // safe, ignored
if (window.stx) window.stx._x = null;  // safe, ignored

Every one of those "safe" spellings is real output from somewhere in the pipeline, and each is pinned.

Verified on the reported page (useStore + :data-t + two @click handlers) and on the minimal plain-function case. Static pages, <script server>-only pages and the appearance bootstrap still ship no runtime.

Tests: packages/stx/test/signal-processing/mount-implies-runtime.test.ts — the invariant stated directly, plus the negative cases, plus the guarded/unguarded predicate table.

One fixture changed, and it is worth reading. #1699's test took the first data-stx-scoped script as the user's; that page mounts, so it now gets a runtime emitted ahead of it. It's updated to skip the runtime script — but note the fixture was green while the page it renders was one of the broken ones. It only ever checked that the script body parsed, never that it could run. That's the shape of the whole issue in miniature.

Full suite green (10588 pass) with the one known pre-existing crosswind-arbitrary-values failure, verified against a clean tree.

Related: #1834 covers the other half of the ordering problem — a scriptless @click is dropped entirely, so there is no mount to force a runtime and nothing throws; it just silently does nothing.

Sign in to comment on this issue.