Skip to content

transpiler: do not print an empty var for a data file with no named export - #43330

Open
robobun wants to merge 3 commits into
mainfrom
robobun/30de07c2/data-loader-only-default-key
Open

robobun wants to merge 3 commits into
mainfrom
robobun/30de07c2/data-loader-only-default-key

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --no-bundle aborts on a JSON, JSONC, JSON5, TOML, YAML or XML file whose only top-level key is default: panic: internal error: entered unreachable code, Crashed while printing d.json. new Bun.Transpiler().transformSync('{"default": 1}', "json") aborts the same way.
  • The cause is parse_data_loader (src/bundler/transpiler.rs:2059). It gives a default key no named export, then always builds a var for the named ones. With no other key the var has zero declarations. var ; is not valid JavaScript, so print_decls (src/js_printer/lib.rs:2223) has unreachable!() for it.

Fix

  • When no key gets a named export, parse_data_loader emits only export default <value>, as it already does for {}, an array or a scalar. It also cuts the symbol list to the declared symbols.
  • Correct because the default export still holds every key: export default { default: 1 };. A runtime import and the bundler give the same single default export.
  • Verified: test/bundler/bundler_loader.test.ts (6 new --no-bundle cases) and test/bundler/transpiler/transpiler.test.js (1 new Bun.Transpiler case). All 7 abort without the change. Also ran test/bundler/cli.test.ts.
  • Self-reviewed: 2 concerns raised, 1 addressed (tests for XML and Bun.Transpiler). Rejected: a differential harness for this route, which is a separate test-only change.

Background

  • A data loader does not run the JS parser. For bun build --no-bundle and Bun.Transpiler, parse_data_loader turns the parsed value into statements.
  • The statements are a var with one declaration per top-level key, export { ... } for those names, and export default of the whole object.
  • A key named default gets no declaration, because export { x as default } would collide with export default.
Notes

Repro on 1.4.3-canary.1 (b52d513), linux-x64:

$ printf '{"default": 1}' > d.json && bun build d.json --no-bundle
panic: internal error: entered unreachable code
Crashed while printing /tmp/repro/d.json
$ bun -e 'new Bun.Transpiler().transformSync(`{"default": 1}`, "json")'   # exit code 134

The same abort happens for default = 1 (.toml), default: 1 (.yaml), {default: 1} (.json5), a .jsonc file, <default>1</default> with --loader .xml:xml, and {"default": 1, "default": 2}.

Output with this change:

$ bun build d.json --no-bundle
export default { default: 1 };
$ bun build d.json --no-bundle --minify
export default {default:1};
  • The bundler builds the exports of a data file in the linker (generateCodeForLazyExport.rs) and never had this problem: bun build d.json prints export { d_default as default }.
  • {"a": 1, "default": 2} is unchanged: var a = 1; export { a }; export default { a, default: 2 };.
  • Why the producer and not the printer: the unreachable!() in print_decls states a real invariant, and parser: drop dead-branch var with zero identifiers instead of emitting var; #31003 fixed an earlier empty var the same way, in the code that built it.
  • bundler: handle YAML and JSON5 keys that are not strings instead of panicking #41827 makes the same loop skip YAML and JSON5 keys that are not strings. A file with only such keys (true: t) then reaches this same state. A review comment on that PR names this root cause. This change is in the block after the loop, a different hunk, so the two merge cleanly.
  • transpiler: parse json and jsonc data loaders into the classic tree directly #40856 lists this abort in its notes as found and not touched.
  • The XML case passes loader: { ".xml": "xml" }. bun build --no-bundle maps .xml to the file loader unless --loader .xml:xml is given, because DEFAULT_LOADER_EXT in src/bundler/options.rs has no .xml. That is a separate gap and this change does not touch it. test/bundler/expectBundled.ts listed xml as a loader that bun build does not implement and registered such a test as a todo, so xml is now in its list.
  • symbols.truncate(count): the list had one entry per property. The entries past count were default symbols that no Ref names. Refs are 0..count, so the cut changes no output.
  • Fail-before on a debug (ASAN) build with src/ at origin/main: 7 fail, each with the panic above. With the change: 7 pass.
  • Suites run with the debug build: test/bundler/bundler_loader.test.ts (65 pass), test/bundler/transpiler/transpiler.test.js (223 pass), test/bundler/cli.test.ts (38 pass).

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

fails on main (without fix)
ASAN without fix: 7 failed, 22 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_loader.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (b52d51348)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [1423.80ms]
(pass) bundler > bun loader > bun/loader-text-file [704.64ms]
(pass) bundler > bun loader > bun/loader-json-file [805.15ms]
(pass) bundler > bun loader > bun/loader-toml-file [926.72ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [955.55ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [645.23ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-no-bundle [814.86ms]
(pass) bundler > bun loader > bun/loader-toml-datetime [823.16ms]
(pass) bundler > bun loader > bun/loader-text-file [906.34ms]
(pass) bundler > bun loader > bun/loader-xml-file [927.45ms]
(pass) bundler > node loader > bun/loader-yaml-file [904.10ms]
(pass) bundler > node loader > bun/loader-text-file [998.78ms]
(pass) bundler > node loader > bun/loader-json-file [800.83ms]
(pass) bundler > node
... (truncated)

release without fix: 7 failed, 22 skipped
bun test v1.4.3-canary.1 (b52d51348)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [32.79ms]
(pass) bundler > bun loader > bun/loader-text-file [16.70ms]
(pass) bundler > bun loader > bun/loader-json-file [16.55ms]
(pass) bundler > bun loader > bun/loader-toml-file [17.95ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [16.11ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [16.45ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-no-bundle [15.61ms]
(pass) bundler > bun loader > bun/loader-toml-datetime [16.92ms]
(pass) bundler > bun loader > bun/loader-text-file [16.66ms]
(pass) bundler > bun loader > bun/loader-xml-file [16.67ms]
(pass) bundler > node loader > bun/loader-yaml-file [16.57ms]
(pass) bundler > node loader > bun/loader-text-file [16.39ms]
(pass) bundler > node loader > bun/loader-json-file [18.83ms]
(pass) bundler > node loader > bun/loader-toml-file [16.73ms]
(pass) bundler > node loader > bun/loader-toml-datetime-shadowed-temporal-global [17.10ms]
(pass) bundler > node loader > bun/loader-toml-datetime-imported-temporal-binding [1
... (truncated)
passes on PR (with fix)
ASAN with fix: 22 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_loader.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (b52d51348)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [1412.75ms]
(pass) bundler > bun loader > bun/loader-text-file [608.25ms]
(pass) bundler > bun loader > bun/loader-json-file [503.82ms]
(pass) bundler > bun loader > bun/loader-toml-file [519.73ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [625.43ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [555.81ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-no-bundle [599.71ms]
(pass) bundler > bun loader > bun/loader-toml-datetime [687.33ms]
(pass) bundler > bun loader > bun/loader-text-file [645.60ms]
(pass) bundler > bun loader > bun/loader-xml-file [548.23ms]
(pass) bundler > node loader > bun/loader-yaml-file [663.43ms]
(pass) bundler > node loader > bun/loader-text-file [499.98ms]
(pass) bundler > node loader > bun/loader-json-file [511.06ms]
(pass) bundler > node
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1399ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/123] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[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 
... (truncated)
diff hotspot
src/bundler/transpiler.rs                  | 66 ++++++++++++++++--------------
 test/bundler/bundler_loader.test.ts        | 22 ++++++++++
 test/bundler/expectBundled.ts              |  2 +-
 test/bundler/transpiler/transpiler.test.js | 25 +++++++++++
 4 files changed, 84 insertions(+), 31 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/bundler/transpiler.rs                       4      2     27
test/bundler/bundler_loader.test.ts             4      3     20
test/bundler/expectBundled.ts                   1      1     36
test/bundler/transpiler/transpiler.test.js      2      2     15

… export

A JSON, JSONC, JSON5, TOML, YAML or XML file whose only top-level key
is "default" gets no named export. `parse_data_loader` still built
`var` with zero declarations, and `print_decls` aborts on that.

Emit only `export default` when no key gets a named export.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 2 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 2 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 12 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 2bc09eab-5048-4e05-a55a-d0d3c273b1ed

📥 Commits

Reviewing files that changed from the base of the PR and between 367d939 and 6a45fae.

📒 Files selected for processing (4)
  • src/bundler/transpiler.rs
  • test/bundler/bundler_loader.test.ts
  • test/bundler/expectBundled.ts
  • test/bundler/transpiler/transpiler.test.js

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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:47 AM PT - Sep 18th, 2026

✅ @robobun, your commit 6a45fae716ea03b2ab9114d3d5225739b7fc267f passed in Build #117847! 🎉


🧪   To try this PR locally:

bunx bun-pr 43330

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

bun-43330 --bun

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.3-canary.1 (b52d513), linux-x64:

$ printf '{"default": 1}' > d.json && bun build d.json --no-bundle
panic: internal error: entered unreachable code
Crashed while printing d.json

With this change the command prints export default { default: 1 };. The 7 new test cases abort on a debug build with src/ at origin/main, and they pass with the change.

@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 the symbols.truncate(count) addition in src/bundler/transpiler.rs: every Ref the loop creates is Ref::init(count, ...) for count in 0..count, the trimmed entries were never named by a Ref, and the only consumer is the printer via ast.symbols, so the truncate changes no output. The count == 0 fall-through leaves symbols empty, the same state the scalar/array branch reaches, and prints the untouched object literal, so a file with only default keys (including duplicates) emits export default { default: ... } — consistent with what the bundler's lazy-export linker path produces at runtime.

Extended reasoning...

The core change is a small, mechanically correct guard: the three-statement var/export {}/export default construction is now skipped when no property produced a named export, falling through to the existing export default <expr> branch. I traced symbols from its Vec::new() at line 1944 through resize_with(n), the dense symbols[count] = ... writes, the new truncate(count), and its consumption at ast.symbols (line 2123) and symbol::Map::init_with_one_list in the print path; no code indexes past count, so the truncate is safe and the fall-through's empty symbol list matches the non-object path. The inline findings are pre-existing sibling issues in the same function (non-string YAML/JSON5 keys still hitting .expect, reserved-word identifiers, scan() not populating named_exports) plus a test-concurrency nit; they are worth a human's judgement on scope, which is why this is a defer rather than an approve.

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

  • 🟣 src/bundler/transpiler.rs — Users running bun build --no-bundle or Bun.Transpiler on a YAML or JSON5 file with a non-string key still get a process abort after this merge, the same crash class this PR claims to fix. The named-export loop at src/bundler/transpiler.rs:2000 calls .expect("infallible: variant checked") on the key, but the YAML parser builds mapping keys through parse_node, so true: t, null: x or 1: a arrive as boolean/null/number keys and panic. Fix: skip or stringify non-e_string keys in this loop (falling into the new count == 0 path) instead of relying on the unmerged #41827; also correct the false SAFETY comment at 1993-1994.

    Extended reasoning...

    The dismissal rests on the PR text saying #41827 handles it; that PR is not in this checkout, and the loop still panics here. The PR's stated goal is that a data file whose keys yield no named export prints export default instead of aborting; a YAML file true: t is exactly such a file and still aborts. src/parsers/yaml.rs around 2640-2650: a flow-mapping key is self.parse_node(...), which returns E::Boolean / E::Null / E::Number for plain scalars, not E::String. Block mappings use the same key parser. Step 1: user writes ports.yaml containing 8080: web (numeric keys are common in YAML port/id maps). Step 2: bun build ports.yaml --no-bundle or new Bun.Transpiler().transformSync(src, "yaml"). Step 3: parse_data_loader reaches the loop at src/bundler/transpiler.rs:1991 with keep_json_and_toml_as_one_statement=false. Step 4: line 1997-2000 key.data.e_string_mut().expect(...) panics on the E::Number key; bun prints a crash report and exits. Step 5: the new if count > 0 guard at 2065 never runs. Runtime import of the same file is unaffected (jsc_hooks.rs:2547 passes keep=true…

    Verification: pre-existing. Trigger: bun build --no-bundle or Bun.Transpiler on a YAML file with a plain-scalar non-string key (true: t, null: x, 1: a) or a JSON5 file with a true/null key. Mechanism verified: /home/claude/bun/src/bundler/transpiler.rs:1995-2001 does prop.key.as_mut().unwrap() then .data.e_string_mut().expect("infallible: variant checked") with a comment claiming…

  • 🟣 src/bundler/transpiler.rs — Users running bun build --no-bundle or Bun.Transpiler on a JSON/TOML/YAML file with a key such as class, if, new, import or this get output that is a SyntaxError when imported. The symbol name at src/bundler/transpiler.rs:2023 comes from ensure_valid_identifier, which only remaps the 9 strict-mode words (let, static, yield...) and returns every other reserved word unchanged, so the printer emits var class = 1; export { class };. Fix: reject or prefix every JS reserved word (not only strict-mode ones) when building the declaration symbol, e.g. reuse the printer/renamer reserved-word check, and add a test for {"class": 1}.

    Extended reasoning...

    The finder argued this away as living in ensure_valid_identifier and being pre-existing without opening it. src/bun_core/string/MutableString.rs:141-157: when every char is an identifier char the function returns strict_mode_reserved_word_remap(str).unwrap_or(str). src/bun_core/string/mod.rs:1719-1729: that table holds exactly implements, interface, let, package, private, protected, public, static, yield. class, var, if, for, new, this, true, null, import, export, return, function, delete, typeof, await, enum are all absent. Step 1: config.json is {"class": "btn"} (a very common key in UI configs, also if/then in rule files, import in tsconfig-like files). Step 2: bun build config.json --no-bundle. Step 3: the loop at 2022 stores original_name class; 2039-2053 build a var decl and export clause with that ref; the printer uses NoOpRenamer (js_printer/lib.rs:7819) so the name is printed verbatim. Step 4: output is var class = "btn"; export { class }; export default { class }. Step 5: importing that file throws SyntaxError. The…

    Verification: pre-existing. Triggering condition: a JSON/JSONC/JSON5/TOML/YAML/XML object with a top-level key that is a JS reserved word other than the 9 strict-mode words (e.g. class, if, new, import, this), transpiled via bun build --no-bundle or Bun.Transpiler (the data-loader path, not the linker's generateCodeForLazyExport). Mechanism verified: src/bundler/transpiler.rs:2022-2035 sets…

  • 🟣 src/bundler/transpiler.rs — Callers of Bun.Transpiler.scan() on a JSON, TOML, YAML, JSON5 or XML source get exports: [] even though transform() of the same source emits export { a, b }. parse_data_loader builds the export clause at src/bundler/transpiler.rs:2074-2080 but never fills ast.named_exports, and JSTranspiler.rs:1331 reads only that map. Fix: populate ast.named_exports (and ast.symbols already set at 2123) with one entry per counted key, so scan() reports the same names transform() prints; the count == 0 path added here should report none.

    Extended reasoning...

    The finder called this pre-existing and out of scope; that is true, but this PR is the one that now defines the count == 0 contract for data loaders and re-touches the same function, and the two entry points disagree on the same input. Step 1: const t = new Bun.Transpiler(); t.scan('{"a":1}', ...) with loader json (scan accepts a loader argument through get_parse_result at JSTranspiler.rs:1310). Step 2: parse_data_loader returns an Ast built by Ast::from_parts at 2122; only symbols is assigned at 2123; named_exports stays the default empty map. Step 3: JSTranspiler.rs:1331 named_exports_to_js(global, &mut parse_result.ast.named_exports) yields []. Step 4: the same transpiler's transformSync prints var a = 1; export { a }; export default { a }. Tools that use scan() to decide whether a data module has named exports (dev servers, HMR export tracking, tree-shaking heuristics) get a wrong answer for every data-loader file on every scan call. Remedy: after the loop, insert each counted name into ast.named_exports with its ref_, mirroring what the JS parser does for…

    Verification: pre-existing — the base fails the same way by the same route; this PR touches parse_data_loader but does not change named_exports handling. Triggering condition: any caller of Bun.Transpiler.scan(code, "json" | "jsonc" | "json5" | "toml" | "yaml" | "xml") on an object source with non-default keys. Mechanism verified in code: - src/runtime/api/JSTranspiler.rs:1279-1282 —…

Comment thread test/bundler/transpiler/transpiler.test.js Outdated
It spawns a subprocess and shares no state, like the data loader cases
in the describe.concurrent block before it.
Comment thread src/bundler/transpiler.rs Outdated
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the review above. Both inline threads are addressed and resolved (f088b53, 6a45fae). The three findings outside the diff are real. Each one exists on main without this change, and this PR does not take them:

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

LGTM — small, well-scoped fix for the empty-var abort, and the concurrency nit from the earlier pass is addressed (the new Bun.Transpiler test is now it.concurrent).

Beyond the inline note, I also checked: symbols.truncate(count) is safe because every emitted Ref is minted from the dense count index; the count == 0 fall-through reuses expr before any prop.value was rewritten to an identifier (that only happens after a key gets a declaration); and adding xml to expectBundled's supported loader list unmasks no other test — no existing bundler test passes an .xml loader map.

Extended reasoning...

Overview

The PR changes one block in parse_data_loader (src/bundler/transpiler.rs:2059-2097): it adds symbols.truncate(count) alongside the existing decls.truncate(count) and wraps the var / export {} / export default three-statement emission in if count > 0, so a data-file object whose only keys are "default" falls through to the pre-existing single export default <expr> path. Tests are added in test/bundler/bundler_loader.test.ts (six --no-bundle itBundled cases across json/jsonc/json5/toml/yaml/xml), test/bundler/transpiler/transpiler.test.js (one it.concurrent subprocess test covering transformSync and transform), and test/bundler/expectBundled.ts gains "xml" in supportedLoaderTypes.

Security risks

None. The change only affects which AST statements are produced for a data file with no named exports; it introduces no new parsing, allocation, or user-controlled indexing. The crash it removes was a reachable unreachable!() in the printer (src/js_printer/lib.rs:2223) on user input, so the PR reduces a DoS-class panic rather than adding risk.

Level of scrutiny

Moderate, and it was met by reading the full loop. The two things worth proving were (1) that symbols.truncate(count) cannot drop a symbol some Ref still names — it cannot, since Ref::init(count as u32, ...) at line 2039 is the only ref constructor and it always uses the dense index; and (2) that the fall-through export default expr is correct when count == 0 — it is, because prop.value = Some(Expr::init_identifier(ref_, ..)) at line 2055 executes only on the path that also increments count, so with count == 0 no property was rewritten. Borrow-wise, decls and export_clauses are only moved/borrowed inside the branch and simply dropped or left in the arena otherwise, which compiles fine and leaks nothing (the arena owns export_clauses). The single inline finding attached to this PR is a pre-existing identifier-collision issue in the same loop that this change neither introduces nor worsens, and it is not a reason to hold a crash fix.

Other factors

The prior review's only comment (make the subprocess test concurrent) was addressed in commit f088b53; no third-party CHANGES_REQUESTED review is on the timeline, and no CODEOWNERS entry covers the changed files. I confirmed the bunRun harness helper returns exactly {stdout, stderr, exitCode, signalCode} with trimmed output, matching the new test's toEqual, and that no other test in test/bundler passes an xml entry in a loader map, so widening expectBundled's supported list only affects the new case. I could not execute the debug build in this environment, so the fail-before/pass-after claim rests on the PR description plus the printer's unreachable!() mechanism, which I verified in source.

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

  • 🟣 src/bundler/transpiler.rs — pre-existing: users of bun build --no-bundle or Bun.Transpiler on a data file with two keys that normalize to one identifier get the wrong value for one key. ensure_valid_identifier at src/bundler/transpiler.rs:2023 maps "a-b" and "a_b" both to a_b, while duplicate_key_checker at :2007 dedupes on the raw key, so two symbols share one name and var a_b = 1, a_b = 2 is printed. Fix: make the generated names unique across the loop (numbered suffixes like the bundler renamer, or skip non-identifier keys as generateCodeForLazyExport.rs:440 does) so every key keeps its own value. This differs from the reserved-word finding: remapping keywords does not dedupe.

    Extended reasoning...

    Input {"a-b": 1, "a_b": 2} as data.json (same for toml/yaml/json5/jsonc/xml; also "my key"/"my_key", ""/"_", "1"/"_1"). Loop at src/bundler/transpiler.rs:1991 runs for both keys. duplicate_key_checker.get_or_put(name) at :2007 uses the raw key, so neither is found_existing. symbols[count].original_name at :2022-2033 becomes a_b for both (ensure_valid_identifier replaces - with _, MutableString.rs:173-179). Two refs 0 and 1, two decls, two clause items with aliases a-b and a_b; each prop.value is replaced by an identifier of its ref at :2055. The transform path uses NoOpRenamer (js_printer/lib.rs:7819), whose name_for_symbol (renamer.rs:97-107) returns original_name, so both refs print as a_b. Output: var a_b = 1, a_b = 2; export { a_b as "a-b", a_b }; export default { "a-b": a_b, a_b }; (the "a-b" key is not an identifier so lib.rs:5013 disables shorthand). At runtime a_b is 2, so import { "a-b" as x } and data["a-b"] both give 2 instead of 1: silent data loss. The bundler path is not affected:…

    Verification: pre-existing. Triggering condition: a data file (json/jsonc/json5/toml/yaml/xml) with two top-level keys that ensure_valid_identifier maps to the same identifier, e.g. {"a-b": 1, "a_b": 2}, transpiled via bun build --no-bundle or Bun.Transpiler (the non-bundling parse_data_loader path). Mechanism verified in /home/claude/bun/src/bundler/transpiler.rs: the loop at :1991-2057 dedupes…

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the second review. The finding about two keys that map to one identifier ({"a-b": 1, "a_b": 2} prints var a_b = 1, a_b = 2) also exists on main without this change. #34617 is open for it: it keeps a map of the generated identifiers in this loop and adds a number to the second one (_if, _if2), which applies to any two keys whose identifiers collide. This PR does not take 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