Stop rewriting a literal import("bun") to Promise.resolve(globalThis.Bun) - #37730
Stop rewriting a literal import("bun") to Promise.resolve(globalThis.Bun)#37730robobun wants to merge 2 commits into
Conversation
|
Warning Review limit reached
Next review available in: 9 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status: reproduced on bun 1.4.0 and on main with two otherwise identical files, Fix in this PR: the printer no longer rewrites a literal For the maintainer looking at this: the one deliberate behavior change is that a literal CI for the current head (build 93159): 178 jobs passed, including every Linux lane and the ASAN lane, which is where the files this PR touches are exercised under exception-check validation. The remaining red is unrelated to the diff: |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the observable result of await import("bun") (now a module namespace rather than Bun itself) and overlaps with #35739 in the same printer hunk, a maintainer sign-off on the behavior change would be worthwhile.
Checked that the removed ImportKind::Dynamic branch falls through cleanly to the external-import() path at src/js_printer/lib.rs:2624 (which calls print_space_before_identifier, so the minify-whitespace fix noted in the description holds). Confirmed ImportRecordTag::Bun handling for static imports (print_global_bun_import_statement at :5625) and the bake dev-server hmr.dynamicImport path are unaffected. The transpiler cache version bump is correct given cached output changes shape. Tests follow harness conventions (tempDir, concurrent pipe drain, using) and cover the literal/inlined-const/non-inlined/let matrix plus bundler and mock.module paths.
Extended reasoning...
Overview
The PR removes one branch from print_require_or_import_expr in src/js_printer/lib.rs: the ImportKind::Dynamic arm under ImportRecordTag::Bun that rewrote a literal import("bun") to Promise.resolve(globalThis.Bun). The Require/Stmt arm is kept unchanged. RuntimeTranspilerCache EXPECTED_VERSION is bumped 25→26. Three test files gain coverage: the runtime transpiler path (four specifier spellings in import-meta.test.js), the bundler path with and without --minify-syntax (bundler_bun.test.ts), and mock.module("bun") now applying to literal dynamic imports (mock-module.test.ts).
Security risks
None. No untrusted input handling, no auth/crypto, no memory-safety surface. Pure printer control-flow simplification plus a cache version bump.
Level of scrutiny
Medium-high. The mechanical change is small and clearly correct — the deleted branch falls through to the existing external-import() path used by every other builtin, and I traced that path to confirm print_space_before_identifier runs and import_options are now honored. But the effect is a user-visible semantic change: (await import("bun")) === Bun flips from true to false for the literal form (it was already false for computed specifiers). The PR description makes a strong case that this is a consistency fix, and property/destructuring access keeps working since the namespace mirrors Bun.*, but it is still an observable break that a maintainer should ratify.
Other factors
- The PR notes textual overlap with open PR #35739 in the same hunk; a human should decide sequencing.
- Test quality is high: hermetic (
tempDir,bunEnv, child processes), pipes drained concurrently, exit code asserted last,describe.concurrent/it.eachfor the variant matrix, and the modifiedimport-meta.test.jscase would fail on the old build (the PR statesUSE_SYSTEM_BUN=1verification was done). - Grepped for other
ImportRecordTag::Bunsites andPromise.resolve(globalThis.Bun)references — no stragglers. The static-import path atjs_printer/lib.rs:5625and the bake dev-server builtin check atconvertStmtsForChunkForDevServer.rs:116are independent of this change.
|
Updated 11:05 AM PT - Aug 12th, 2026
❌ @robobun, your commit 132085e has 1 failures in
🧪 To try this PR locally: bunx bun-pr 37730That installs a local version of the PR into your bun-37730 --bun |
bf9c7a0 to
774d252
Compare
774d252 to
0f7640d
Compare
There was a problem hiding this comment.
Thanks — 79213a9 addressed the stale-comment note (the builtin-esm-lazy-exports.test.ts comments now match the new behavior, and the import() test uses the literal directly). Re-review found no issues. Not auto-approving only because (await import("bun")) === Bun going from true to false is a user-visible default change you already flagged for maintainer sign-off.
What was reviewed:
- Printer change:
ImportKind::Dynamicfalls through to the normal external-import path;Require/Stmtstill inline toglobalThis.Bun;wraphandling preserved. - Transpiler cache version bump (25→26) so cached
Promise.resolve(globalThis.Bun)output is invalidated. - New tests follow harness conventions (
tempDir, concurrent pipe drain, child process for themock.module("bun")case) and cover literal / inlined-const / non-inlined /letplus the bundler output shape.
Extended reasoning...
Overview
The PR removes one branch from print_require_or_import_expr in src/js_printer/lib.rs: ImportRecordTag::Bun with ImportKind::Dynamic no longer emits Promise.resolve(globalThis.Bun), so a literal import("bun") reaches the loader like any other dynamic import and resolves to the "bun" module namespace. Require and Stmt kinds keep the globalThis.Bun inlining. EXPECTED_VERSION in RuntimeTranspilerCache.rs is bumped 25→26. Four test files are updated/extended: import-meta.test.js (namespace shape + four-spelling matrix in subprocesses), bundler_bun.test.ts (output contains import("bun"), runtime prints [object Module]), mock-module.test.ts (mock applies to literal and computed alike, in a child), and builtin-esm-lazy-exports.test.ts (stale comments fixed, specifier indirection dropped so the test now exercises this path).
Security risks
None. This is a printer/transpiler output change for the "bun" builtin specifier; no untrusted-input parsing, no auth/crypto/permissions surface.
Level of scrutiny
Medium-high. The code change is a ~15-line simplification and the mechanism is well-argued in the description, but it changes the observable result of await import("bun") from Bun itself to a module namespace whose default is Bun. That is the correct direction (it makes the literal agree with every non-literal spelling, with mock.module, and with how every other builtin behaves under import()), but it is still a user-facing default change to a Bun-native API — the kind of decision REVIEW.md routes to a maintainer rather than an automated approval. The author explicitly flagged this in the thread as the one thing needing sign-off.
Other factors
My prior inline note about stale comments in builtin-esm-lazy-exports.test.ts was addressed in 79213a9: the comment at lines 43–44 now correctly says only static import ... from "bun" and require("bun") are rewritten, the specifier indirection and its explanatory comment are deleted, and the affected test uses await import("bun") directly (so it now fails on the old printer with defaultIsBun: false, per the updated PR description). The comment-cop bot threads on lib.rs and RuntimeTranspilerCache.rs are resolved — the surviving comments are three and two lines respectively and match neighboring conventions. Test coverage is thorough (variant matrix, bundler output assertion, mock isolation in a subprocess, cache-version bump), and the description records manual checks of .cjs, --format=cjs, --minify, --compile, and Bake dev-server paths. The only open item is the maintainer call on the behavior direction; there is also an open PR #35739 touching the same hunk that whichever lands second will need to reconcile.
There was a problem hiding this comment.
I re-reviewed after 79213a9 and found no further issues — the stale-comment note is addressed and the import("bun") case in builtin-esm-lazy-exports.test.ts now exercises the changed path directly. Deferring to a maintainer only because this deliberately changes what a literal await import("bun") returns (namespace instead of Bun itself), which is an API-visible decision the author already flagged for sign-off.
What was reviewed:
- Printer change:
ImportKind::Dynamicno longer matches theImportRecordTag::Buninline;Require/Stmtstill do, and the fall-through prints a normal externalimport("bun"). - Transpiler cache version bump to 26 so cached
Promise.resolve(globalThis.Bun)output is invalidated. - New tests cover runtime (four specifier spellings agree), bundler
--target=bunoutput with and without--minify-syntax, andmock.module("bun")reaching the literal form.
Extended reasoning...
Overview
The PR drops the ImportKind::Dynamic arm of the ImportRecordTag::Bun rewrite in src/js_printer/lib.rs:2416-2428, so a literal import("bun") is printed as a real dynamic import and resolved by the loader to the "bun" module namespace, matching what a computed specifier already returned. require("bun") and static import ... from "bun" remain inlined to globalThis.Bun. EXPECTED_VERSION in RuntimeTranspilerCache.rs is bumped 25→26 so on-disk .pile entries carrying the old rewrite are discarded. Four test files add coverage: import-meta.test.js (namespace shape + four-spelling parity in subprocesses), bundler_bun.test.ts (bun build --target=bun output and run, plus --minify-syntax const-inlining case), mock-module.test.ts (child bun test verifying mock.module("bun") now applies to the literal), and builtin-esm-lazy-exports.test.ts (comment update + the import() test now uses the literal, which fails on the old printer).
Security risks
None. No untrusted-input parsing, no auth/crypto, no filesystem or network surface. The only runtime effect is that a literal import("bun") now goes through the same loader path a computed one already did.
Level of scrutiny
Moderate. The src/ change is a small, well-scoped deletion in the printer plus a cache version bump, and the test coverage is thorough (each new test fails without the fix per the description). What raises this above auto-approve is that it is a deliberate user-visible behavior change: (await import("bun")) === Bun was previously true for the literal form and is now false (.default === Bun instead). The author explicitly calls this out as the one thing a maintainer should confirm. That is an API-surface decision, not a mechanical fix.
Other factors
My earlier inline note about stale comments in builtin-esm-lazy-exports.test.ts was addressed in 79213a9 — the file now correctly documents that only static import/require are rewritten, and the specifier indirection was removed so the test uses the literal directly. The comment-cop bot flags on comment length were also addressed and resolved. There is a related open PR (#35739) that overlaps textually in the same hunk; the description already explains the interaction. The PR depends on #37714 (lazy "bun" module exports) for the load-cost / exception-checks story; that dependency is stated and the branch is rebased onto it. Given the API decision and the stated dependency, a maintainer sign-off is the right bar here.
…s.Bun)
The module loader resolves "bun" to a module namespace (default = Bun plus
one named export per property), and that is what import() returns whenever
the specifier is not a literal. The printer rewrote the literal form into
Promise.resolve(globalThis.Bun) instead, so a const holding "bun" returned
one or the other depending on whether const inlining happened to fold it
(which depends on unrelated statements earlier in the file), and the same
split showed up in bun build --minify-syntax output.
Drop the dynamic import half of the rewrite so the literal takes the loader
path too. require("bun") keeps being inlined to globalThis.Bun, which is
also what the loader returns for a non-literal require(). Bump the runtime
transpiler cache version so entries holding the old rewrite are not reused.
e809987 to
132085e
Compare
There was a problem hiding this comment.
I reviewed this PR and did not find any bugs. Because it deliberately changes what a literal await import("bun") returns (namespace instead of the Bun object) — a user-facing behavior change the description explicitly flags for a maintainer — a human sign-off on that direction would be worthwhile.
What was reviewed:
- Printer fall-through for
ImportKind::DynamicwithImportRecordTag::Bunreaches the externalimport()path withprint_space_before_identifier(), soimport_optionsand the--minify-whitespaceseparator are handled. ImportKind::Stmtstill hits theglobalThis.Buninline (this arm is unreachable viaprint_require_or_import_expr; static imports are handled elsewhere), so no change there.- Transpiler cache version bump matches the printed-output change; tests cover literal, inlined-const, non-inlined-const,
let, bundler with/without--minify-syntax, andmock.module.
Extended reasoning...
Overview
Drops the ImportKind::Dynamic arm of the ImportRecordTag::Bun rewrite in src/js_printer/lib.rs::print_require_or_import_expr, so a literal import("bun") is printed as an external dynamic import and resolved by the loader instead of being replaced with Promise.resolve(globalThis.Bun). require("bun") and static import ... from "bun" are still inlined. RuntimeTranspilerCache EXPECTED_VERSION is bumped 25 → 26. Four test files add/update coverage: import-meta.test.js (four spellings of the specifier in child processes), bundler_bun.test.ts (bundled output shape + run), mock-module.test.ts (mock applies to the literal), and builtin-esm-lazy-exports.test.ts (uses the literal now that it reaches the loader; stale comments updated per my earlier note).
Security risks
None identified. This is a printer-output change for a hardcoded builtin specifier; no untrusted input reaches new parsing or allocation.
Level of scrutiny
High — the printer is on the hot path for every transpiled file, and the change is a deliberate, observable behavior change to user code: (await import("bun")) === Bun goes from true to false, and the returned object is now a module namespace whose default is Bun. Property/destructuring reads keep working (the namespace has a named export per Bun.* property), and the change makes the literal consistent with every non-literal spelling and with other builtins, so the argument for it is strong. But per the guidelines this is exactly the kind of "large scale design decision a human should weigh in on"; the PR body itself frames it as the one deliberate behavior change for a maintainer to look at.
Other factors
- The description notes textual overlap with open PR #35739 (drops the runtime half of both rewrites for the
globalThisshadowing bug); a human can decide the merge order. - Builds on #37714 (lazy
"bun"module exports), already onmain, so the extra module load the literal now triggers does not eagerly reifyBun.*. - Prior review feedback (comment-cop nags on comment length; my note on stale comments in
builtin-esm-lazy-exports.test.ts) has been addressed and resolved. - I traced the fall-through in
print_require_or_import_expr: withrecord.source_indexinvalid the dynamic case reaches the external-import()block, which callsprint_space_before_identifier()and honorsimport_options, so the minify-whitespacereturnimportconcern and dropped-options concern do not apply here.
Problem
await import("bun")returns two different things depending on how the specifier is spelled. A literal gives theBunobject itself ([object Bun],ns.default === Bunis false);import(s)with alet, or aconstdeclared after any other statement, gives a module namespace ([object Module],ns.default === Bun).bun build --target=bun --minify-syntaxshows the same split in its output.import("bun")has two implementations. The printer rewrites a literal toPromise.resolve(globalThis.Bun)(from 2022, before a real"bun"module existed); anything the printer cannot see as a literal goes to the loader's"bun"module (make dynamically importing bun work #4055). Which one a file hits is decided by const inlining, an optimisation that is supposed to be unobservable.mock.module("bun", ...)applied toimport(s)but not toimport("bun"), and import options were silently dropped.Fix
import("bun")is printed as an ordinary external import and the loader resolves it, in the runtime transpiler and inbun build --target=bun.require("bun")and static imports are still inlined toglobalThis.Bun, which is also what the loader returns for arequireof"bun"(theirglobalThisshadowing bug is subtle bundler bug,require("bun")in a scope that defines local variable namedglobalThiswill bundle incorrectly #8058 / js_printer: fix require("bun") and other printer literals being captured by same-named locals #35739, untouched here).import()of a module record can only ever produce a namespace, so the literal has to move to it."bun"then behaves likeimport("bun:sqlite")orimport("node:fs").(await import("bun")) === Bunis now false for the literal (it already was for every other spelling);.default, named exports and destructuring all work. The literal is now a real module load, cheap only because Declare the "bun" module's ESM exports lazily instead of reifying the whole Bun object #37714 made the module's exports lazy, so this PR is stacked on it. The runtime transpiler cache version is bumped so cached output holding the old rewrite is not reused after an upgrade.mock.modulesuites fail without the printer change and pass with it, including four one-file programs (literal, inlined const, non-inlined const,let) that must print identical output. Import options,.cjscallers,--format=cjs,--minify,--compileand the Bake dev server path were checked by hand.Background
"bun"module: since make dynamically importing bun work #4055 the module loader has a native"bun"module, a namespace whosedefaultis theBunglobal with one named export perBun.*property.export * from "bun",mock.module("bun", ...)and every computedimport()already go through it."bun"carries aBuntag, and the printer used that tag to emitglobalThis.Bunin place of the import for all three kinds (require, static import, dynamic import). After this PR only the first two are rewritten.--minify-syntaxin the bundler) substitutes aconststring intoimport(x)only when theconstis in the leading run of const declarations of its scope, the TDZ-safe rule. That is why one extra statement before theconstflips which path a file takes.[review] gate passed · iteration 0 · 6 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
Original description
What
import()of"bun"returned two different kinds of object depending on how the specifier reached the transpiler:let s = "bun"behaves likeb.mjs; a literalimport("bun")behaves likea.mjs.bun build --target=bun --minify-syntaxshows the same split in its output (Promise.resolve(globalThis.Bun)fora.mjs,import(s)forb.mjs). Reproduced on bun 1.4.0 and on main.Cause
Two implementations of
import("bun"):print_require_or_import_expr,ImportRecordTag::Bun) rewrites a literalimport("bun")intoPromise.resolve(globalThis.Bun). This dates from a97914f (2022), when there was no"bun"module in the loader at all and the rewrite was what maderequire("bun")/import("bun")work."bun"module to the loader (generateNativeModule_BunObject): a namespace whosedefaultisBun, with one named export perBun.*property. Everyimport()whose specifier the printer cannot see as a literal goes there, as doexport * from "bun"andmock.module("bun", ...).The runtime transpiler (and
--minify-syntaxin the bundler) inlines aconststring intoimport(s)only when theconstis in the leading run of const declarations of its scope, which is the standard TDZ-safe rule for const inlining and is working as intended. Inlining is supposed to be unobservable; the printer rewrite is what made it observable, because a folded specifier took the first implementation and an unfolded one took the second.Fix
Remove the dynamic-import half of the rewrite. A literal
import("bun")is now printed as a normal externalimport("bun")and the loader resolves it, in both the runtime transpiler andbun build --target=bun.require("bun")is still inlined toglobalThis.Bun: the loader also returns theBunobject for arequire()of"bun", so the two paths already agree there (the shadowedglobalThisproblem with that inlining is #8058 / #35739, which this PR does not touch). The runtime transpiler cache version is bumped because cached output for files containing a literalimport("bun")would otherwise keep the old rewrite after an upgrade.Making the namespace the single answer is the only option that can be consistent: the loader path exists regardless (computed specifiers, re-exports, mocks), and
import()of a module record can only ever produce a namespace object, so the literal has to move to it rather than the other way around. It also makes"bun"behave like every other builtin (await import("bun:sqlite")orawait import("node:fs")return namespaces too), and the oldPromise.resolve(globalThis.Bun)text was skipping the loader entirely, somock.module("bun", ...)applied toimport(s)but not toimport("bun"), and import options were silently dropped. Those now behave the same as for any other module.Observable changes
await import("bun")is the module namespace:ns.default === Bun,ns.serve === Bun.serve,Object.keys(ns)lists theBunproperties.(await import("bun")) === Bunis now false; it was already false for every non-literal form. Destructuring or property access keeps working, and.defaultstarts working.import("bun")is one more module load instead of a resolved promise. This is built on top of Declare the "bun" module's ESM exports lazily instead of reifying the whole Bun object #37714, which makes the"bun"module's exports lazy, so the load constructs nothing on theBunobject up front (before Declare the "bun" module's ESM exports lazily instead of reifying the whole Bun object #37714 the generator reified everyBun.*property, about 5ms on a release build, and that bulk reification also tripsBUN_JSC_validateExceptionCheckson the ASAN lane, which is what the first CI run of this PR hit once the literal form started reaching it). Staticimport ... from "bun"andrequire("bun")are unchanged.--minify-whitespace:return import("bun")was printed asreturnPromise.resolve(...)(the dynamic-import case of Fix missing space between preceding identifier and rewritten globalThis.Bun under --minify-whitespace #30673); the external import path prints the separating space, so that case is fixed here. Therequire("bun")case in Fix missing space between preceding identifier and rewritten globalThis.Bun under --minify-whitespace #30673 still stands.import("bun")now goes throughhmr.dynamicImport("bun")like other builtins do, which falls through to the realimport(); checked manually with a route doing both the literal and a computed import and getting the same namespace.#35739 (open) drops the runtime half of both rewrites for the
globalThisshadowing bug; it would fix the runtime side of this as a side effect but leaves the bundler split in place. The two overlap textually in the same hunk and are otherwise compatible: if it lands first, this PR reduces to deleting the dynamic-import branch for the bundler too.Tests
All of these fail without the
src/change and pass with it:test/js/bun/resolve/import-meta.test.js: the literalimport("bun")test now asserts the namespace shape and that it is the same record asimport(eval("'bun'")); a new concurrent group runs four single-file programs (literal, const that gets inlined, const after an import that does not,let) and expects identical output from each.test/bundler/bundler_bun.test.ts:bun build --target=bunof a literalimport("bun")keepsimport("bun")in the output and prints the namespace when run, withrequire("bun") === Bunchecked alongside; the same through--minify-syntaxwith the specifier in aconst.test/js/bun/test/mock/mock-module.test.ts:mock.module("bun", ...)in a childbun testapplies to the literalimport("bun")and the computed one alike.test/js/bun/resolve/builtin-esm-lazy-exports.test.ts(from Declare the "bun" module's ESM exports lazily instead of reifying the whole Bun object #37714 / Declare node:process and node:module ESM exports lazily as well #37726): its comments described the literalimport("bun")as being rewritten, and itsimport()test routed the specifier through the helper module to keep it away from the transpiler. The comments are updated and the test now uses the literal directly, so it exercises the path this PR changes (it fails on the old printer withdefaultIsBun: false).Also run on this build: the rest of
import-meta.test.js,bundler_bun.test.ts,mock-module.test.tsandbuiltin-esm-lazy-exports.test.ts,test/js/bun/util/BunObject.test.ts,test/cli/run/syntax.test.ts(includesawait import('bun')),test/cli/run/transpiler-cache.test.ts. Manually checkedimport("bun", {...}),import("bun")from a.cjsfile,--format=cjsoutput,--minifyoutput, and abun build --compilebinary; all return the namespace.