Summary
resources/stores/*.ts are not modules. The store loader deletes import statements with a line-anchored regex, strips export, and concatenates every store file into one shared IIFE. Three consequences follow, and the first is a silent, total failure:
- The import strip is single-line only. A multi-line import survives the strip and survives transpilation, and an
importstatement inside a classic-script IIFE is a parse-timeSyntaxError— which takes out every store in the bundle, not just the file at fault. - Because all files share one IIFE scope, every top-level name in
resources/stores/must be globally unique across all store files, with no diagnostic if two collide. - Consumers address stores by a magic string, so there is no compile-time link between a store and its type.
Impact (real app)
analyticshq's session store opens with a 24-line contract comment, because none of this is discoverable — resources/stores/session.ts:1-24:
HOW THIS FILE IS LOADED (store-loader contract -- it is not a normal module):
- Every single-line
import ... from '...'is DELETED before transpile. That is why this file imports NOTHING: its types come from types/session.d.ts as ambient declarations. A value import here would be stripped and throw ReferenceError at runtime with no build error; an import of a type would vanish just as quietly.exportis stripped and all store files are concatenated into ONE shared IIFE, so every top-level name here must be globally unique across resources/stores/.
So the file imports nothing, carries a 92-line ambient types/session.d.ts with no import and no export, and manually prefixes its top-level helpers to avoid collisions: sessionCookieSignal, sessionMigrateRaw, sessionRefreshInFlight.
The failure mode when the contract is broken is documented at session.ts:40-43:
That failure is silent in the worst way: the ReferenceError is thrown inside the store IIFE, so defineStore never completes and useStore('session') reports "Store not found" somewhere else entirely. Verified in a headless browser, not by reading the bundle.
Consumers pay too — const session = useStore('session') as SessionStore appears verbatim in six files: resources/views/account.stx:14, dashboard.stx:790, register.stx:13, pricing.stx:21, login.stx:13, sites/new.stx:17.
Evidence in stx
packages/stx/src/store-loader.ts:157 — the strip:
code = code.replace(/^import\s+.*from\s+['"][^'"]+['"]\s*;?\s*$/gm, '')^…$ with /m, and . does not cross newlines, so only a single-line import matches.
Reproduced with the loader's own transpiler configuration (store-loader.ts:138, new Bun.Transpiler({ loader: 'ts', target: 'browser' })):
input:
import {
foo,
bar,
} from './helpers'
const x = foo(bar)
after the strip at :157 → unchanged
after transformSync → import { foo, bar } from "./helpers"; const x = foo(bar);That text is then placed inside the IIFE built at store-loader.ts:200:
const code = `;(function(){\nvar __stx = window.stx || {};\n${runtimeBindings}\n${joinedChunks}\n})();`An import declaration inside a function body in a classic script is a SyntaxError at parse time — the whole bundle never executes, so every store is missing and the developer sees Store not found raised by an unrelated useStore call (signals.ts:5966-5969).
store-loader.ts:159 strips export; :199-200 joins all chunks into that single IIFE, which is the mechanism forcing globally-unique top-level names.
The runtime useStore takes a bare id — packages/stx/src/signals.ts:5962:
useStore: function(id) {
var store = window.stx._stores.get(id);
…
}Prior art
- Pinia:
export const useSessionStore = defineStore('session', () => { … })in a normal ES module — imported directly, fully inferred, no string key at the call site. - Redux Toolkit:
createSlicein a module + a typeduseAppSelector. - Zustand:
create<State>()returning a typed hook.
All three are ordinary modules with ordinary imports.
Ask
- Stop silently dropping imports. Either bundle store files properly (the
client-script-bundlerpath already exists for<script client>) or, at minimum, make the strip multi-line aware and error on any import that survives, naming the file — aSyntaxErrorthat kills every store is not an acceptable diagnostic. - Give each store file its own IIFE scope so top-level names are file-local, and report a collision when two files export the same store id.
- Support a typed store handle: allow
defineStoreto return a callable that a view can use directly, souseStore('session') as SessionStorebecomes an import with inference.stx.d.ts:557already declaresuseStore<T = any>(_id: string): T; the missing half is a module boundary that lets the type come from the store file rather than an ambient.d.ts. - Document the contract in the store scaffold, not only in the loader source.
Related: #1838 (the runtime-globals preamble for store files — that half is fixed on 0.2.155), #1780.