ReviewOS

stacks/stx

Each component bundle inlines its own copy of shared imports: module state is per component, and pages ship the same code several times

#1957
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Follow-up to #1780. Imports in <script client> work now: each component's client script is bundled with its imports (client-script-bundler.js runs one Bun.build per script, entrypoints: [tmpEntry], no splitting). The consequence is that two components importing the same module each get their own inlined copy of it.

That has two effects:

  1. Module state exists once per component, not once per page. A cache, a registry or an in-flight promise in a shared module is silently duplicated. Nothing warns about it, and it reads like ordinary module code.
  2. Pages ship the same code several times. Nothing is shared or cached between bundles.

What it looked like in a real app (stx 0.2.286)

WildLoop's profile page is 456 KB of HTML, 389 KB of it inline JS across 30 scripts. Our auth client module is inlined into four bundles: the page and layout, the nav, the mobile header and the sign-in sheet. The nav, the mobile header and the sign-in sheet render on every page, so every page carries at least three copies.

Because each copy had its own state:

  • the session was restored once per copy, so one page load made 5 GET /api/me calls and dispatched 5 auth-ready events
  • the nav and the mobile header refetched notifications on each event: 13 GET /api/notifications for one badge
  • a sign-out hook registered through one copy was invisible to the copy that performed sign-out, so it never ran

We worked around it by keeping that state on a globalThis singleton. Duplicate requests went from 21 to 6 on that page, but the page weight is unchanged.

Minimal shape

// resources/functions/counter.ts
let count = 0
export function bump() { return ++count }
<!-- components/A.stx and components/B.stx, both on one page -->
<script client>
import { bump } from '../functions/counter'
console.log(bump())
</script>

Both log 1, and view-source shows counter.ts twice. With one shared module instance, the second would log 2.

Expected

Shared imports are bundled once per page and executed once, so module-level state is page-wide the way @stores and @composables already are. They are external to the per-component bundles today.

Possible directions

  • Build all client scripts on a page in one Bun.build call with splitting: true. Emit the shared chunk as one content-hashed asset, as #1878 did for the stylesheet, so it is also cached across pages. This assumes the component scripts can run as modules.
  • Or keep per-component bundles, but mark project-local modules imported by more than one component as external and serve them from a page-level module registry, like window.__composables.
  • At minimum, document that each component gets its own module instance, and warn when the same module is inlined into more than one bundle on a page.

A related but separate size issue: @stacksjs/mobile ships as a single dist/index.js, and importing only secureStorage pulls in about 25 KB of the bridge. In our auth module that's 25 KB of the 38 KB, so every duplicated copy multiplies it.

9 days ago

Fixed in 166d36b486 + 580498bffa, taking your second direction.

Components' direct imports are now served by a page-level module registry. Each import becomes a read of globalThis.__stxModules, and one page-level bundle, injected ahead of the store and composable bundles, imports each module once and registers it. So each module is bundled once and evaluated once per page.

Your minimal shape, in headless Chrome through the real pipeline:

first=1 second=2 | module copies in page: 1

The sign-out-hook case is pinned too: a hook registered through one component now runs when another component signs out. So is a session module holding state(), where one component writes and another reads it through a single signal instance, against the real runtime.

Why this direction and not a single split ES-module build: component bundles are classic IIFE scripts. type="module" defers execution, and the SPA router re-executes page scripts, which modules cannot be.

A few decisions worth knowing:

  • The page learns what to serve by scanning its own output for registry reads. Component bundles are cached per script, and a cache hit never runs the build that would report its imports.
  • Ids are paths relative to the project, or npm:<specifier>. Never absolute, which would leak the server's filesystem layout into every page.
  • It survives SPA navigation. The registry script travels with the page, and a guard stops it re-evaluating modules when it runs again. A module's top-level code runs once, not on every navigation.
  • A missing module throws, naming the module, at the component's first line, instead of producing an undefined binding somewhere later.

Semantics that changed: module state now persists across SPA navigation, which is true ES-module behaviour. A reassigned exported let is not live across components, though functions and objects are, which covers all the reported cases.

Not covered, deliberately:

  • A module imported by both a store and a component still has two instances. Stores keep inlining. The store bundle is already one per page, and it is also fetched on its own for store HMR (/_stx/stores.js), where a store edited to add an import would read a module the running page never registered. Closing that needs HMR to ship the registry too.
  • The @stacksjs/mobile single-dist size point is untouched. It's a separate package's build. Each duplicated copy no longer multiplies it, though, since it now ships once per page.

Found while verifying, and pre-existing on main: the bundler serves a script unbundled when a var is declared inside a callback. Its "a script that declared something must come back with it" check mistakes the nested declaration for a top-level binding, decides the build dropped everything, and falls back to the raw source. That leaves an import in a classic script, and the browser rejects the whole component with Cannot use import statement outside a module. It's filed separately.

🤖 Generated with Claude Code

Sign in to comment on this issue.