ReviewOS

stacks/stx

fix(store-loader): stores are not modules — a multi-line import survives the strip and kills every store with a SyntaxError

#1859
Closed glennmichael123 opened this 15 days ago · 2 comments
15 days ago

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:

  1. The import strip is single-line only. A multi-line import survives the strip and survives transpilation, and an import statement inside a classic-script IIFE is a parse-time SyntaxError — which takes out every store in the bundle, not just the file at fault.
  2. 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.
  3. 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.
  • export is 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: createSlice in a module + a typed useAppSelector.
  • Zustand: create<State>() returning a typed hook.

All three are ordinary modules with ordinary imports.

Ask

  1. Stop silently dropping imports. Either bundle store files properly (the client-script-bundler path already exists for <script client>) or, at minimum, make the strip multi-line aware and error on any import that survives, naming the file — a SyntaxError that kills every store is not an acceptable diagnostic.
  2. 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.
  3. Support a typed store handle: allow defineStore to return a callable that a view can use directly, so useStore('session') as SessionStore becomes an import with inference. stx.d.ts:557 already declares useStore<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.
  4. 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.

15 days ago

Fixed in 0dab8ed.

Reproduced exactly:

single-line stripped: "export const x = 1"
multi-line  stripped: "import {\n  state,\n  derived,\n} from 'stx'\nexport const x = 1"

And the reason it gets all the way to the browser: import is legal at a module's top level, so Bun's transpiler passes it through too. It only fails once the body reaches the shared IIFE — where it is a SyntaxError that takes every store in the bundle with it, surfaced as a misleading "Store not found".

The fix hinges on one detail worth recording: [^'"] between import and from is what stops a multi-line match running past its own statement into the next one's specifier. The clause never contains a quote; the module specifier always does. Side-effect imports (import './setup') are handled now too — neither pattern stripped those before.

You were right that composable-loader.ts:174 carries the byte-identical regex. Rather than fix it twice, the strip moved into store-imports.ts, a leaf that already owns import transformation — fixing one copy and leaving the other is precisely how this recurs.

End-to-end: a store written with your multi-line import now produces a bundle that parses. Full suite 10818 pass.

15 days ago

Confirmed, and it directly shaped bench's store architecture. The in-code note at resources/stores/auth.ts:123-129 documents the exact failure:

the stx store compiler doesn't follow cross-directory value imports cleanly, and the previous attempt at extracting [the shared fetch helper] into resources/lib/ blew up with apiFetch is not defined at scope-setup time.

So the shared _apiFetch helper is inlined into the store module (auth.ts:132) rather than imported, and every cross-store dependency is resolved lazily at call time via useStore('auth') to dodge the same module-boundary breakage. Two other module-strip footguns we hit in the same vein: a multi-line const inside a store/script silently dropped from scope, and backticks inside // comments flipping the merged setup function into template-literal mode. Treating stores as real modules would remove all of it.

Sign in to comment on this issue.