Skip to content

js_printer: parenthesize missing-import void 0 on LHS of ** - #36134

Open
robobun wants to merge 1 commit into
mainfrom
farm/aaf7c514/binpow-missing-import-undefined
Open

js_printer: parenthesize missing-import void 0 on LHS of **#36134
robobun wants to merge 1 commit into
mainfrom
farm/aaf7c514/binpow-missing-import-undefined

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Repro

// foo.js
export const x = 3;
// entry.js
import * as ns from './foo';
console.log(ns.nope ** 2);
$ bun build entry.js --minify-syntax
console.log(void 0 ** 2);
                   ^
error: Unexpected **

The linker emits a "will always be undefined" warning but still produces a bundle, and that bundle is a SyntaxError (UnaryExpression is not a valid left operand of **).

Cause

A namespace-import property with no matching export is rewritten to EImportIdentifier with import_item_status == Missing. The printer's EImportIdentifier handler calls print_undefined(loc, level), which under --minify-syntax emits unwrapped void 0 when level < Prefix. The BinPow left-operand match in binary_check_and_prepare has no EImportIdentifier arm, so left_level stays at Level::Exponentiation, and the output is void 0 ** 2.

Fix

Add an EImportIdentifier arm to the BinPow left-operand match, bumping left_level to Call when minify_syntax is enabled. This mirrors the existing EBoolean arm (booleans print as !0/!1 under the same option). When the import is not missing the printer emits an identifier or a member access, neither of which consults the incoming level, so the bump is a no-op there; and without minify_syntax the printer emits the identifier undefined, which is already a valid left operand.

Sibling of #36133, which handles the enum-inlining path on the same operand.

Verification

New importstar/NamespaceImportMissingES6ExponentiationLHS in test/bundler/esbuild/importstar.test.ts fails against the unfixed build (bundle contains void 0 ** 2 and does not run) and passes with the fix. It also asserts no extra parentheses are added for the right operand or for an existing export.


[stamp-90s] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/importstar.test.ts
bun test v1.4.0 (549a14b7f)

test/bundler/esbuild/importstar.test.ts:
(pass) bundler > importstar/ImportStarUnused [1275.03ms]
(pass) bundler > importstar/ImportStarCapture [597.61ms]
(pass) bundler > importstar/ImportStarNoCapture [545.66ms]
(pass) bundler > importstar/ImportStarExportImportStarUnused [519.30ms]
(pass) bundler > importstar/ImportStarExportImportStarNoCapture [476.94ms]
(pass) bundler > importstar/ImportStarExportImportStarCapture [445.60ms]
(pass) bundler > importstar/ImportStarExportStarAsUnused [528.10ms]
(pass) bundler > importstar/ImportStarExportStarAsNoCapture [561.91ms]
(pass) bundler > importstar/ImportStarExportStarAsCapture [582.46ms]
(pass) bundler > importstar/ImportStarExportStarUnused [1013.08ms]
(pass) bundler > importstar/ImportStarExportStarNoCapture [589.04ms]
(pass) bundler > importstar/ImportStarExportStarCapture [481.20ms]
(pass) bundler > importstar/ImportStarCommonJSUnused [492.46ms]
(pass) bundler > importstar/ImportStarCommonJSCapture [620.91ms]
(pass) b
... (truncated)

release without fix: 1 failed, 4 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/esbuild/importstar.test.ts:
(pass) bundler > importstar/ImportStarUnused [37.00ms]
(pass) bundler > importstar/ImportStarCapture [23.39ms]
(pass) bundler > importstar/ImportStarNoCapture [24.41ms]
(pass) bundler > importstar/ImportStarExportImportStarUnused [27.80ms]
(pass) bundler > importstar/ImportStarExportImportStarNoCapture [25.48ms]
(pass) bundler > importstar/ImportStarExportImportStarCapture [22.98ms]
(pass) bundler > importstar/ImportStarExportStarAsUnused [23.17ms]
(pass) bundler > importstar/ImportStarExportStarAsNoCapture [24.39ms]
(pass) bundler > importstar/ImportStarExportStarAsCapture [22.96ms]
(pass) bundler > importstar/ImportStarExportStarUnused [23.43ms]
(pass) bundler > importstar/ImportStarExportStarNoCapture [29.49ms]
(pass) bundler > importstar/ImportStarExportStarCapture [24.61ms]
(pass) bundler > importstar/ImportStarCommonJSUnused [24.92ms]
(pass) bundler > importstar/ImportStarCommonJSCapture [23.42ms]
(pass) bundler > importstar/ImportStarCommonJSNoCapture [24.17ms]
(pass) bundler > importstar/ImportStarAndCommonJS [4.32ms]
(pass) bundler > importstar/ImportStarNoBundleUnused [24.10ms]

... (truncated)
passes on PR (with fix)
ASAN with fix: 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/importstar.test.ts
bun test v1.4.0 (549a14b7f)

test/bundler/esbuild/importstar.test.ts:
(pass) bundler > importstar/ImportStarUnused [1124.70ms]
(pass) bundler > importstar/ImportStarCapture [475.02ms]
(pass) bundler > importstar/ImportStarNoCapture [452.10ms]
(pass) bundler > importstar/ImportStarExportImportStarUnused [477.50ms]
(pass) bundler > importstar/ImportStarExportImportStarNoCapture [459.18ms]
(pass) bundler > importstar/ImportStarExportImportStarCapture [458.33ms]
(pass) bundler > importstar/ImportStarExportStarAsUnused [416.00ms]
(pass) bundler > importstar/ImportStarExportStarAsNoCapture [467.30ms]
(pass) bundler > importstar/ImportStarExportStarAsCapture [564.47ms]
(pass) bundler > importstar/ImportStarExportStarUnused [568.23ms]
(pass) bundler > importstar/ImportStarExportStarNoCapture [570.35ms]
(pass) bundler > importstar/ImportStarExportStarCapture [1016.47ms]
(pass) bundler > importstar/ImportStarCommonJSUnused [518.94ms]
(pass) bundler > importstar/ImportStarCommonJSCapture [499.65ms]
(pass) b
... (truncated)

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

22 deps, 108 codegen, 1171 objects in 1052ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [22.00ms]
[2/1234] gen ErrorCode+*.h
[3/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [7.00ms]
[4/1234] fetch picohttpparser
[picohttpparser] up to date
[5/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [19.00ms]
[6/1234] gen bindgenv2
[7/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1234] fetch zlib
[zlib] up to date
[9/1234] fetch tinycc
[tinycc] up to date
[10/1234] gen .bind.ts → GeneratedBindings.cpp
[11/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindin
... (truncated)
diff hotspot
src/js_printer/lib.rs                   |  6 ++++++
 test/bundler/esbuild/importstar.test.ts | 28 +++++++++++++++++++++++++++-
 2 files changed, 33 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                     reads  edits  tests
src/js_printer/lib.rs                        7      1      0
test/bundler/esbuild/importstar.test.ts      2      2      0

self-review · no surviving concerns

28 concerns were raised and did not survive verification.

A namespace-import property with no matching export is rewritten to
EImportIdentifier with import_item_status = Missing, which the printer
replaces with undefined. Under --minify-syntax that emits 'void 0',
and on the left of ** it was printed at Level::Exponentiation, below
print_undefined's Level::Prefix wrap threshold, producing the
SyntaxError 'void 0 ** 2'.

Bump left_level to Call for EImportIdentifier when minify_syntax is on,
mirroring the existing EBoolean arm. The non-Missing cases print an
identifier or a member access, neither of which consults the incoming
level, so the bump is a no-op there.
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with bun build --minify-syntax on a two-file project where ns.nope has no matching export: output contains void 0 ** 2, which fails to parse. New test fails on main and passes with the fix; full importstar, ts, and transpiler bundler suites pass locally.

CI build 83526: the bundler test added here passes on every lane. The only red is test/js/bun/http/serve.test.ts "releases a paused request body when the handler responds without reading it" on darwin 14 x64, which #36088 is already open to fix; the remaining lanes are retry-pass flakes (bake dev-server timeout on Windows, webview on Alpine, napi and issue-20144 on darwin aarch64). None touch the printer.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The JavaScript printer now applies call-level precedence to missing import identifiers on the left side of minified exponentiation. A bundler regression test verifies generated parentheses, warnings, and runtime output.

Changes

Exponentiation import identifier handling

Layer / File(s) Summary
Printer precedence handling
src/js_printer/lib.rs
Minified exponentiation treats import identifiers on the left operand with call-level precedence.
Bundler regression coverage
test/bundler/esbuild/importstar.test.ts
Adds assertions for runtime output, the missing-export warning, and (void 0) wrapping in generated code.

Possibly related PRs

  • oven-sh/bun#36122: Related minified handling of undefined-like values and void 0 output in the JavaScript printer.

Suggested reviewers: jarred-sumner

🚥 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.
Title check ✅ Passed The title clearly describes the main fix: parenthesizing missing-import void 0 on the left of **.
Description check ✅ Passed The description covers the change and verification, though it uses Repro/Cause/Fix/Verification instead of the template headings.

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 — targeted 6-line printer fix following the existing EBoolean arm pattern.

What was reviewed:

  • Confirmed print_undefined wraps only when level >= Prefix, so bumping left_level to Call under minify_syntax correctly forces (void 0).
  • Checked the EImportIdentifier print path (src/js_printer/lib.rs:3932): non-missing branches emit an identifier or member access and never consult level, so the unconditional bump is a no-op there — matching the test's not.toMatch(/\(\w+\) \*\* 2/) assertion.
  • Test covers LHS (wrapped), RHS (unwrapped, right-associative), and an existing export (unwrapped), plus runs the bundle and asserts the warning.
Extended reasoning...

Overview

This PR adds a single EImportIdentifier arm to the BinPow left-operand match in binary_check_and_prepare (src/js_printer/lib.rs). Under --minify-syntax, a namespace-import property with no matching export is printed as void 0 via print_undefined, which only parenthesizes when the incoming level is >= Prefix. Since ** is right-associative, the left operand's level was staying at Exponentiation (< Prefix), producing void 0 ** 2 — a SyntaxError. The fix bumps left_level to Call, mirroring the existing EBoolean arm two lines above (booleans print as !0/!1 under the same option and need the same treatment).

Security risks

None. This is a code-printer precedence adjustment for an edge case (missing import used as ** LHS). No parsing of untrusted input, no I/O, no allocation.

Level of scrutiny

Low. The change is 6 lines that copy the pattern of the adjacent EBoolean/EBranchBoolean arm exactly, gated on the same minify_syntax flag. I verified in the EImportIdentifier print handler at line 3932 that the non-missing branches (namespace alias → member access, or plain identifier) do not read the incoming level, so the unconditional bump cannot introduce spurious parentheses — the test's negative assertions (not.toContain("(2 ** void 0)"), not.toMatch(/\(\w+\) \*\* 2/)) confirm this. Without minify_syntax the missing import prints as the identifier undefined, which is already a valid ** LHS, so the minify_syntax gate is correct.

Other factors

The new itBundled test is well-constructed per the repo's bundler-test conventions: it runs the output bundle (proving no SyntaxError), asserts exact output shape for the wrapped LHS and unwrapped RHS/existing-export, and checks the "will always be undefined" warning. It sits alongside the existing NamespaceImportMissingES6 test in the right file. The PR description explains the mechanism precisely and references the sibling PR #36133 for the enum-inlining path. No outstanding reviewer comments.

@dylan-conway

Copy link
Copy Markdown
Member

@robobun is this exactly what esbuild does?

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

No. esbuild 0.28.1 has the same bug:

$ npx esbuild@latest entry.ts --bundle --minify-syntax
(() => {
  // entry.ts
  console.log(void 0 ** 2);
})();
$ node -e "$(...)"
SyntaxError: Unary operator used immediately before exponentiation expression.

esbuild's BinOpPow arm bumps leftLevel for EUnary/EAwait/EUndefined/ENumber and (under MinifySyntax) EBoolean, but not EImportIdentifier, while its EImportIdentifier handler also calls printUndefined(expr.Loc, level) for ImportItemMissing. So the gap is the same as in bun's printer.

This PR adds the missing arm in the same shape as the existing EBoolean case (gated on MinifySyntax because without it undefined is printed as an identifier, which is already a valid left operand). It diverges from esbuild only in that it produces valid output for this input.

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.

2 participants