ReviewOS

stacks/stx

Bundler serves a script unbundled — leaving a raw import that kills it — when its only declarations are nested on their own line

#1959
Closed glennmichael123 opened this 10 days ago ·
10 days ago

A <script client> with an import is served unbundled when its only declarations are nested inside a callback and each sits at the start of its own line. The raw import then reaches the browser inside a classic script, which rejects the entire block:

Uncaught SyntaxError: Cannot use import statement outside a module

The whole component script dies, not just one binding. The only trace is a server-side log line.

Found while verifying #1957. Pre-existing: reproduced on unmodified main.

Repro

bundleClientScript on four variants of the same script, all importing greet from a local module:

script body after the importraw import left in output
setTimeout(function () {
var msg = greet()
…
}, 0)
yes
setTimeout(() => {
const msg = greet()
…
}, 0)
yes
setTimeout(function () { var msg = greet(); … }, 0) (declaration mid-line)no
const msg = greet() at top levelno

The server logs:

[stx:bundler] dropped every binding for page.stx (…); serving the unbundled source and not caching this result

Cause (from reading the code)

In buildBundle (packages/stx/src/client-script-bundler.ts), a safety net guards against a build that lost its output:

if (declNames.size > 0 && exposedBindings.length === 0) {
  // … serving the unbundled source and not caching this result
  return code
}

declNames comes from a line-anchored scan for const / let / var / function / class. It can't see nesting, so a declaration at the start of a line inside a callback counts as top-level. Bun correctly exposes nothing for a nested local, so exposedBindings is empty. The check concludes the build dropped everything and returns the raw source, import and all.

That's why the mid-line form escapes: the anchored scan doesn't match it. The top-level form escapes because it really is exposed.

Impact

The triggering shape is ordinary. Any script whose top level is only a call, with the work inside a multi-line callback, hits it:

import { api } from '../functions/api'
onMount(async () => {
  const data = await api.get()
  // …
})

Suggested fix

Make the declaration scan agree with what can actually be exposed: count only depth-0 declarations. The comment/literal-stripped, bracket-depth approach from #1953 (stripCommentsAndLiterals plus depth tracking in client-script.ts) is a ready template. Alternatively, derive the expected names from the transpiler's own scan of the source rather than a regex.

The safety net itself is worth keeping. It exists because an empty cached bundle would otherwise outlive the transient failure that caused it. It just needs to stop firing on correct builds.

🤖 Generated with Claude Code

10 days ago

Fixed in aa3162f1d5.

The export scan now runs over the comment- and literal-stripped source and keeps a declaration only at bracket depth zero:

script body after the importbeforeafter
callback, var on its own lineraw importbundled
callback, const on its own lineraw importbundled
callback, declaration mid-linebundledbundled
top-level constbundledbundled

The onMount(async () => { const data = … }) shape now bundles in both modes, and the "dropped every binding" fallback no longer fires on it.

One implementation, not two. The depth helper moved out of client-script.ts (#1953) into strip-literals.ts, so the payload bridge and the bundler share it.

The part a quick fix would have got wrong. stripCommentsAndLiterals doesn't recognise regex literals, so /\{/ leaves a brace behind and every later depth reads one too deep. Trusting that would drop a real top-level binding from the export list, and Bun would tree-shake it — worse than this bug. So the helper now reports whether its brackets balance. When they don't, both callers fall back to exactly what they did before they knew about nesting.

Both halves were checked against broken versions:

  • Removing the depth filter fails both reported shapes.
  • Removing the fallback fails the regex-brace case, in the bundler and in the bridge, where it would have emitted a duplicate binding.

The safety net itself is kept. An empty cached bundle would otherwise outlive the transient failure that caused it; it just no longer fires on correct builds.

🤖 Generated with Claude Code

Sign in to comment on this issue.