ReviewOS

stacks/stx

Component `<script client>` const that shadows an imported module export is silently misregistered (binds to the raw import, not the local derived)

#1767
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

Summary

A component <script client> that imports from a module and declares a top-level const whose name equals a top-level export of that same module silently loses its binding. bundleClientScript's Bun.build step inlines the import and renames the component's local const (foo → foo2) to avoid the clash, but scope registration then binds the template's bare foo to the raw inlined import, not the component's derived. No error, no warning — the template just reads the wrong value.

Aliasing the import (import { foo as bar }) does not help: the collision is with the module's export name as Bun inlines it, not the local alias.

Minimal reproduction

data.ts

export const items = [{ id: 1, label: 'raw' }]

Widget.stx

<script client>
  import { items as allItems } from './data'
  const items = derived(() => allItems.map(x => ({ ...x, extra: 'computed' })))
</script>
<div :for="it in items">{{ it.extra }}</div>

Expected: it.extra renders computed. Actual: items binds to the raw import (which has no extra), so it.extra is undefined.

Driving bundleClientScript(transpileTypeScript(script), 'Widget.stx', { projectRoot }) on that script emits:

// data.ts
var items = [{ id: 1, label: "raw" }];

// bundle-tmp/….ts
var items2 = derived(() => items.map((x) => ({ ...x, extra: "computed" })));

extractExports (packages/stx/src/includes.ts:156) then harvests both items and items2, and the scope-registration codegen (includes.ts:277-301) registers items = the raw array. The template's :for="it in items" binds to that raw array.

Root cause

  • packages/stx/src/client-script-bundler.ts:197 bundleClientScript — Bun.build dedupes the top-level name clash by suffixing the component's local (items → items2), keeping the inlined import as items.
  • packages/stx/src/includes.ts:156 extractExports + :277-301 scope registration — registers the bare name (items) bound to the inlined import value, not the component's declaration.

Secondary: x-html/ of undefined renders the literal string

When the mis-bound value is undefined, packages/stx/src/signals.ts:1862 does el.innerHTML = evalAttrExpr(value) with no null guard, so el.innerHTML = undefined renders the literal text undefined. (In our app this showed up as undefined clipped to "defin" inside a small circular avatar — very confusing to debug.)

Suggested fixes

  1. Primary: after Bun.build renames a colliding local, register the renamed identifier under the component's original name (re-map export names extracted from the pre-bundle source), or namespace the inlined imports so they can never shadow component-local top-level declarations.
  2. Or fail-loud: detect a component top-level const/let/var that shadows an imported module export and emit a clear compile-time warning/error instead of silently misregistering.
  3. Defensive (independent): in the x-html/:text handlers (signals.ts:1862), coerce null/undefined to '' before assigning, so a broken binding degrades to empty rather than rendering the string undefined.

Version

@stacksjs/stx 0.2.79. Reproduced offline against packages/stx/src directly (no dev server).

Sign in to comment on this issue.