ReviewOS

stacks/stx

Three client bundles assign window.stx wholesale, clobbering each other — 16 of 82 auto-imported names are undefined at runtime

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

Summary

Three separate client bundles each assign window.stx wholesale rather than merging into it. Whichever runs last silently erases the other two. The result is that 16 of the 82 names in STX_AUTO_IMPORTS are undefined at runtime — the compiler destructures them from window.stx for you, so you get undefined is not a function at the call site with nothing pointing at the cause.

The four assignment sites:

FileLineStatementMerges?
src/reactivity.ts995window.stx = { ref, reactive, computed, watch, … }✗ overwrite
src/state-management.ts1221window.stx = s;✗ overwrite
src/signals.ts5458window.stx = { hydrate, state, derived, effect, … }✗ overwrite
src/client-helpers.ts14window.stx = window.stx || {}✓ merge

Only client-helpers.ts does it correctly — and it loses anyway, because it merges first and is then overwritten by one of the other three.

Proof it is clobbering, not absence

This is the measurement that distinguishes the two. On a rendered page, onBeforeMount is present in an inline script — its bundle loaded and ran — yet it is not on window.stx:

rendered      : signals(hydrate)=function reactivity(onBeforeMount)=undefined
                statemgmt(createStore)=undefined helpers(formatCurrency)=undefined
js requested  : runtime.js, router.js
inline blocks : 9
any inline defining onBeforeMount: true      <- the bundle IS on the page

signals.ts is the winner (hydrate survives). reactivity.ts, state-management.ts and client-helpers.ts are all erased.

Reproduction

Any <script client> block:

<script client>
const r = state('')
r.set(['hydrate', 'onBeforeMount', 'createStore', 'formatCurrency']
  .map(n => n + '=' + typeof window.stx?.[n]).join(' '))
</script>
<main><p>{{ r() }}</p></main>

→ hydrate=function onBeforeMount=undefined createStore=undefined formatCurrency=undefined

The 16 dead names

Enumerated by parsing STX_AUTO_IMPORTS out of client-script.ts:308 and testing each against window.stx in a live page:

promised by auto-import : 82
NOT provided at runtime  : 16
isDerived, useMeta, watchMultiple, onBeforeMount, onBeforeUpdate, onUpdated,
inject, getCurrentInstance, onErrorCaptured, useSlots, useAttrs,
createStore, action, createSelector, h, Fragment

They fail for two different reasons, and the fix differs:

Clobbered — defined in a bundle that loads and is then overwritten:

NameOwner
onBeforeMount, onBeforeUpdate, onUpdated, watchMultiple, getCurrentInstancereactivity.ts
createStore, createSelectorstate-management.ts

Never exposed to the client at all — in STX_AUTO_IMPORTS but absent from every client window.stx literal:

NameWhere it lives
isDerivedsignals.ts imports it from signals-api for server use only
useMetacomposables/use-meta.ts — server-only module tree
hjsx-runtime.ts — not bundled to the client
inject, onErrorCaptured, useSlots, useAttrs, action, Fragmentno implementation found anywhere

The sharpest one

provide works and inject does not.

provide=function  inject=undefined

provide is implemented twice (reactivity.ts and signals.ts), so it survives on the winner. inject is implemented nowhere despite being auto-imported. Half of a provide/inject pair is worse than neither — the first call succeeds, so the failure surfaces only in the consumer.

Note provide is also window[name] = value (reactivity.ts:990), i.e. a global write with no scoping, which is a separate concern.

Why this is hard to debug downstream

Because these names are auto-imported, an app author never writes an import for them. onBeforeMount(() => …) reads as a supported API, matches the documented Vue-compat surface, and fails at runtime as undefined is not a function with no indication that the name was destructured off an object that a later bundle replaced.

Suggested fix

  1. Make every site merge: window.stx = Object.assign(window.stx || {}, { … }). That alone revives the 7 clobbered names.
  2. Derive STX_AUTO_IMPORTS from what the runtime actually exposes, or add a build-time assertion that every entry resolves. The list is hand-maintained in client-script.ts:308 and has drifted from three separate runtime bundles with nothing checking agreement.
  3. Either implement inject/useSlots/useAttrs/onErrorCaptured/action/Fragment or remove them from the auto-import list. Promising a name that cannot resolve is worse than not offering it.

Point 2 is the durable one — 1 and 3 fix today's drift, 2 stops it recurring.

Found while migrating a production stx app off vanilla JS onto the framework's own primitives. Related: #1803, #1797, #1795.

9 days ago

Fixed in 80101bf418.

Verified the 16 independently — same list. One correction to the diagnosis, though: clobbering is not what makes them undefined. The signals runtime is the only one of those bundles actually injected on a rendered page, and it wins; the 16 were never on window.stx at all. Clobbering is a real hazard (any ordering change silently drops keys) but it is currently latent, so both got fixed for different reasons.

Your point 2 was the right one to build on, and it's now done the durable way: STX_AUTO_IMPORTS is STX_RUNTIME_GLOBALS. That list already carries the #1785 guard asserting every name exists on window.stx, so the auto-import path inherits it. One list, one guard.

Of the 16, five were cheap and already declared, so the runtime grew them instead of dropping them: isDerived, inject, useSlots, watchMultiple, onBeforeMount. inject reads from a private registry as well as window, because — as you noted — provide writing window[name] collides with native properties (provide('name', x) lands on window.name, which the browser coerces to a string). watchMultiple is not aliased to $watch: $watch spreads its values and passes no oldValues, so callers written against the declared contract would have got the wrong arguments.

The other 11 are gone from the list, per your point 3.

Also fixed a fourth case the issue doesn't mention: untrack, peek, isSignal and setRouteParams were on window.stx and in the auto-import list but missing from the setup-script destructure — so they resolved in a classic <script> and threw ReferenceError inside a signals setup script. Same bug class as #1785, still live for those four.

All three wholesale assignments now merge. The merge also preserves object identity, which matters because the generated store runtime captures window.stx in a closure — replacing the object leaves that closure calling a detached one.

One thing worth flagging for future readers: five tests located the runtime surface by searching for the literal text window.stx = {, and every one returned an empty set on a miss. Changing the assignment shape would have turned the #1785 drift guard into a test that passed because it checked nothing. They now share one extractor that throws.

Sign in to comment on this issue.