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 moduleThe 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 import | raw 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 level | no |
The server logs:
[stx:bundler] dropped every binding for page.stx (…); serving the unbundled source and not caching this resultCause (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