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:197bundleClientScript—Bun.builddedupes the top-level name clash by suffixing the component's local (items→items2), keeping the inlined import asitems.packages/stx/src/includes.ts:156extractExports+:277-301scope 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
- Primary: after
Bun.buildrenames 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. - Or fail-loud: detect a component top-level
const/let/varthat shadows an imported module export and emit a clear compile-time warning/error instead of silently misregistering. - Defensive (independent): in the
x-html/:texthandlers (signals.ts:1862), coercenull/undefinedto''before assigning, so a broken binding degrades to empty rather than rendering the stringundefined.
Version
@stacksjs/stx 0.2.79. Reproduced offline against packages/stx/src directly (no dev server).