Skip to content

bake: do not abort on a "use client" module without a separate SSR graph - #42885

Open
robobun wants to merge 4 commits into
robobun/4def93c6/use-server-build-errorfrom
robobun/817288d6/use-client-single-graph-abort
Open

robobun wants to merge 4 commits into
robobun/4def93c6/use-server-build-errorfrom
robobun/817288d6/use-client-single-graph-abort

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #42826 (three commits on top of its branch).

Problem

  • With serverComponents.separateSSRGraph: false, a "use client" module aborts the process: panic: TODO: unique_key here (src/js_parser/p.rs:8624). Every bun build --app hits it. So does the dev server when the file has a /** @jsxRuntime react-jsx */ pragma.
  • wrap_value_for_server_component_reference picks the path of registerClientReference(value, path, name) by jsx.development. The pragma turns that flag off. The other branch is the todo_panic!.
  • Behind it, a production build aborts again: panic: index out of bounds: the len is 7 but the index is 4294967295.

Fix

Background

  • "use client" marks a client component. Server code gets a reference to it. The browser loads the module.
  • With separateSSRGraph: true (built-in React) the bundler generates a proxy module for the server. With false the parser wraps the exported values in registerClientReference.
  • path is what the browser imports: the source path in the dev server, a chunk URL in production (bake.d.ts:186-202). The true mode writes a placeholder that the linker replaces. The false mode has no such code.
Notes

I found this by a read of the todo_panic! sites around #42826. There is no user report. The built-in React framework sets separateSSRGraph: true, so only a custom framework reaches this code.

The new build error:

1 | "use client";
    ^
error: "use client" is not supported yet in a production build unless 'framework.serverComponents.separateSSRGraph' is true
    at /tmp/uc/Button.ts:1:1

Checked by hand on 1.4.3-canary.1+09bb54630 (release) and on a debug build of this branch. The framework is minimalFramework from test/bake/bake-harness.ts (separateSSRGraph: false). After the rebase onto 49bc7cf (the head of #42826), I ran the tests again. The 3 new tests fail on 1.4.3-canary.1+367d939d9 and on a debug build of the base branch. They pass on a debug build of this branch.

case canary this branch
bun build --app, "use client" module with export function panic: TODO: unique_key here, exit 134 build error, exit 1, no dist
bun build --app, "use client" module with only export { Button } panic: index out of bounds: the len is 7 but the index is 4294967295 build error, exit 1, no dist
dev server, "use client" module, no pragma 200 200
dev server, the same module saved with /** @jsxRuntime react-jsx */ panic: TODO: unique_key here, every route gone 200, same client references
dev server, the pragma present at start-up panic on the first request 200
dev server, @jsx h, @jsxRuntime classic, @jsxRuntime automatic, or "jsx": "react-jsx" in tsconfig.json 200 200

The second abort: add_server_component_boundaries_as_extra_entry_points makes the SSR index of each client boundary an entry point. This mode has no SSR copy, so the index is Index::INVALID, and LinkerGraph::load indexes the source list with it. todo_panic!("separate_ssr_graph=false") in process_server_component_manifest_files (bundle_v2.rs) stays on purpose, as the marker of the missing feature. The check in ParseTask.rs makes it unreachable.

Why production reports an error and does not write a module id:

Self-review. The concern that I did not take: fold this commit into #42826. I stacked it. #33589 can replace the "use server" error of #42826, and this fix does not depend on that decision. The concerns that I took:

Related:

  • bun build --server-components without --app has no framework. A "use client" file aborts there with panic: called `Option::unwrap()` on a `None` value, at the unwrap() of topts.framework in ParseTask.rs, before the check of this PR. bun build --server-components: require the bun target and reject directives without a framework #38046 owns that abort: it removes both unwraps and reports a build error. It adds an arm to the same if chain, so the second of the two to land needs a rebase.
  • The branch robobun/0eb47e53/use-server-build-error has the dev server half of this fix in another form (commit afab10d reads features.hot_module_reloading and keeps the todo_panic! for production). This PR replaces that commit.
  • A file that is only the 12 bytes "use client" (no semicolon) builds under bun build --server-components, as on main. 5c5e077 in bundler: report a "use server" module as a build error instead of aborting #42826 restored that, and this branch has it since the rebase.
  • With separateSSRGraph: false the parser wraps export default, export function and export const. It does not wrap export { a }, export class A {} or export { a } from, so server code gets the raw value. That is a different bug (bake: with separateSSRGraph: false, a "use client" module only gets client references for three export forms #42886). This PR does not change it.
  • With separateSSRGraph: true, the dev server can abort with panic: Server Incremental Graph is missing component for "" when a route imports a "use client" module that imports another "use client" module. It aborted in 5 of 20 runs on 1.4.3-canary.1+367d939d9. The panic is in src/runtime/bake/dev_server/incremental_graph.rs, which this PR does not touch. No open issue or PR names that panic.

Suites run with the debug build: test/bake/dev/bundle.test.ts, production.test.ts (with --timeout 120000, because a React production build takes about 5 s on a debug build), ecosystem.test.ts (the Svelte islands fixture uses this mode), response-to-bake-response.test.ts, test/bake/dev-and-prod.test.ts, test/bake/app-options.test.ts.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/production.test.ts

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on canary (1.4.3-canary.1+09bb54630, and again on 1.4.3-canary.1+367d939d9) with the minimalFramework from test/bake/bake-harness.ts (separateSSRGraph: false), a route that imports Button.ts, and Button.ts:

"use client";
export function Button() {
  return "button";
}
  1. bun build --app ./app.ts --outdir ./dist aborts with panic: TODO: unique_key here, exit code 134.
  2. With export { Button } in place of the exported function, the same build aborts with panic: index out of bounds: the len is 7 but the index is 4294967295.
  3. Bun.serve({ app, development: true }) answers GET / with 200. Save /** @jsxRuntime react-jsx */ as the second line of Button.ts. The process aborts with panic: TODO: unique_key here.

The new tests cover these cases. They fail on canary and on the base branch (#42826, head 49bc7cf). They pass with the debug build of this branch:

USE_SYSTEM_BUN=1 bun test test/bake/dev/bundle.test.ts -t "jsxRuntime pragma"                 # 1 fail
USE_SYSTEM_BUN=1 bun test test/bake/dev/production.test.ts -t "without a separate SSR graph"  # 2 fail
bun bd test test/bake/dev/bundle.test.ts test/bake/dev/production.test.ts                     # pass

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked that removing the jsx.development branch in wrap_value_for_server_component_reference (src/js_parser/p.rs) is only reachable through the dev server: WrapExportsForClientReference is set solely in ParseTask.rs, and the new guard keys on has_dev_server(), which is the bake.DevServer pointer — so every --app/bake production build with separateSSRGraph: false now fails at the directive before the parser runs.

Extended reasoning...

Findings were already posted inline (the --server-components-without---app unwrap path that still aborts before the new guard, and the serial test.each cost in production.test.ts). This note only records one additional check: I traced the setters of server_components (Arguments.rs, build_command.rs, bake_body.rs, bake/mod.rs) and the has_dev_server() accessor in src/bundler/options.rs, and confirmed the p.rs todo_panic! removal cannot be reached from a non-dev-server bundle except via the path the inline finding already names.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/bundler/ParseTask.rs — Users running bun build --server-components without --app still get a process abort on any "use client" file, not the new build error. The new guard is computed at ParseTask.rs:2591 only after the .unwrap() chain on topts.framework at ParseTask.rs:2570-2577 has already run, and that framework is None on the non-bake CLI path. Fix: evaluate the unsupported-directive check (or an explicit "requires a framework" error) before the server_components match and replace both unwraps with a returned log error, so every "use client" abort in this mode, with or without a framework, becomes a diagnosable error.

    Extended reasoning...

    The PR title says a "use client" module must not abort the process, and its description says the message is worded so it stays true without a framework because #38046 makes that case reachable. That case is already reachable today: --server-components is a documented flag (Arguments.rs:538) and sets ctx.bundler_options.server_components = true (Arguments.rs:2633), copied into the transpiler at build_command.rs:197 with options.framework left None. In parse_worker, use_directive is parsed at ParseTask.rs:2388-2393 because topts.server_components is true. Target is Bun, so the match at ParseTask.rs:2567 reaches the UseDirective::Client arm and calls topts.framework.as_ref().unwrap() at 2570-2573, panicking. The unsupported_directive match at 2591 is never reached. The dismissing finders confirmed the base panics identically (I verified with git show of the base file: same lines), but the PR claims "both aborts are unreachable" and the review guidance requires fixing the whole class of user-reachable panics in the same PR. Trigger: `bun build --server-components…

    Verification: pre-existing (the base aborts by the same route; the PR's new guard simply does not reach this path, and REVIEW.md's "fix the whole class in the same PR" rule applies because the PR's own error message is worded for exactly this no-framework case). Trigger: bun build <entry> --target=bun --server-components (no --app, so no framework) where any JavaScript-like module in the graph starts…

Comment thread test/bake/dev/production.test.ts Outdated
Comment thread src/bundler/ParseTask.rs Outdated
Comment thread src/js_parser/p.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, self-contained conversion of two user-reachable aborts into a build error, and the serial test.each from the earlier round is now test.concurrent.each.

What was reviewed:

  • The has_dev_server() gate in src/bundler/ParseTask.rs: traced options.dev_server to its only setters in src/runtime/bake/DevServer.rs (server/client/ssr transpilers), so the dev server keeps bundling and bun build --app fails at the directive; it matches the sibling unique_key gate at ParseTask.rs:1364.
  • WrapExportsForClientReference is set only at ParseTask.rs:2581, and wrap_value_for_server_component_reference is reached only in that mode, so dropping the jsx.development branch in src/js_parser/p.rs cannot expose a production path.
  • The match is exhaustive over UseDirective, and the error path is the same add_range_error + ParserError sequence the existing "use server" arm used.
Extended reasoning...

Overview

The PR touches two Rust sites and two test files. In src/bundler/ParseTask.rs the existing "use server" early error is generalized into an unsupported_directive match that also fires for a "use client" module when the parser mode is WrapExportsForClientReference (i.e. separateSSRGraph: false) and there is no dev server. In src/js_parser/p.rs the registerClientReference wrapper now always writes self.source.path.pretty instead of branching on jsx.development and hitting todo_panic! in the other branch. Tests add a dev-server case (pragma toggling the JSX dev flag on a client module across a hot rewrite) and a production build case in two export shapes asserting the new error, its location, no dist output, and exit code 1.

Security risks

None. The change only affects diagnostics and which build-time code path is taken; no input parsing, path handling, or privilege-sensitive code is involved. The error message is a static byte string.

Level of scrutiny

Moderate. The main risk is that the new gate either fails dev-server builds that previously worked or leaves a production path that still reaches the removed branch. I traced options.dev_server: it is set on the server, client and SSR transpilers in src/runtime/bake/DevServer.rs and defaults to null in src/bundler/options.rs, so has_dev_server() is true exactly for dev-server parses, consistent with the sibling unique_key selection at ParseTask.rs:1364. WrapExportsForClientReference is assigned only at ParseTask.rs:2581 and the wrapper in p.rs is only invoked in that mode (the symbol is declared at p.rs:3480 only for it), so production can no longer reach the wrapper. The match on UseDirective is exhaustive, and the error path reuses the add_range_error + Err(ParserError) sequence from the pre-existing arm. The remaining todo_panic!("registerServerReference") is unreachable because UseDirective::Server errors first.

Other factors

The single prior comment from this system (serial test.each) was addressed in b29bec4. No CODEOWNERS entry covers the changed files. Tests live in the existing bake test files, drain stderr before asserting the exit code, use tempDir, and assert the exact message and :1:1 location. The PR is stacked on another unmerged branch, but the diff reviewed here is self-contained relative to that base. The hunt exited on dry_streak with no findings.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Replies to the review findings.

bun build --server-components without --app still aborts on a "use client" file. Confirmed: panic: called `Option::unwrap()` on a `None` value, at the unwrap() of topts.framework in ParseTask.rs, before the check of this PR. I did not fix it here. #38046 is open for that abort. It removes both unwraps, reports a build error for a directive without a framework, and has the tests for the CLI path. A second fix in this PR would conflict with it in the same lines. The two aborts that this PR makes unreachable are the ones with a framework that sets separateSSRGraph: false. The notes in the description now say this, and they no longer say that the case without a framework is unreachable today.

Serial test.each in production.test.ts. Done in b29bec4: the two cases run with test.concurrent.each. I left the "use server" matrix as it is, because it belongs to #42826.

Comments of more than one line. Done in 7eb7c36: each new comment in ParseTask.rs and p.rs is one line.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, self-contained change that turns two process aborts into a reported build error, and the concurrency nit from the previous round is addressed by b29bec4.

What was reviewed:

  • The unsupported_directive match in src/bundler/ParseTask.rs is exhaustive and sits at the only site that assigns features.server_components, so every WrapExportsForClientReference parse in a non-dev-server build is rejected before get_ast runs; the "use server" branch keeps its prior message and range.
  • wrap_value_for_server_component_reference in src/js_parser/p.rs is only reachable in wraps_exports() modes, and the server-reference mode still panics before the changed line, so dropping the jsx.development branch only affects the dev-server client-reference path now covered by the new devTest.
  • The new production test asserts the exact message, the :1:1 location, no dist output, and exit code 1 for both the exported-function and export-clause fixtures; framework.serverComponents.separateSSRGraph in the message matches the real option name in src/runtime/bake/bake.d.ts.
Extended reasoning...

Overview

The PR touches two Rust files and two test files. In src/bundler/ParseTask.rs the existing "use server" range-error branch becomes an Option<&'static [u8]> computed by a match on use_directive, adding a UseDirective::Client arm that fires only when the computed mode is WrapExportsForClientReference (i.e. separateSSRGraph: false) and topts.has_dev_server() is false. In src/js_parser/p.rs, wrap_value_for_server_component_reference no longer branches on jsx.development and always emits self.source.path.pretty as the module id. Tests add a dev-server case (pragma flips jsx.development off for one file) and a test.concurrent.each production matrix.

Security risks

None identified. The change only affects the bake bundler pipeline for "use client" modules under a non-default custom-framework mode; it converts an abort into a logged build error and otherwise emits the same dev-server output. No user-controlled data reaches a new sink; the error message is a static byte string with a range pointing at the directive.

Level of scrutiny

Moderate. I traced that features.server_components is assigned only in ParseTask.rs (line ~2563), so the new guard covers every producer of the mode the parser change relies on. The parser function has six callers in visit_stmt.rs, all gated by wraps_exports(); the WrapExportsForServerReference case still hits its todo_panic! before the changed line, and "use server" is rejected earlier in ParseTask anyway, so the removed branch is unreachable outside the dev server. The match is exhaustive (Client | None => None), and has_dev_server() is the same predicate used elsewhere in the file for dev-vs-production decisions. No debug build was present in this checkout, so I did not execute the tests; the assertions were checked by reading them against the source.

Other factors

The one inline concern from the prior round (serial test.each) was addressed in b29bec4 with test.concurrent.each; the subsequent commit only trims comments. No CODEOWNERS entry covers the changed files. The PR is stacked on an unmerged base (the "use server" error and UseDirective::range helper), but this diff only contains the commits on top of that base and does not depend on any decision beyond it landing first. The remaining todo_panic!("separate_ssr_graph=false") in bundle_v2.rs becomes unreachable with this guard, which is consistent with the PR's stated scope of not implementing production support for this mode.

With `separateSSRGraph: false` the parser wraps the exported values of a
"use client" module in `registerClientReference(value, path, name)`. It
chose `path` by `jsx.development`. A `@jsxRuntime react-jsx` pragma
turns that flag off for one file, so the dev server reached
`todo_panic!("unique_key here")`, the branch for production builds.

The parser now always writes the source path. Only the dev server
bundles in this mode. A production build reports the directive as a
build error, next to the "use server" error, because no later step
implements this mode there (#14763).
@robobun
robobun force-pushed the robobun/817288d6/use-client-single-graph-abort branch from 7eb7c36 to acd83e1 Compare September 26, 2026 07:02
@robobun

robobun commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:57 AM PT - Sep 26th, 2026

✅ @robobun, your commit 6746fdc1ef3fbd3415ddd97de2da934d254c4cb5 passed in Build #120909! 🎉


🧪   To try this PR locally:

bunx bun-pr 42885

That installs a local version of the PR into your bun-42885 executable, so you can run:

bun-42885 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant