Skip to content

Hoist the lowering of import ... from "bun" above the module body at runtime - #39206

Open
robobun wants to merge 1 commit into
mainfrom
farm/394ab85a/hoist-bun-import-rewrite
Open

Hoist the lowering of import ... from "bun" above the module body at runtime#39206
robobun wants to merge 1 commit into
mainfrom
farm/394ab85a/hoist-bun-import-rewrite

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • At runtime, a binding from import { x } from "bun" is undefined in any code written above the import statement. import { readFileSync } from "fs" in the same position works, so only the "bun" specifier is affected.
    console.log(typeof escapeHTML); // "undefined", expected "function"
    import { escapeHTML } from "bun";
    With a real use instead of typeof: TypeError: escapeHTML is not a function. (In 'escapeHTML("<a>")', 'escapeHTML' is undefined).
  • Reproduces with bun 1.4.0 and a debug build of main, for bun file.mjs, .ts, .cjs, bun -e and bun test files alike. bun build --target=bun is not affected.
  • Cause: the runtime transpiler does not load "bun" as a module. The printer replaces the statement with var declarations (src/js_printer/lib.rs, SImport arm of print_stmt calling print_global_bun_import_statement), and it prints them where the statement was written. The parser only moves import statements to the front of the file when bundling (src/js_parser/parse/parse_entry.rs, SImport case in _parse); at runtime it keeps them in place because real import statements are hoisted by the engine anyway. A var initialized at the statement's position is not, so the binding exists but is still undefined when earlier statements run. Transpiled output before this change:
    const before = typeof color;
    var {color } = globalThis.Bun;
    console.log({ before, after: typeof color });

Fix

  • print_ast (the runtime transpiler's printing entry point) now prints the "bun" import statements of a file before the rest of the module, then skips them while printing the parts in order. The output for the example above starts with var {color } = globalThis.Bun;. Files without such an import are detected from the import records and take the single loop they took before.
  • Why this is the right place: the lowering itself is a printer decision, and print_ast is only used for the non-bundled path, which is the only path that leaves imports in place. The bundler (bun build --target=bun) already emits these declarations at the top of the module, and the Bake dev server likewise binds builtin imports at the top of its module wrapper, so this makes the runtime agree with them. The import records are tagged by two different runtime paths (Linker::link and RuntimeTranspilerStore), and both print through print_ast, so both are covered by one change. Printing through the existing SImport arm keeps the output text, source mappings and module record data identical to before; only the position moves.
  • Why it is correct: the declarations only read globalThis.Bun, which exists before any user module runs, and an ES module's imported modules are all evaluated before its own body starts, so the front of the body is the earliest point in the module and still after every dependency. This is also the position the statement would have been hoisted to had it stayed an import. The one observable difference besides the fix is a module that assigns to Bun.* and only then, further down, imports that name from "bun": it now gets the value from before the assignment, which is what bun build and a real import give it too.
  • The runtime transpiler cache version is bumped (src/jsc/RuntimeTranspilerCache.rs, 25 to 26) because cached output for files with a late "bun" import would otherwise keep the old layout after upgrading. js_printer: fix require("bun") and other printer literals being captured by same-named locals #35739, Stop rewriting a literal import("bun") to Promise.resolve(globalThis.Bun) #37730 and bun:test: hoist jest.mock/vi.mock/mock.module above imports #36297 also bump this constant, so whichever of these lands later needs a one-line rebase there.
  • Scope, and the alternative: this fixes the statement-order divergence without changing what the lowering produces. The underlying cause is the lowering itself (it predates the loader's real "bun" module), and the same lowering also still differs from a real import in an import cycle (an exported function called before this module's body has run sees the binding as undefined; bun build shares that limitation), for mock.module("bun", ...), and for import { default as B } from "bun" (binds undefined, runtime and bundler alike; reported separately). The alternative that fixes all of those at once is to stop lowering the statement at runtime (keep the SImport rewrite only when options.bundling) and let the loader link the existing native module; it is about as small as this change and would delete it, and the tests here are written against the imported values rather than the lowering, so they carry over unchanged. It is not done here because it is user visible in two ways this change is not: import * as ns from "bun" becomes a module namespace (ns !== Bun) and importing a name the Bun object does not have becomes a link-time SyntaxError instead of undefined. That is the same trade Stop rewriting a literal import("bun") to Promise.resolve(globalThis.Bun) #37730 proposes for import("bun"); if that direction is taken for static imports too, this PR becomes unnecessary. The bug itself was found while working on something else, not from a user report; none of the roughly 500 files in this repository that import from "bun" uses a binding above the statement, so this is a correctness fix rather than a reported breakage.
  • Verification: test/bundler/transpiler/runtime-transpiler.test.ts, new import ... from "bun" block. Three cases (bun -e, entry.mjs, entry.ts) use named, default and namespace imports above their import statements, interleaved with a node:path import, a require() call (which shares the hoisting spot with the var {require}=import.meta; declaration) and, for TypeScript, import type and inline type specifiers. The file cases also check that a stack trace line number still maps to the source once the output no longer lines up with it. All three fail on the released bun with the TypeError above and pass with this change.
  • Also run on this build: the rest of runtime-transpiler.test.ts, test/js/bun/resolve/import-meta.test.js, test/js/bun/resolve/builtin-esm-lazy-exports.test.ts, test/cli/run/transpiler-cache.test.ts, test/cli/run/run-eval.test.ts, test/bundler/bundler_bun.test.ts, test/cli/inspect/debugger-buntranspiledmodule.test.ts (bun test --isolate, which is the path that records module info while printing), test/cli/inspect/BunFrontendDevServer.test.ts. Manually checked the printed output for a multi-line import, import "bun", import B, { x } from "bun", import * as ns from "bun", a file that also uses require, a .cjs file, a file with a top-level using, and export { x } from "bun" (a real re-export, untouched).

Background

  • "bun" imports at runtime: when the runtime transpiler resolves a file's imports, the specifier "bun" is matched against the builtin alias table and its import record is tagged ImportRecordTag::Bun. The printer turns such a statement into var declarations reading globalThis.Bun (var {x} = globalThis.Bun, var B = globalThis.Bun) instead of printing an import, so the module loader never sees the specifier. require("bun") and import("bun") are inlined the same way as expressions and are not affected by this PR.
  • Import hoisting: in an ES module, import statements are processed while the module is linked, before any statement of its body runs, so imported bindings are usable anywhere in the file regardless of where the statement is written. A var is different: its name exists from the start but it only gets its value when execution reaches the declaration.
  • Parts: the parser splits a non-bundled file into one part per top-level statement, in source order, and print_ast prints them in that order. When bundling, the parser moves import parts to the front, which is why the bundler never had this problem.
  • Runtime transpiler cache: bun caches the printed output of source files on disk under a version constant; bumping it discards entries whose printed output would differ now.
Probes
$ cat hoist3.mjs
const before = typeof color;
import { color } from "bun";
console.log({ before, after: typeof color });

$ bun hoist3.mjs            # 1.4.0
{ before: "undefined", after: "function" }

$ bun-debug hoist3.mjs      # this branch
{ before: "function", after: "function" }

Transpiled output on this branch for a file combining the shapes (debug build source dump):

var {require}=import.meta;var {
  escapeHTML, 
  stringWidth: sw
} = globalThis.Bun;
var B = globalThis.Bun;
var {stringWidth } = B;
var ns =globalThis.Bun;

export class Foo {
}
const { sep } = require("node:path");
const before = [typeof escapeHTML, typeof stringWidth, typeof B, typeof ns, typeof basename];
import { basename } from "node:path";
export default escapeHTML;
console.log(before, typeof sw, B === Bun, ns === Bun, typeof sep);

(export class Foo {} is moved up by the parser independently of this change; the "use strict" directive the file started with is dropped by the parser for ES modules, also independently of this change.)

…body

At runtime, `import ... from "bun"` is printed as `var` declarations
reading globalThis.Bun, at the position of the import statement. Unlike
the import statement it replaces, those declarations are not hoisted, so
a binding used above the import was undefined when the module ran.
print_ast now prints these statements before the rest of the module.

The runtime transpiler cache version is bumped so cached output printed
with the old layout is not reused.
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 34 minutes

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: 59477b08-da48-418e-b1e5-0080f4696c33

📥 Commits

Reviewing files that changed from the base of the PR and between 0bfeade and 5ee183a.

📒 Files selected for processing (3)
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/transpiler/runtime-transpiler.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 PM PT - Aug 15th, 2026

@robobun, your commit 5ee183a7c29faf1246ba71a78b031283ae800ac8 passed in Build #98650! 🎉


🧪   To try this PR locally:

bunx bun-pr 39206

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

bun-39206 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, self-review done, waiting on CI and a maintainer.

Reproduced with bun 1.4.0 and a debug build of main: a file that uses a binding above its import { ... } from "bun" statement sees undefined (TypeError: escapeHTML is not a function with a real use), while the same layout with any other specifier works. The three new cases in test/bundler/transpiler/runtime-transpiler.test.ts fail on the released bun and pass with this branch.

Self-review outcome: the fix stands as the change that does not alter what the import evaluates to. The larger option (stop lowering the statement at runtime and let the loader link "bun", which would also cover import cycles, mock.module("bun") and import { default as B }) changes namespace identity and turns unknown names into link errors, so it is described in the PR body as the alternative for a maintainer to weigh alongside #37730 rather than done here. The import { default as B } from "bun" bug found on the way is being reported separately.

@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 — small, well-guarded printer fix with matching cache-version bump and thorough test coverage.

What was reviewed:

  • print_ast hoist loop and skip in the main loop use the same is_global_bun_import_statement predicate, so no statement is duplicated or dropped; the predicate matches exactly what the existing SImport arm routes to print_global_bun_import_statement (both check record.tag == ImportRecordTag::Bun under IS_BUN_PLATFORM).
  • has_global_bun_import_statement gates the extra pass on ImportKind::Stmt, so files without a "bun" import statement take the same single loop as before; require("bun")/import("bun") records don't trigger it.
  • Hoisted statements go through print_stmt, so add_source_mapping(stmt.loc) and module-info recording still happen — only output position moves; the new test asserts a stack-trace line still maps back to source line 2.
  • EXPECTED_VERSION bumped 25→26 so cached output with the old layout is invalidated.
Extended reasoning...

Overview

This PR fixes import { x } from "bun" bindings being undefined when read by code written above the import statement. The runtime transpiler lowers such imports to var {...} = globalThis.Bun declarations printed at the statement's source position, which — unlike the import they replace — are not hoisted by the engine. The fix makes print_ast (the runtime transpiler's printing entry point) emit these declarations ahead of the module body, then skip them in the main statement loop. Three files touched: ~40 lines in src/js_printer/lib.rs (two new predicates + a guarded pre-pass in print_ast), a one-line cache-version bump in src/jsc/RuntimeTranspilerCache.rs, and a new test block in test/bundler/transpiler/runtime-transpiler.test.ts.

Security risks

None. The change reorders output of statements the printer already emits verbatim; no new parsing, no untrusted input handling, no allocation or FFI changes. The hoisted declarations read globalThis.Bun, which is available before any user module runs.

Level of scrutiny

Medium. print_ast runs for every module the runtime loads, so a mistake here would be broadly visible — but the change is guarded: has_global_bun_import_statement() scans the (short) import-records list and returns false for any file without an ImportRecordTag::Bun + ImportKind::Stmt record, in which case the main loop is byte-identical to before (the hoist_bun_imports && ... short-circuits). For files that do have such an import, the hoist pass and the skip in the main loop use the identical is_global_bun_import_statement predicate, which matches exactly what the existing StmtData::SImport arm (lib.rs:5731-5736) already dispatches to print_global_bun_import_statement. There is no way for a statement to be printed twice or dropped.

Other factors

  • Placement is right: the hoist runs after the existing var {require}=import.meta; prelude and before the parts loop, matching where a real import would be hoisted. Since print_stmt is used for the hoisted call, print_indent/add_source_mapping/prev_stmt_tag bookkeeping is preserved — the test verifies source-map line numbers still resolve correctly after reordering.
  • Other print_ast callers (Bun.Transpiler in src/bundler/transpiler.rs, the REPL) either don't tag records with ImportRecordTag::Bun (no linking) or want the same hoisting semantics; the IS_BUN_PLATFORM const-generic (bound to ASCII_ONLY for print_ast, lib.rs:7498) gates the whole thing off for non-bun-target printing.
  • Cache version correctly bumped with a comment following the file's existing convention.
  • Tests cover named/default/namespace imports, TS import type and inline type specifiers, interleaving with a real node:path import and require(), both -e and file entry (.mjs/.ts), and assert source-mapped stack-trace line numbers. They use bunRun/tempDir from harness, test.concurrent, and check stderr/stdout before exit code per repo conventions.
  • The PR description is unusually thorough about mechanism, alternatives, and what was manually verified; the bug-hunting system found nothing.

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