Skip to content

js_parser: don't inline single-use anonymous fn/arrow/class initializers - #36313

Open
robobun wants to merge 6 commits into
mainfrom
farm/c47e5de4/fix-inline-anonymous-fn-name
Open

robobun wants to merge 6 commits into
mainfrom
farm/c47e5de4/fix-inline-anonymous-fn-name

Conversation

@robobun

@robobun robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

The single-use-variable inlining pass (active whenever minify_syntax is on, which is always the case for the runtime transpiler) was substituting anonymous function/arrow/class initializers into their sole use site. The declaration site of a let/const with an anonymous function initializer is a NamedEvaluation context, so this substitution drops the .name the value would otherwise receive.

The block-level function-declaration lowering produces exactly this shape ({ function f() {} } becomes { let f = function() {} } with the name cleared), so a block-scoped function declaration lost its .name at runtime whenever it was referenced exactly once.

Fixes #20398
Fixes #22770

Repro

// entry.mjs
{
  function slow() { return 42; }
  const name = slow.name;
  console.log(JSON.stringify(name));
}
$ node entry.mjs
"slow"
$ bun entry.mjs      # before
""
$ bun entry.mjs      # after
"slow"

Originally observed as performance.timerify() emitting function entries with an empty name for a block-scoped function declaration wrapped via an aliased timerify reference.

Fix

Skip single-use inlining when the initializer is an anonymous function/arrow/class expression. Named function expressions and all non-function values still inline as before.

How did you verify your code works?

New tests in test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts spawn a fresh bun process so the fixture goes through the real runtime transpiler:

  • before: ["","","","named",43,""] / ["",""]
  • after: ["fn","arrow","Cls","named",43,"f"] / ["slow","work"]

Also ran test/js/node/perf_hooks/perf_hooks.test.ts, test/bundler/bundler_minify.test.ts, and test/bundler/transpiler/ (all pass).


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

fails on main (without fix)
ASAN without fix: 3 failed, 14 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/extra.test.ts test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts
bun test v1.4.0 (a3315419b)

test/bundler/esbuild/extra.test.ts:
(pass) bundler > extra/FileAsDirectoryBreak [462.14ms]
(todo) bundler > extra/PathWithQuestionMark
(pass) bundler > extra/JSXEscaping1 [109.08ms]
(pass) bundler > extra/JSXEscaping2 [81.45ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers1 [365.87ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers2 [367.60ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers3 [367.03ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers4 [366.54ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers5 [342.98ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers6 [369.80ms]
(pass) bundler > extra/RemoveASMDirective [379.75ms]
(pass) bundler > extra/ImportOrder1 [371.46ms]
(pass) bundler > extra/ImportOrder2 [369.13ms]
(pass) bundler > extra/CyclicImport1 [362.37ms]
(pass) bundler > extra/TypeofRequireESM [1258.73ms]
(pass) bundler > ex
... (truncated)

release without fix: 14 skipped
bun test v1.4.0-canary.1 (b2411a3ac)

test/bundler/esbuild/extra.test.ts:
(pass) bundler > extra/FileAsDirectoryBreak [12.67ms]
(todo) bundler > extra/PathWithQuestionMark
(pass) bundler > extra/JSXEscaping1 [3.27ms]
(pass) bundler > extra/JSXEscaping2 [2.74ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers1 [11.17ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers2 [10.98ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers3 [10.65ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers4 [9.62ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers5 [10.66ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers6 [10.06ms]
(pass) bundler > extra/RemoveASMDirective [10.22ms]
(pass) bundler > extra/ImportOrder1 [10.14ms]
(pass) bundler > extra/ImportOrder2 [10.52ms]
(pass) bundler > extra/CyclicImport1 [10.50ms]
(pass) bundler > extra/TypeofRequireESM [24.05ms]
(pass) bundler > extra/CJSExport1 [11.72ms]
(pass) bundler > extra/CJSExport2 [11.83ms]
(pass) bundler > extra/CJSExport3 [10.36ms]
(pass) bundler > extra/CJSExport4 [11.10ms]
(pass) bundler > extra/CJSExport5 [11.39ms]
(pass) bundler > extra/CJSExport6 [12.80ms
... (truncated)
passes on PR (with fix)
ASAN with fix: 14 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/extra.test.ts test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts
bun test v1.4.0 (a3315419b)

test/bundler/esbuild/extra.test.ts:
(pass) bundler > extra/FileAsDirectoryBreak [455.68ms]
(todo) bundler > extra/PathWithQuestionMark
(pass) bundler > extra/JSXEscaping1 [89.34ms]
(pass) bundler > extra/JSXEscaping2 [80.43ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers1 [385.38ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers2 [374.87ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers3 [341.47ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers4 [347.98ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers5 [349.76ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers6 [397.49ms]
(pass) bundler > extra/RemoveASMDirective [374.45ms]
(pass) bundler > extra/ImportOrder1 [393.04ms]
(pass) bundler > extra/ImportOrder2 [417.82ms]
(pass) bundler > extra/CyclicImport1 [368.04ms]
(pass) bundler > extra/TypeofRequireESM [1252.07ms]
(pass) bundler > ext
... (truncated)

release with fix: 14 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1728ms (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_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bun_valkey v
... (truncated)
diff hotspot
src/js_parser/visit/mod.rs                         |   6 ++
 test/bundler/esbuild/extra.test.ts                 |  11 +-
 .../transpiler-inline-anonymous-fn-name.test.ts    | 112 +++++++++++++++++++++
 3 files changed, 127 insertions(+), 2 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/js_parser/visit/mod.rs                                    4      3      0
test/bundler/esbuild/extra.test.ts                            2      3      0
…/transpiler/transpiler-inline-anonymous-fn-name.test.ts      0      5      0

A let/const binding with an anonymous function, arrow or class expression
as its initializer is a NamedEvaluation context: the value receives the
binding name as its ".name" property. The single-use inlining pass was
substituting such an initializer into its sole use site, which drops that
name and makes it observably empty.

This is always on at runtime (target=bun implies minify_syntax), and the
block-level function-declaration lowering produces exactly this shape, so
"{ function f() {} ... }" was losing f.name whenever f was referenced
exactly once. First observed as performance.timerify() emitting entries
with an empty name for a block-scoped function declaration.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 48421b01-98bf-4e62-b763-4fba22b0c292

📥 Commits

Reviewing files that changed from the base of the PR and between 59242d6 and a331541.

📒 Files selected for processing (3)
  • src/js_parser/visit/mod.rs
  • test/bundler/esbuild/extra.test.ts
  • test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts

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

@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:35 AM PT - Jul 29th, 2026

❌ @robobun, your commit a331541 has 2 failures in Build #84848 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36313

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

bun-36313 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. An arrow function name is erased if it's not used directly. #20398 - Arrow function .name is erased when the variable is used only once (single-use inlining drops NamedEvaluation)
  2. Arrow functions defined in tests have no name property unless name is accessed within the same test #22770 - Arrow functions in tests have no .name unless .name is accessed in the same scope (same inlining root cause)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #20398
Fixes #22770

🤖 Generated with Claude Code

Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_parser/visit/mod.rs
robobun added 2 commits July 29, 2026 03:59
…es gap)

--keep-names does not emit __name() for the block-level function
declaration lowering, so the bundled output gives f.name === "f2"
(node agrees on the bundled output). These two tests only passed before
because the runtime transpiler inlined the single-use let back into the
var assignment, which re-applied NamedEvaluation with the original name.
Same gap FunctionHoistingKeepNames1/2 were already marked todo for.
Comment thread test/bundler/esbuild/extra.test.ts 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 — my earlier feedback (link a tracking issue for the --keep-names block-level-lowering gap) was addressed: #36320 is now referenced from all four FunctionHoistingKeepNames todos.

What was reviewed:

  • The bail-out reuses the existing is_anonymous_named() helper (arrow / nameless fn / nameless class) and uses break, matching every other exit in that loop; refusing to inline is always semantics-preserving, so this can only cost minification, not correctness.
  • Verified named function expressions and non-function initializers still fall through and inline (covered by t4/t5).
  • Re-checked the FunctionHoistingKeepNames3/4 → todo transition: bundler output is unchanged, node already produced "f2" on the same bytes, and the gap is now tracked in #36320.
  • Confirmed the #36320 links are present in the current PR diff (the finder's "lost during branch rewrite" concern was ruled out).
Extended reasoning...

Overview

Six-line guard in src/js_parser/visit/mod.rs inside the single-use-let inlining loop: if the initializer of the last decl is an anonymous function/arrow/class expression, break out of the inlining loop instead of substituting it into its sole use site. This preserves the .name the value receives via NamedEvaluation at the declaration site. New test file exercises the runtime transpiler (spawned subprocess), the Bun.Transpiler API, and the block-level function-declaration lowering shape from #20398/#22770. Two --keep-names bundler tests that were passing by accident (bun's runtime transpiler was re-inlining the bundled output and masking a pre-existing gap) are marked todo and linked to #36320.

Security risks

None. Pure minifier-optimization bail-out; no untrusted input handling, no memory management, no FFI.

Level of scrutiny

The parser's inlining pass runs on every file bun executes, so it warrants care — but this change is strictly conservative: it only declines an optimization, so the worst case is slightly less compact output, never a new correctness bug. is_anonymous_named() is the same helper already used throughout visit_decls/visit_binding for exactly this NamedEvaluation purpose, and break matches every other bail-out in this loop (the loop only ever looks at the last decl, so an un-inlined last decl blocks earlier ones regardless). I couldn't construct a shape where this over- or under-bails: named function expressions carry an intrinsic name and correctly fall through to the identifier check; conditional/comma-wrapped anonymous functions aren't NamedEvaluation targets and is_anonymous_named() correctly returns false for those.

Other factors

  • My previous review's only ask (tracking issue for the todo'd tests) was addressed; #36320 is linked from all four FunctionHoistingKeepNames entries in the current diff.
  • The PR description's mechgate evidence shows the new tests fail on main (["","","","named",43,""]) and pass with the fix, in both debug+ASAN and release.
  • Test hygiene follows CLAUDE.md conventions: bunExe/bunEnv, using tempDir, concurrent pipe drain, exit code asserted alongside stdout/stderr in one object.
  • All inline review threads on the PR are resolved; no outstanding human feedback.

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

Thanks for filing #36320 and linking it from the four FunctionHoistingKeepNames todos — that addresses my earlier note. The fix itself looks correct and the tests are thorough; deferring to a maintainer only because this sits in the parser's single-use inlining path and trades a very common inlining opportunity (const f = () => {}) for spec-correct .name, which is worth a human ack.

What was reviewed:

  • is_anonymous_named() covers exactly EArrow / nameless EFunction / nameless EClass; break matches the other bail-outs in the same loop.
  • Traced the KeepNames3/4 green→todo transition — bundler output is unchanged; the runtime transpiler was previously masking the pre-existing #36320 gap.
  • New tests spawn a fresh process, drain pipes concurrently, and cover fn/arrow/class/named/non-fn plus the #20398 array-literal substitution shape.
Extended reasoning...

Overview

Six-line addition to the single-use-variable inlining loop in src/js_parser/visit/mod.rs: bail out when the initializer is an anonymous function/arrow/class expression, because the let/const binding is a NamedEvaluation context and inlining would drop the observable .name. Ships a new 3-test file exercising the runtime transpiler and Bun.Transpiler, and marks extra/FunctionHoistingKeepNames3/4 as todo (linked to newly-filed #36320) since they only passed before because the runtime transpiler's inlining was papering over a pre-existing --keep-names gap.

Security risks

None. Pure AST-optimization change; no untrusted input handling, allocation, or FFI touched.

Level of scrutiny

High — src/js_parser/visit/mod.rs runs on every JS/TS file Bun loads, and the inlining pass is active by default (runtime transpiler enables minify_syntax). The change is strictly conservative (only ever skips an optimization, never adds one), so the correctness risk is low, but the surface area is the entire runtime. The design tradeoff — losing inlining for every single-use const f = () => {} in a nested scope in exchange for spec-correct .name — is clearly the right call, but a maintainer should confirm they're happy taking it rather than a narrower fix (e.g. only when keep_names is off, or naming the function expression at the block-level-lowering site instead).

Other factors

  • My previous inline nit (link a tracking issue for the two green→todo tests) was addressed in a331541; all four FunctionHoistingKeepNames todos now reference #36320.
  • is_anonymous_named() is the same helper already used by visit_decls/visit_binding/visit_class for NamedEvaluation handling, so no new predicate.
  • The break placement is consistent with every other bail-out in the 'inner loop; it correctly stops back-to-back inlining once an un-inlinable decl is hit (earlier decls can't be reordered past it anyway).
  • Tests follow harness conventions (bunEnv/bunExe/tempDir/await using, concurrent pipe drain, combined-object assertion), and the PR body shows the suite failing on main and passing with the fix under both ASAN-debug and release.

@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Diff is ready. Build #84848: 189/196 jobs passed. Remaining red is unrelated to this change:

  • step-failed-outside-runner on the build-bun lanes: a build-cpp sibling-step cascade early in the run (same on main #84716); all retried and passed. Annotations are stale from the first attempt.
  • test/js/bun/http/bun-serve-html.test.ts segfault at 0xFFFFFFFFFFFFFFFF on :windows: 2019 x64 (no-AVX lane), one shard out of eight; all other Windows lanes passed. This PR touches no native code and the parser change only skips an optimization. Reported for main-break triage.
  • proxy-stress-protocol, spawn-streaming-stdout, svelte/client-side, 20875, migrate: all marked [flaky] and passed standalone.

The source change is the 6-line is_anonymous_named() bail-out in src/js_parser/visit/mod.rs; cargo check -p bun_js_parser and a local bun bd both build and all touched test suites pass (transpiler-inline-anonymous-fn-name.test.ts, bundler/esbuild/extra.test.ts, bundler/bundler_minify.test.ts, bundler/transpiler/, node/perf_hooks/).

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

One more user-visible effect of the same inlining, for the record: the substituted function also loses its name in error.stack, so any helper that is defined in a const and called exactly once shows up as <anonymous> (or async <anonymous>) in stack traces.

// a.mjs
function outer() { const nested = () => { throw new Error("A"); }; nested(); }
try { outer(); } catch (e) { console.log(e.stack.split("\n")[1].trim()); }
$ node a.mjs
at nested (file:///tmp/a.mjs:1:49)
$ bun a.mjs        # 1.4.0 and current main
at <anonymous> (/tmp/a.mjs:1:53)

The same happens for const handler = async () => {...}; await handler(); (async <anonymous>), an anonymous function () {} initializer, and const K = class {...}; new K() (node prints at new K). outer is transpiled to (() => { throw Error("A"); })();, so there is no binding left for the engine to infer a name from; adding a second reference to the binding makes bun print at nested.

I applied the src/js_parser/visit/mod.rs change from this PR on top of current main (it applies cleanly) and all four shapes above print the same frame names as node (nested, handler, fn, new K), while a non-function single-use binding (const x = fn(); return x.y();) still inlines to return fn().y();. So this PR covers the stack trace case as well; I am not opening a separate PR for 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

2 participants