Skip to content

js_parser: attach the generated default name to an anonymous default function only in a bundle - #34932

Draft
robobun wants to merge 10 commits into
mainfrom
farm/14e68797/export-default-fn-name
Draft

robobun wants to merge 10 commits into
mainfrom
farm/14e68797/export-default-fn-name

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Outside a bundle, an anonymous export default function has the .name "<file>_default" ("mod_default" for mod.mjs). ECMA-262, node and two test262 files say "default".
  • s_export_default (src/js_parser/visit/visit_stmt.rs:666) writes the generated binding into the function name.

Fix

  • It now sets that name only when p.options.bundle is set. Without a bundle JSC names the function "default".
  • 0 of 77 bundle outputs differ from main. A debug assertion keeps the readers of the name in bundles.
  • The runtime transpiler cache version goes from 43 to 44.
  • Verified: five test files, 6 cases fail on main. Self-reviewed: 24 concerns raised, 22 addressed, 2 wait for the rule below.

Background

Downsides

  • Merge only after the rule that shows *default* as default is on main (Name anonymous export default functions and classes "default" in stack traces and inspect #38517, in conflict today). Alone, a frame reads at starDefault before a read of .name and at default after. Main shows <file>_default.
  • These stay with that rule: the mock name is "", a CPU profile says default with no file hint, and a stored snapshot of such a function fails under CI=1.
  • The new cache version costs one re-transpile per cached file (+174 ms for 9.1 MB).
Notes

Repro.

// mod.mjs
export default function () {}
import self from "./mod.mjs";
console.log(JSON.stringify(self.name));
// node mod.mjs        -> "default"
// bun 1.4.4 mod.mjs   -> "mod_default"
// this branch         -> "default"

The async, generator and async generator forms have the same defect. export default class {} and export default () => 1 are already right on main.

Scope. The claim is for the four forms of an anonymous export default function declaration: plain, async, generator and async generator. Two test262 files cover it, test/language/module-code/instn-named-bndng-dflt-fun-anon.js and instn-named-bndng-dflt-gen-anon.js. With a preload that defines assert.sameValue, both fail on bun 1.4.4 and on main da867c6 and pass on this branch. Other cases of function names that Bun gets wrong are not part of this change (see "Not fixed here").

Who asked. Nobody reported this. I found no issue from a user about the .name, about String(fn) or about the collision. #44721 described the collision of the generated symbol, and Jarred-Sumner closed it as not planned on 2026-10-08: "GitHub issues are for user-facing issues and a user did not run into this issue". The reach is measurable: d3 7.9.0 exports 696 functions, and 210 of them are anonymous default functions. They are named <file>_default on main and default on this branch and on node v26.3.0.

The condition. bundle is set only by the bundler (src/bundler/ParseTask.rs:2438, bundle_v2.rs:7462), in front of the features that read the name. So bun <file>, bun build --no-bundle and Bun.Transpiler leave the function anonymous, and every bundler parse runs the same statements as on main. The linker sets the name again when it strips the export (convertStmtsForChunk.rs), so a bundle keeps function mod_default() {} and .name stays the generated name there. The condition and the Bun.Transpiler test case come from commit 33ef9bd (branch robobun/da68edbd/anonymous-default-export-name, described in the comment of 2026-10-08 on this PR).

The assertion. Four features read the name: hot module reloading (lower_esm_exports_hmr.rs:234 unwraps it), React Fast Refresh, the React Compiler and the server components wrapper. Only ParseTask turns them on (ParseTask.rs:2503, :2507, :2509, :2520), after it sets bundle. Nothing stated that, so a later change could turn one of them on without a bundle and lose the name with no failing test. parse_entry.rs now has a debug_assert! for it at the start of the parse, beside the assertion about tree_shaking. Its message gives the reason. With react_fast_refresh forced on in Transpiler::parse_internal, the debug build stops at that line for any module. Release builds leave debug-assertions off, so the assertion costs nothing there. The ASAN lanes of CI have it on. #42033 (open) turns the React Compiler on without a bundle, so whichever of the two lands second has to set the name for that case.

The first revision. It set the name only under react_fast_refresh. That flag belongs to one of the readers. The others then lost the name before the linker set it again. The matrix below shows what changed: bun build --react-compiler no longer compiled an anonymous default function in App.tsx or useCounter.tsx, --minify assigned other short names, and --format=internal_bake_dev printed other text.

Measurements. Release builds are main da867c6 and this branch at cb7101f, built here with bun run build:release. The two commits after it (1318657, 4e94d84) add the debug assertion and tests, so these release numbers hold for the head.

  • bundler output vs main: 0 of 77 fixture-by-flag cells differ (cmp, release builds, main da867c6 vs PR). The first revision: 15 of 77 (debug builds).
  • s_export_default instructions per statement (gdb stepi, release): named +0 / +0 (bundle / no bundle), anonymous +2 / -55. Modules without export default function: breakpoint not hit.
  • release binary: s_export_default +56 bytes over 2 monomorphs (9796 to 9832 and 10491 to 10511). .text +0 bytes and the stripped binary +0 bytes (nm -S, size -A, main da867c6 vs PR).
  • allocator calls per module (ASAN print_stats, debug builds, 3 equal runs): no default export +0, named default +0, anonymous default 1 realloc fewer (booked as 1 malloc, 1 realloc and 1 free fewer).
  • transpiler cache: 1 re-transpile per cached module on the first run after the upgrade, 0 on the second. +6 system calls for that rewrite (235 against 229, counted with gdb catch syscall, 3 equal rounds: +2 openat, +2 close, +1 fallocate, +1 pwritev, +1 renameat, +1 unlink, -2 pread64). A same-content twin prints "default" for both paths (main: alpha_default for both).

strace, perf and valgrind are not installed in the container. The system calls are counted with gdb, and the instructions with a gdb stepi loop from the entry of the function to its return, callees included.

The bundler matrix. Fixtures: App.tsx and useCounter.tsx (anonymous default with hooks), widget.tsx, Named.tsx, Arrow.tsx, route.ts, util.mjs, an entry that imports an anonymous default component from node_modules, an entry that imports two anonymous default functions, a "use client" module, an anonymous default class with a decorator. Flags: none, --minify, --react-compiler, --react-fast-refresh, both, --format=internal_bake_dev, --target=bun, each with react external. Each cell is written with --outfile and compared with cmp.

this branch (release builds)           0 of 77 cells differ from main
the first revision (debug builds)     15 of 77: --minify 5, --react-compiler 2, --format=internal_bake_dev 8

Labels. fn.mjs holds export default function () { throw new Error("boom") }. Release builds of main da867c6 and of this branch at cb7101f, and node v26.3.0. "The rule" is the change that shows *default* as default (see "Order of landing"). The last column is read from the diff of commit 4e1498f. It is not built here.

                                     main                      this branch alone         node                 with the rule
fn.name                              "fn_default"              "default"                 "default"            "default"
stack frame before .name is read     at fn_default             at starDefault            at default           at default
stack frame after .name is read      at fn_default             at default                at default           at default
stack frame, async or generator      at <file>_default         at starDefault            at default           at default
stack frame, arrow                   at starDefault            at starDefault            at default           at default
stack frame, class                   at new starDefault        at new starDefault        at new default       at new default
Bun.inspect(fn), console.log(fn)     [Function: fn_default]    [Function: starDefault]   [Function: default]  [Function: default]
describe(fn) title, snapshot key     fn_default                starDefault                                    default
mock(fn).getMockName()               "fn_default"              ""                                             ""
--cpu-prof, name of the function     hot_default               default                                        default
  • The function form shows what the arrow and class forms already show on main.
  • Without the rule the label of a frame is not stable. It reads starDefault until something reads .name, and default after that. A frame of a resumed async or generator function keeps starDefault. So a snapshot of error.stack can differ between bun test and bun test -t. On main the label is wrong and stable.
  • The mock name has no owner. JSMockFunction.cpp:307 reads JSFunction::name, which is empty for *default*. Neither Name anonymous export default functions and classes "default" in stack traces and inspect #38517 nor 4e1498f touches that file.
  • A .name label reads default, as on node. The file hint of main (<file>_default) is gone from such labels, from a CPU profile and from a stored snapshot value. In a run of the self-review the CPU profile also had a starDefault row for the same function. A comment of 2026-10-08 on this PR names heap snapshot class names and debugger call frames too. I did not run those two.

Error.captureStackTrace(e, fn). This is a change of a result, not of a label. hide.mjs and outer.mjs each hold an anonymous default function. The stack is capture < outer < viaOuter < hide < top, and capture calls Error.captureStackTrace(e, hide).

                         hide is on the stack                                   hide is not on the stack
bun 1.4.4, main, node    at top, the module                                     (no frames)
this branch alone        at viaOuter, at starDefault (hide.mjs), at top, the module   the frames of the caller

isFrameOfCaller (src/jsc/bindings/ErrorStackTrace.cpp) compares by identity and then by name, and both functions are named starDefault. Main has the same false match for two anonymous default arrows or classes. The rule gives both the name default, so it does not remove the match.

Order of landing. Do not merge this PR before the rule is on main. Two places can hold the rule:

When the rule is on main, this PR takes main and adds the rows of the function forms to test/js/bun/test/stack.test.ts and test/js/bun/util/inspect.test.js (from commit 33ef9bd). Then CI fails if the order is wrong. Two concerns of the self-review wait for that push: those label rows, and a case that pins the captureStackTrace result.

Two questions for a maintainer.

  • Is the change of .name at runtime wanted now, with no report from a user?
  • Is the mapping in the bindings enough as the rule, or must the rule be in the WebKit fork first?

Two gains, for the function forms.

  • Main fails to load a module that has a top-level binding with the generated name: const fn_default = 1; export default function () {} in fn.js gives SyntaxError: Cannot declare a const variable twice: 'fn_default'. on bun 1.4.4. This branch and node print 1.
  • The cache keys an entry on the content of the file. On main, two 5 KB files alpha.mjs and beta.mjs with the same text both report alpha_default from one cache folder. This branch reports default for both.

Not fixed here.

Other pull requests. #38287 edits the same function and inserts the name with get_or_insert, so the two meet in a merge. #42033 turns the React Compiler on without a bundle (see "The assertion"). #47390 adds a source lint for the cache version.

Tests.

  • test/bundler/transpiler/runtime-transpiler.test.ts: .name of the four function forms through a static import, a re-export, import() and require() of the module, String(fn), typeof fn_default, an import cycle that reads .name before the body of the module runs, and the module with const fn_default.
  • test/bundler/transpiler/transpiler.test.js: Bun.Transpiler returns the four forms unchanged, also with minify.identifiers.
  • test/cli/run/transpiler-cache.test.ts: two files with the same text and one cache folder.
  • test/regression/issue/31401.test.ts: the --no-bundle case expects export default function () {}. A new case keeps the check of js_parser: sanitize auto-generated default export name for digit-named modules #31403 without a bundle: a decorated anonymous default class in 1.ts prints export default class _1_default {. The bundle case pins that a bundle keeps the valid identifier _1_default.
  • test/bundler/transpiler/react-compiler.test.ts: bun build --react-compiler compiles an anonymous default function in App.jsx. This case tells the condition bundle from the condition of the first revision: with react_fast_refresh as the condition it fails (debug build, the output has no react/compiler-runtime).
  • On the release build of main da867c6, 6 cases fail: 3 in runtime-transpiler.test.ts and 1 in each of transpiler.test.js, transpiler-cache.test.ts and 31401.test.ts. The React Compiler case, the class case and the bundle case pass on main on purpose.
  • The block at the end of this description ("gate passed") is from the first revision (259c30d). It ran a test file that this branch no longer has.

Cache version. EXPECTED_VERSION (src/jsc/RuntimeTranspilerCache.rs) is 43 in bun 1.4.4 and on main. The rule at the top of src/js_parser/parser.rs asks for a new version when a parser change alters what runs. Probe: a 5 KB module with export default function () {} that prints its own .name, and one cache folder. The older build runs the file first, then the new build runs it.

release builds: bun 1.4.4 first, then this branch        default (0 wrong rows)
release builds: main da867c60bf first, then this branch  default (0 wrong rows)
debug builds: this branch at version 43 behind main      big_default, 3 of 3 runs (the entry of main)
1 KB file                                                default (under 4 KiB, not cached)

Other open PRs also change this number, and they do not all take the same one. All of them edit the same line, so each PR that lands after the first one conflicts. The rule for that rebase: take the number that main has at that time plus one, never a lower number. #47390 adds a source lint that fails when a merge lowers the number.

Cost of the new cache version. Measured on bun 1.4.4 (release, linux x64): require() of one file, a cache miss against a cache hit, 15 interleaved runs, median wall clock. The noise floor is the difference between two interleaved series of hits.

typescript.js       9,143,423 bytes   miss 296.6 ms   hit 122.9 ms   +173.7 ms   noise floor 2.6 ms
esbuild/lib/main.js    87,654 bytes   miss  17.2 ms   hit  14.9 ms     +2.2 ms   noise floor 0.2 ms

The cost occurs once per cached file, on its first run after the upgrade. It is shared with any other change in the same release that takes a new version.

Merge with main. The branch has main da867c6 merged in. Main f7607a3 merges with no conflict and still has EXPECTED_VERSION 43. The diff against main is the condition in s_export_default, the assertion in parse_entry.rs, the cache version and five test files.

Suites run on this branch (debug build, 1318657; the quick files again at 4e94d84).

  • test/regression/issue/31401.test.ts, test/cli/run/transpiler-cache.test.ts, test/bundler/transpiler/runtime-transpiler.test.ts, test/bundler/transpiler/transpiler.test.js: 307 pass, 2 skip, 21 todo.
  • test/bundler/transpiler/react-compiler.test.ts, test/bundler/bundler_jsx.test.ts: 187 pass, 1 skip, 6 todo.
  • test/bake/dev/react-spa.test.ts, hot.test.ts, esm.test.ts, bundle.test.ts, react-response.test.ts: 96 pass. These run hot module reloading, Fast Refresh and server components, and the assertion does not stop them.
  • At cb7101f: react-compiler-fixtures.test.ts, react-compiler-fixtures-run.test.ts, es-decorators.test.ts, test/bundler/bundler_minify.test.ts and all of test/bake/dev/ passed, except one 5 s timeout in production > works with sourcemaps - error thrown in React component, which also fails 1 of 2 runs on the debug build of main.
  • test/aot/parallel/errors/names-of-functions-behind-export-default/main.mjs needs a canary build and was not run here. Its stored output is what the executable prints, and the bundler input of that build does not change.
  • CI at cb7101f: Buildkite build 135487 passed.

Self-review. It raised 24 concerns and asked for no change of the condition. Addressed in this push or in this description: the order of landing, the assertion, the case that tells the two conditions apart, the scope of the claims, the labels that stay, the missing report from a user, and the check of #31403 without a bundle. Two wait for the rule, as "Order of landing" says.


[review] gate passed · iteration 4 · 3 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/export-default-name.test.ts "test/regression/issue/31401.test.ts"
bun test v1.4.0 (259c30df9)

test/regression/issue/31401.test.ts:
(pass) issue 31401: anonymous default export from digit-named module > bundled output uses a valid identifier for the generated default name [277.90ms]
(pass) issue 31401: anonymous default export from digit-named module > run a digit-named module with an anonymous default function [452.77ms]
(pass) issue 31401: anonymous default export from digit-named module > import a digit-named module with an anonymous default function [445.81ms]

test/bundler/transpiler/export-default-name.test.ts:
18 |       env: bunEnv,
19 |       cwd: String(dir),
20 |       stderr: "pipe",
21 |     });
22 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
23 |     expect({ stdout, stderr, exitCode }).toEqual({ stdout: '"default"\n', stderr: "", exitCode: 0 });
                                              ^
error: expect(received).toEqual(expe
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (88e8b7a84)

test/regression/issue/31401.test.ts:
(pass) issue 31401: anonymous default export from digit-named module > bundled output uses a valid identifier for the generated default name [5.23ms]
(pass) issue 31401: anonymous default export from digit-named module > run a digit-named module with an anonymous default function [13.05ms]
(pass) issue 31401: anonymous default export from digit-named module > import a digit-named module with an anonymous default function [12.24ms]

test/bundler/transpiler/export-default-name.test.ts:
(pass) anonymous export default .name > function: .name is 'default' [14.26ms]
(pass) anonymous export default .name > function*: .name is 'default' [12.98ms]
(pass) anonymous export default .name > async function: .name is 'default' [13.52ms]
(pass) anonymous export default .name > async function*: .name is 'default' [12.47ms]
(pass) anonymous export default .name > class: .name is 'default' [11.96ms]
(pass) anonymous export default .name > arrow: .name is 'default' [11.46ms]
(pass) anonymous export default .name > named function keeps its own name [11.50ms]

 10 pass
 0 fail
 2 snapshots, 16 expect() calls
Ran 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/export-default-name.test.ts "test/regression/issue/31401.test.ts"
bun test v1.4.0 (259c30df9)

test/regression/issue/31401.test.ts:
(pass) issue 31401: anonymous default export from digit-named module > bundled output uses a valid identifier for the generated default name [377.54ms]
(pass) issue 31401: anonymous default export from digit-named module > run a digit-named module with an anonymous default function [477.34ms]
(pass) issue 31401: anonymous default export from digit-named module > import a digit-named module with an anonymous default function [607.24ms]

test/bundler/transpiler/export-default-name.test.ts:
(pass) anonymous export default .name > function*: .name is 'default' [512.83ms]
(pass) anonymous export default .name > function: .name is 'default' [747.25ms]
(pass) anonymous export default .name > class: .name is 'default' [864.17ms]
(pass) anonymous export default .name > async function: .name is 'default' [921.52ms]
(pass) anonymous export default .name > async function*: .name is 'default' [1
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 727ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m   Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[92m   Compiling^[[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
^[[1m^[[92m   Compiling^[[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
^[[1m^[[92m   Compiling^[[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
^[[1m^[[92m   Compiling^[[0m bun_paths v0.0.0 (/workspace/bun/src/paths)
^[[1m^[[92m   Compiling^[[0m bun_collections v0.0.0 (/workspace/bun/src/collections)
^[[1m^[[92m   Compiling^[[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
^[[1m^[[92m   Compiling^[[0m b
... (truncated)
diff hotspot
src/js_parser/visit/visit_stmt.rs                  |  6 +++-
 .../bundler/transpiler/export-default-name.test.ts | 39 ++++++++++++++++++++++
 test/regression/issue/31401.test.ts                |  9 ++---
 3 files changed, 49 insertions(+), 5 deletions(-)

gate history · 3 passed · 1 rejected · iteration 4

evidence per changed file
file                                                 reads  edits  tests
src/js_parser/visit/visit_stmt.rs                        4      4      0
test/bundler/transpiler/export-default-name.test.ts      0      1      0
test/regression/issue/31401.test.ts                      1      4      0

The visit pass was writing the generated default-export binding symbol
(`<basename>_default`) into `func.func.name` for `export default function () {}`
(and its async/generator variants), so the printer emitted
`export default function <basename>_default() {}` and the function's
observable `.name` became `"<basename>_default"` instead of `"default"`.

Per ES2015 15.2.3.11, an anonymous `export default` function declaration
gets its name via SetFunctionName(F, "default"); node prints "default".
The class path already only injects a name when decorator lowering
requires it; this brings the function path in line.

`data.default_name` is still recorded via `record_on_exit!()`, and the
bundler's own conversion in convertStmtsForChunk assigns the name
separately, so nothing else relies on this assignment.
@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with:

$ printf 'export default function () {}\nimport self from "./mod.mjs";\nconsole.log(JSON.stringify(self.name));\n' > /tmp/mod.mjs
$ bun /tmp/mod.mjs    # bun 1.4.4
"mod_default"
$ node /tmp/mod.mjs
"default"

PR: #34932

Status: draft, on purpose. The change is complete. The generated name is set only in a bundle (p.options.bundle), 0 of 77 bundle outputs differ from main, and a debug assertion states that the readers of the name run only in a bundle. Two things are open, and the description has the details:

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Default export naming

Layer / File(s) Summary
Conditional default export naming
src/js_parser/visit/visit_stmt.rs
Anonymous default-exported functions retain their anonymous status unless React Fast Refresh is enabled, where generated stable names remain assigned.
Runtime and bundling regression tests
test/bundler/transpiler/export-default-name.test.ts, test/regression/issue/31401.test.ts
Tests cover runtime .name values for default exports and bundled output for digit-named modules.

Possibly related PRs

  • oven-sh/bun#34795: Refactors the same s_export_default path while this change adjusts generated default function name assignment.
Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly identifies the anonymous default-function name change and its conditional handling. It does not mention the React Fast Refresh exception, but it remains related and sufficiently spec…
Description check Passed The description is complete and relevant. It explains the problem, implementation, scope, known limitations, and extensive verification results. It does not use the template headings exactly, but it p…
Full details: Title check

Explanation

The title clearly identifies the anonymous default-function name change and its conditional handling. It does not mention the React Fast Refresh exception, but it remains related and sufficiently specific.

Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, implementation, scope, known limitations, and extensive verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:59 AM PT - Oct 30th, 2026

❌ @robobun, your commit 4e94d84 has 2 failures in Build #135670 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34932

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

bun-34932 --bun

Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
…ests

The React Fast Refresh transform emits $RefreshReg$(default_name_ref, ...)
for an anonymous default-exported function, and the HMR export lowering
relies on func.func.name being set to produce a binding for that ref.
Keep the name injection on that dev-only path so bake/dev react-spa
works, while the runtime transpiler (bun run / --no-bundle) still leaves
the function anonymous.

Also:
- 31401.test.ts: the third case asserted the --no-bundle identifier;
  switch it to a bundled build, which still surfaces _1_default via
  convertStmtsForChunk and preserves the original leading-digit check.
- runtime-transpiler.test.ts: assert {stdout, stderr, exitCode} as one
  object instead of expect(stderr).toBe("").

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/visit/visit_stmt.rs`:
- Around line 584-587: Shorten the comment near the generated function-name
handling to no more than three lines while preserving the essential points:
leave the name unset for default assignment, and inject it only for the React
Fast Refresh binding path.

In `@test/regression/issue/31401.test.ts`:
- Around line 48-49: Remove the prose comments in the regression test, including
the comment describing --no-bundle and convertStmtsForChunk; retain only the
issue URL comment required by the project’s regression-test convention.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0525b3dc-1123-4c95-a29b-faf7034d3681

📥 Commits

Reviewing files that changed from the base of the PR and between e550f2c and e4da3ef.

📒 Files selected for processing (3)
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/runtime-transpiler.test.ts
  • test/regression/issue/31401.test.ts

Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread test/regression/issue/31401.test.ts Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
runtime-transpiler.test.ts has an in-process dynamic .hbs import that is
sensitive to the release transpiler cache; keep the new subprocess-based
tests in a separate file so the gate's release lane stays deterministic.

@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.

Beyond the inline nit, I re-checked the downstream consumers after the react_fast_refresh gating: convertStmtsForChunk.rs:525 still force-assigns s.default_name to func.name when stripping exports (so bundled output and the updated 31401 test are covered), and opts.features.react_fast_refresh is only ever set true on bundler/bake paths (ParseTask.rs:2475, bake/*) — never on the runtime transpiler — so the .name === "default" fix holds for bun run and --no-bundle while HMR keeps its binding.

Extended reasoning...

The only finding this run is a test-organization nit; the substantive concerns from my earlier passes (31401 breakage, HMR ReferenceError) were addressed in e4da3ef and I verified the current head still satisfies them: the bundler linker unconditionally names the function when lowering SExportDefault, and the feature flag the new gate reads is exclusively a bundler/dev-server flag. Deferring rather than approving because this is a parser visit-pass change that fans out to four output modes; the gating choice (keep name-injection under react_fast_refresh vs. teaching ConvertESMExportsForHmr to handle the anonymous case) is a small design call worth a maintainer glance.

@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.

No issues found in this pass; all earlier feedback (HMR/$RefreshReg$ binding, 31401 test, stderr assertion, comment length, test-file placement) has been addressed. Deferring to a human because this is a user-observable parser output change whose first revision broke the Fast Refresh / HMR export-lowering path — someone familiar with bake/HMR should confirm the react_fast_refresh gate is the right seam.

Checked: the else { data.default_name.ref_ } arm in the fast-refresh block is now only reachable when func.func.name was set (gate matches), so ConvertESMExportsForHmr still gets a binding.
Checked: convertStmtsForChunk assigns default_name itself when stripping exports, and the updated 31401 test exercises that.
Checked: the server_components.wraps_exports() path without fast-refresh now wraps an anonymous function expr — the reference uses the "default" string arg, not .name, so no behavior loss.
The one CI failure (bun-server.test.ts on macOS aarch64) is unrelated to the parser.

Extended reasoning...

Overview

The PR gates a single assignment in src/js_parser/visit/visit_stmt.rs (s_export_default → SFunction arm): func.func.name = Some(data.default_name) now only fires when p.options.features.react_fast_refresh is set. This keeps export default function () {} anonymous in runtime-transpiler and --no-bundle output so the engine's SetFunctionName assigns .name = "default", matching Node and the ES2015 spec. A new test file covers function/async/generator/async-generator/class/arrow/named variants, and test/regression/issue/31401.test.ts is re-expressed against bundled output where convertStmtsForChunk still emits _1_default.

Security risks

None. This changes emitted identifier presence for anonymous default-exported functions; if anything it stops leaking the source file's basename via .name. No untrusted-input parsing, no allocation, no FFI.

Level of scrutiny

Moderate-to-high. The production diff is ~6 lines, but it sits in the parser visit pass and the removed assignment was doing double duty — it was both the source of the .name bug and the thing ConvertESMExportsForHmr relied on to produce a binding for $RefreshReg$. The first revision of this PR unconditionally removed the assignment and would have caused a runtime ReferenceError under bake HMR for anonymous default components in .tsx files; the current revision gates it correctly and test/bake/dev/react-spa.test.ts was verified locally. That history is exactly why a maintainer who owns bake/HMR should confirm react_fast_refresh is the right flag to gate on (vs. e.g. hot_module_reloading or checking both).

Other factors

  • All four inline findings from earlier passes are resolved and marked as such in the thread.
  • Test coverage is good: the variant matrix (all four function forms, class, arrow, named regression guard) is covered via subprocess spawns with combined {stdout, stderr, exitCode} assertions; the bundler path's name-injection is still exercised by the updated 31401 test.
  • The separate export-default-name.test.ts file was justified by the author (a pre-existing cache-poisoning bug in runtime-transpiler.test.ts).
  • The one CI failure (test/js/bun/http/bun-server.test.ts, macOS aarch64) is an HTTP test with no plausible connection to a parser identifier-emission change.

@robobun

robobun commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Findings from work on the same hunk, while I looked at #44721. I ran each item on release builds of main bd599f5 and of the branch below, except where it says "read".

Gate. This PR sets the name only under react_fast_refresh. The React Compiler also reads it in a bundle. visit_func passes func.name to enter_react_compiler_candidate (src/js_parser/react_compiler_host.rs:189), and may_compile infers a component from that name. So bun build --react-compiler without Fast Refresh loses the name of an anonymous default function in App.tsx (read, not run). The gate if p.options.bundle keeps every bundler parse as it is. Branch robobun/da68edbd/anonymous-default-export-name has the hunk with that gate and tests (33ef9bd).

Labels. Without a name, JSC binds the function to its private name *default*. A reader that does not go through .name then shows starDefault or nothing, where main shows <file>_default:

  • Stack frames, Bun.inspect, describe() titles and bun:test matcher output. The mapping of Name anonymous export default functions and classes "default" in stack traces and inspect #38517 fixes these, so it must land first or together with this PR.
  • mock(fn).name and getMockName() give "". JSMockFunction.cpp:296 reads JSFunction::name.
  • --cpu-prof shows starDefault when the function is inlined into its caller (1,108 of 1,121 samples in one run, mid_default on main).
  • Heap snapshot class names (generateHeapSnapshotForDebugging) show starDefault, anonfn_default on main.

JavaScriptCore composes the last two itself (SamplingProfiler.cpp, HeapSnapshotBuilder.cpp). A mapping in Bun's bindings does not reach them. The rule that *default* shows as default has to be in the display name fall-throughs of the WebKit fork before this hunk lands.

Cache version. The printed text of a module changes, so EXPECTED_VERSION in src/jsc/RuntimeTranspilerCache.rs needs a bump (the rule at the top of src/js_parser/parser.rs).

One more effect. The runtime transpiler cache keys an entry on the content, not on the path. On main, a 73 KB utils.mjs copied to helpers.mjs reports utils_default as the .name of the default function of helpers.mjs. With this hunk both report default.

The default export name fix changes the text that the runtime transpiler
prints. An entry that an older build wrote holds the old text. With the same
version, the new build reads that entry and the fix does not reach the file.
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
@robobun
robobun marked this pull request as draft October 29, 2026 14:45

@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 finding, I re-traced the react_fast_refresh gate through the HMR export lowering (src/js_parser/lower/lower_esm_exports_hmr.rs) in both flag states: with Fast Refresh on, the function keeps data.default_name so the $RefreshReg$ binding and the componentish-name edge case still resolve; with it off (including the bake server graph), the now-anonymous function takes the can_be_moved_to_inner_scope path and lands in export_props as an expression, so the default export is not lost. I also checked that bundled output still naming the function <file>_default is expected — the linker assigns the name in convertStmtsForChunk, independent of this parser change.

Extended reasoning...

The diff gates one line in s_export_default on react_fast_refresh, bumps EXPECTED_VERSION 43 to 44, adds a seven-case subprocess test, and reworks the --no-bundle case of test/regression/issue/31401.test.ts into a bundled case. No security-sensitive surface is touched. A verified inline finding is posted and further verified findings were not posted, so approval is withheld; the note above records only what this run additionally ruled out.

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

  • 🟣 src/js_parser/visit/visit_stmt.rs — pre-existing: callers of Bun.Transpiler or bun build targeting browser or node lose the default export when a module has a top-level using and an export default function or export default class. The SFunction arm at src/js_parser/visit/visit_stmt.rs:851 pushes the SExportDefault statement unchanged, and the using lowering at src/js_parser/p.rs:9964 drops every SExportDefault, expecting an export clause that only the Expr arm (visit_stmt.rs:591-627) emits. Fix: when p.will_wrap_module_in_try_catch_for_using holds at module scope, emit var <default_name> = function… plus export { <default_name> as default } in both the SFunction arm and the SClass arm (visit_stmt.rs:933), as the Expr arm does and as esbuild does for all three forms.

    Why this was flagged

    Input: a module such as using a = b;\nexport default function () {} transpiled with new Bun.Transpiler({ target: "browser" }).transformSync(...) or bundled with bun build --target=browser; lower_using is set to !target.is_bun() at src/bundler/transpiler.rs:1550. should_lower_using_declarations (src/js_parser/p.rs:8336) returns true, so visit/mod.rs:1771-1784 runs LowerUsingDeclarationsContext::finalize. In s_export_default the SFunction arm only pushes the original SExportDefault statement (visit_stmt.rs:851); the SClass arm does the same at visit_stmt.rs:933. finalize at src/js_parser/p.rs:9964 has SExportDefault(_) => continue, but that var-plus-export-clause pair is created only in the Expr arm (visit_stmt.rs:591-627). So the function or class default export is dropped from the output: Bun.Transpiler returns code with no default export, and the bundler records no "default" export so importers fail to link. The base branch behaves the same; this diff touches the SFunction arm but does not change this path, so it is pre-existing.

    Verification: pre-existing. Trigger: a module with a top-level using plus export default function () {} transpiled or bundled for a non-Bun target. The Stmt arms of s_export_default push the SExportDefault statement unchanged (src/js_parser/visit/visit_stmt.rs:851, 933). finalize then drops SExportDefault at src/js_parser/p.rs:9964, so the default export is removed from the output. The base branch fails by the same route.

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

The finding about a top-level using holds. new Bun.Transpiler({ target: "browser" }).transformSync("using a = b;\nexport default function () {}") prints no export on bun 1.4.2 and on main. The named function form and the class form behave the same. The expression form keeps its export.

The defect is on main and this PR does not change that path. This PR does not fix it.

`s_export_default` set the generated `<file>_default` name only under
`react_fast_refresh`. Every other bundler parse then lost the name until
the linker set it again. The React Compiler stopped compiling an anonymous
default function in `App.tsx`, and `--minify` and the dev server printed
other text than main.

The condition is now `p.options.bundle`. Every bundler parse runs the
statements of main. A parse without a bundle leaves the function
anonymous, so its `.name` is "default".

The tests move into the test files of the transpiler, the runtime
transpiler and the transpiler cache. The condition and the
`Bun.Transpiler` case come from 33ef9bd.
@robobun robobun changed the title js_parser: keep anonymous export default function anonymous so .name is "default" js_parser: attach the generated default name to an anonymous default function only in a bundle Oct 29, 2026
@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Findings from a second design review of this change. It ran on the state before cb7101f, and it agrees with that commit on the condition (p.options.bundle). Four points are new. The builds are debug builds of main da867c6 with and without that condition, and node v26.3.0.

Order. The review ranks this PR as the second of two parts. The first part is a rule that shows the private name *default* as default. The review puts that rule in JavaScriptCore (the oven-sh/WebKit fork), then a WebKit version bump in Bun with tests of the labels. Its reason: heap snapshot class names, debugger call frames and the profiler read the string that JavaScriptCore makes, so a filter in Bun's bindings (#38517, #38530) does not reach them. If a maintainer refuses the fork change, the filter in the bindings is the fallback. In both cases the rule lands before this PR, not in the same release after it. Work on that rule has started separately.

Error.captureStackTrace(e, fn). This PR alone changes a result, not only a label. Frames kept in three cases: a resumed async default function passes itself, a resumed generator default function passes itself, and a default function x calls the default function of another module, which passes x.

              before a read of .name    after
main          1 / 2 / 0                 1 / 2 / 0
this change   1 / 2 / 1                 0 / 0 / 1
node          2 / 3 / 2                 2 / 3 / 2

An assertion for the condition. Nothing states today that each reader of the name is a bundler feature. The review asks for one debug_assert! in the arm:

debug_assert!(
    p.options.bundle
        || !(p.options.features.hot_module_reloading
            || p.options.features.react_fast_refresh
            || p.options.features.react_compiler.is_enabled()
            || p.options.features.server_components.wraps_exports())
);

I built it. With one of the four features forced on in a parse without a bundle, the debug build stops at that line. The four features are set only in src/bundler/ParseTask.rs (:2503, :2507, :2509, :2520), after opts.bundle = true (:2438).

31401.test.ts. No case prints a generated name without a bundle now, so a revert of #31403 passes in that mode. A decorated anonymous default class still prints one. function dec(value: unknown) {}\n@dec export default class {} in 1.ts with bun build --no-bundle prints export default class _1_default {. A case for that keeps the check.

Two more numbers from the same builds:

  • .name === "default" in 20 of 20 cells for {function, async function, function*, async function*} x {entry self-import, import, import(), require(), Worker} (main: 0 of 20, node: 20 of 20).
  • test262 instn-named-bndng-dflt-fun-anon.js and instn-named-bndng-dflt-gen-anon.js, with a preload for assert.sameValue: 2 of 2 (main: 0 of 2).

The assertion and the test case can go in with the push that takes this PR out of draft, after the rule of the first part is on main. That push also adds the rows of the function forms to the label tests.

…bundle

`s_export_default` names an anonymous default function only when
`p.options.bundle` is set. Hot module reloading, React Fast Refresh, the React
Compiler and the server components wrapper read that name. Only `ParseTask`
turns them on, after it sets `bundle`, and nothing stated that. A debug
assertion at the start of the parse states it now: a parse without a bundle
that turns one of them on stops there in a debug or ASAN build.

Tests:
- react-compiler: an anonymous default function in `App.jsx` is compiled in a
  bundle. The case fails when the name is set under `react_fast_refresh` and
  not under `bundle`.
- 31401: a decorated anonymous default class in `1.ts` keeps a valid generated
  name without a bundle. No other case prints a generated name without a
  bundle now.
- transpiler-cache: the title of the case names the function form.
Comment thread src/js_parser/parse/parse_entry.rs Resolved
The two comment lines above the assertion said why the features need a
bundle. The assertion now says it in its message, where the build that stops
there prints it.
@robobun

robobun commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

The rule for the label is now proposed in JavaScriptCore: oven-sh/WebKit#950 stores the name default in the executable of an anonymous default export, so stacks, Bun.inspect, heap snapshots and the debugger read default and not starDefault.

This pull request lands after that change reaches Bun. Without it, a declaration that reaches JavaScriptCore with no name shows starDefault in a stack. The Bun tests for the labels are on the branch robobun/2cdb9aa5/default-export-display-name, and a draft pull request with them follows when the preview build of oven-sh/WebKit#950 is published.

@robobun

robobun commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

The Bun pull request for the labels is #47502 (draft, on the preview build of oven-sh/WebKit#950). This pull request lands after it.

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