Skip to content

js_printer: inline a cross-module enum member only where the parser counted a read - #42454

Open
robobun wants to merge 1 commit into
mainfrom
robobun/209c045f/enum-inline-reads-only
Open

robobun wants to merge 1 commit into
mainfrom
robobun/209c045f/enum-inline-reads-only

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The printer inlines only a node the parser marked is_import_property_use, and it unwraps the key the same way. A write or delete target has no mark and prints as written. The parser counted it as a use of E, so the enum object stays.
  • With --minify-syntax the parser folds E[key] into E.A after the visit (visit_expr.rs). That node now gets the mark, so the read stays inlined.
  • Trade: React Compiler output is built after the visit and has no mark. It prints Status.Busy, not 1. It kept the enum object before too.
  • Verified: test/bundler/esbuild/ts.test.ts (4 new cases), test/bundler/bundler_dynamic_import_dce.test.ts (2 new cases). 1.4.3-canary.1 fails 5 of 6. Other suites: see Notes.

Background

  • Enum values from another file are inlined at print time from the linker's ts_enums table. E.A prints as 1 /* A */.
  • record_import_property_use counts a read of X.name as a use of the property, not of X. The linker drops X when every counted read is a known member.
  • is_import_property_use (on E::Dot / E::Index) records that count. import_member_binding already needs it.
Notes
  • A read after a write still prints the value, as it does for an enum in the same file.
  • Self-reviewed. Changes made after it: this body, the is_import_property_use doc comment, the --minify-syntax mark (the review found the import-cycle case below), and EIndex targets plus non-identifier keys in the tests, so the minified variants fail before the fix too.
  • The fourth new case in ts.test.ts (EnumCrossModuleInliningFoldedIndexInCycle) passes on 1.4.3-canary.1. It guards the --minify-syntax path: LogLevel[process.env.NODE_ENV] in a file that runs before the enum's file. With only the printer change that bundle threw TypeError: undefined is not an object (evaluating 'e.production').
  • The mark on the folded node uses record_import_property_use only. It does not run maybe_rewrite_property_access there, so ns["a" + ""] on an import * as ns prints as before.
  • The other two printer flaws from the same report have open PRs. I applied their printer hunks on 158ff6c and ran the static, require(), await import() and .then() forms. js_printer: parenthesize inlined negative enum value on LHS of ** #36133 fixes -1 /* A */ ** 2 in all 10 forms. The EImportIdentifier arm in js_printer: print 0 / 0 and void 0 where a local binding or with object shadows NaN / undefined #36124 fixes void 0 ** 2 in all 8 forms. This PR does not touch those lines.
  • js_printer: wrap cross-module enum inlined as a delete operand when non-finite #36744 wraps delete NaN as delete (0, NaN). It predates bundler: bind property accesses on re-exported namespaces directly #41009. With this change delete E.N prints as written, so that wrap is not needed.
  • esbuild 0.21.5 prints the same 1 /* A */ = 5. For E[Key.first] it keeps E and prints E["A" /* first */], which runs.
  • The runtime transpiler output does not change (record_import_property_use returns false without bundle, and ts_enums is only set by the linker), so the transpiler cache version stays.
  • Suites run with the fix (0 failures): esbuild/ts, esbuild/importstar, esbuild/dce, esbuild/default, esbuild/splitting, esbuild/lower, bundler_dynamic_import_dce (282), bundler_minify, bundler_edgecase, bundler_regressions, bundler_cjs, bundler_cjs2esm, bundler_barrel, bundler_splitting, bundler_promiseall_deadcode, bundler_jsx, transpiler/react-compiler, transpiler/transpiler.test.js.

[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 11 failed, 16 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_dynamic_import_dce.test.ts test/bundler/esbuild/ts.test.ts
bun test v1.4.3 (4ff919377)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructure [1032.38ms]
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [515.19ms]
(pass) bundler > dynamic_import_dce/AwaitDot [445.09ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [444.50ms]
(pass) bundler > dynamic_import_dce/LetBinding [433.63ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [431.10ms]
(pass) bundler > dynamic_import_dce/BailoutRest [497.31ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [533.98ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [457.53ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [466.12ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [956.95ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [491.20ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [431.22ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure 
... (truncated)

release without fix: 11 failed, 16 skipped
bun test v1.4.3-canary.1 (4ff919377)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructure [30.41ms]
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [18.93ms]
(pass) bundler > dynamic_import_dce/AwaitDot [20.70ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [27.55ms]
(pass) bundler > dynamic_import_dce/LetBinding [19.32ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [21.84ms]
(pass) bundler > dynamic_import_dce/BailoutRest [22.27ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [19.84ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [26.41ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [21.45ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [71.49ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [20.35ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [52.79ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure [41.41ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllDestructure [21.26ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllThenDestructure [23.75ms]
(pass) bundler > dynamic_import_dce/SplittingProm
... (truncated)
passes on PR (with fix)
ASAN with fix: 16 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_dynamic_import_dce.test.ts test/bundler/esbuild/ts.test.ts
bun test v1.4.3 (4ff919377)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructure [1067.33ms]
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [617.03ms]
(pass) bundler > dynamic_import_dce/AwaitDot [559.07ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [528.97ms]
(pass) bundler > dynamic_import_dce/LetBinding [515.53ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [523.99ms]
(pass) bundler > dynamic_import_dce/BailoutRest [675.38ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [553.55ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [481.31ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [575.34ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [954.29ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [703.11ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [529.74ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure 
... (truncated)

release with fix: 16 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e7b7769098
  features     baseline

23 deps, 131 codegen, 1172 objects in 872ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (4ff919377)

Checked 22 installs across 61 packages (no changes) [29.00ms]
[2/1244] gen bindgenv2
[3/1244] gen ErrorCode+*.h
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (4ff919377)

Checked 1 install across 2 packages (no changes) [11.00ms]
[5/1244] fetch tinycc
[tinycc] up to date
[6/1243] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (4ff919377)

Checked 111 installs across 104 packages (no changes) [10.00ms]
[7/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1216] gen node-fallbacks/react-refresh.js
Bundled 1 module in 6ms

  react-refresh.js  4.81 KB  (entry point)

[9/1216] gen .bind.ts → GeneratedBindings.cpp
[10/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConst
... (truncated)
diff hotspot
src/ast/e.rs                                    |   4 +-
 src/js_parser/visit/visit_expr.rs               |  14 ++--
 src/js_printer/lib.rs                           |  53 ++++++-------
 test/bundler/bundler_dynamic_import_dce.test.ts |  60 ++++++++++++++
 test/bundler/esbuild/ts.test.ts                 | 101 ++++++++++++++++++++++++
 5 files changed, 199 insertions(+), 33 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/ast/e.rs                                         1      1     25
src/js_parser/visit/visit_expr.rs                    1      2     25
src/js_printer/lib.rs                                7      2     25
test/bundler/bundler_dynamic_import_dce.test.ts      3      2     17
test/bundler/esbuild/ts.test.ts                      3      5     19

…ounted a read

The printer replaced every `X.name` on an imported TypeScript enum with the
member's value. The parser and the linker decide the same thing earlier
(`record_import_property_use`), to know if `X` is still used, and the two
decisions differed:

- The parser does not count a write or a `delete` target as a read. The
  printer inlined it anyway, so `E.A = 5` printed as `1 /* A */ = 5`,
  a SyntaxError.
- The parser reads the key of `E[Key.first]` through an inlined enum. The
  printer did not, so the linker dropped `E` and the printer still printed
  `E["A"]`, a ReferenceError.

The printer now inlines only a node the parser marked
(`is_import_property_use`), and it reads an index key the way the parser
does.

With `minify_syntax` the parser turns `E[key]` into `E.A` once the key
folds to a string. That node had no mark. It now gets one, so such a read
stays inlined and `E` can be removed.
@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:46 AM PT - Sep 12th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 42454

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

bun-42454 --bun

@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on 1.4.3-canary.1+4ff919377 with bun build ./entry.js --target=bun --outdir out && bun out/entry.js:

  • import { E } from "./e.ts"; E.A = 5; (with export enum E { A = 1 }) prints 1 /* A */ = 5; and fails with SyntaxError: Left side of assignment is not a reference. The const { E } = require("./e.ts"), await import("./e.ts") and .then(({ E }) => ...) forms print the same.
  • import { E } from "./e.ts"; enum Key { first = "A" } console.log(E[Key.first]); prints E["A" /* first */] with E removed and fails with ReferenceError: E is not defined.

The new cases in test/bundler/esbuild/ts.test.ts and test/bundler/bundler_dynamic_import_dce.test.ts fail on that build and pass on this branch.

CI (build 114671): the diff is green. Both changed test files pass on every lane. Two tests are red, and neither uses the bundler:

  • test/js/bun/http/serve-pending-promise-abort-leak.test.ts on debian 13 x64-asan. It also fails on main.
  • test/js/web/fetch/fetch-backpressure.test.ts on windows 11 aarch64 (two 90 s timeouts in stalled no consumer drains the full body).

Both are reported for main-break triage. The other 9 entries (inspector protocol, napi uv, webview, cpu-prof, expect-assertions, install registry, install patch, filesink, css color) passed on retry or alone. This PR is ready for a maintainer.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 26ca229e-0ddf-46cd-a2d8-4bfdfae8ed87

📥 Commits

Reviewing files that changed from the base of the PR and between b993710 and e7b7769.

📒 Files selected for processing (5)
  • src/ast/e.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_printer/lib.rs
  • test/bundler/bundler_dynamic_import_dce.test.ts
  • test/bundler/esbuild/ts.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

The change tracks import-property usage during index-to-dot conversion, limits imported enum substitution to tracked reads, and adds regression coverage for writes, deletes, computed indexes, minification, and cyclic module initialization.

Changes

Enum import-property handling

Layer / File(s) Summary
Track import-property accesses
src/ast/e.rs, src/js_parser/visit/visit_expr.rs
Documentation defines import-property use behavior. e_index records this state before converting eligible string indexes to dot accesses.
Gate enum substitution
src/js_printer/lib.rs
Imported enum and member substitution now requires tracked import-property usage. Indexed access also requires an inlined string index.
Validate enum access behavior
test/bundler/bundler_dynamic_import_dce.test.ts, test/bundler/esbuild/ts.test.ts
Tests cover read inlining, preserved writes and deletes, computed indexes, minification, and cyclic module initialization.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to e7b77

The enum access tracking and printer gating changes preserve writable targets while retaining valid read inlining coverage.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
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.
Description check ✅ Passed The description clearly explains the problem, fix, scope, trade-offs, and verification results. It does not use the template headings exactly, but it contains the required information and is complete.
Title check ✅ Passed The title is concise, specific, and accurately describes the main change: restricting cross-module enum member inlining to parser-marked reads.

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

@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 — the printer's inlining decision now matches the parser's use-count decision via the existing is_import_property_use flag, and the folded-index path is kept in sync.

What was reviewed:

  • Traced record_import_property_use in the new minify-fold branch: single decrement (early return prevents the non-minify call at L1057), short-circuits before mutation on optional chain / assign-target / delete.
  • Checked the E::Index printer arm: unwrap_inlined() on the key mirrors the parser's read at visit_expr.rs:1000, so parser and printer agree on which keys are inlinable.
  • Confirmed the one pessimization (post-visit nodes like React Compiler output lose the inline) stays runtime-correct because the base use was never decremented, so the enum object is kept.
  • Tests cover the full assign-target matrix (simple/compound/++/destructure/rest/default/for-in/for-of/delete/logical), Dot and Index, identifier and non-identifier keys, minify on and off, and the cycle guard for the parser-side change.
Extended reasoning...

Overview

This PR fixes cross-module TypeScript enum inlining in the bundler so that only reads of E.member are replaced with the constant value; writes, compound assignments, destructuring targets, for-in/for-of targets, and delete targets keep the property access. The printer (src/js_printer/lib.rs, E::Dot and E::Index arms) now gates try_to_get_imported_enum_value behind the existing e.is_import_property_use flag, and the E::Index arm now calls unwrap_inlined() on the key so E[Key.first] (where Key.first was itself inlined) is recognized. The parser visitor (src/js_parser/visit/visit_expr.rs) now marks the E::Dot produced by the minify_syntax a["b"] → a.b fold via record_import_property_use, using the same hoisted IdentifierOpts as the non-fold branch. A doc comment on Dot::is_import_property_use is expanded, and six new bundler tests are added.

Security risks

None. This is a compiler/bundler code-generation change with no auth, crypto, filesystem, or network surface. The only user-observable effect is that previously-invalid emitted JS (1 = 10, delete NaN) now prints as the original property access.

Level of scrutiny

Moderate. The native change is ~30 lines across two files and reuses an existing, already-tested flag (is_import_property_use) that import_member_binding was already gated on. The key correctness invariant — "inline only where the parser decremented the base import's use count" — is exactly what the flag encodes, so the printer and linker cannot disagree about whether the enum object survives. I traced record_import_property_use to confirm the new call site in the minify-fold branch cannot double-decrement (the branch returns early before the existing call at L1057), short-circuits on optional_chain.is_some() before any state mutation, and returns false without side effects for assign/delete targets. The unwrap_inlined() addition in the E::Index printer arm mirrors line 1000 of visit_expr.rs, so the parser and printer read the same key.

Other factors

Test coverage is unusually thorough for a bundler fix: both itBundled and itElides variants, static import / import * / await import() / require() / .then(), every assign-target form including nested destructuring with defaults and rest, E::Dot and E::Index with identifier and non-identifier keys, minifySyntax both on and off, and a dedicated import-cycle case that guards the parser-side change in isolation. The one acknowledged trade-off (React Compiler-generated nodes lose the inline but stay runtime-correct because the enum object is retained) is a minor pessimization, not a correctness issue. No CODEOWNERS cover the changed paths, the bug hunt exited on dry_streak, and there are no outstanding reviewer objections.

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