Skip to content

transpiler: keep a top-level class and export default where they are written - #46795

Draft
robobun wants to merge 5 commits into
mainfrom
robobun/d6c0c432/transpiler-source-order
Draft

robobun wants to merge 5 commits into
mainfrom
robobun/d6c0c432/transpiler-source-order

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #25569

Problem

  • Outside the bundler, a top-level class or export default moves to the top of the module: typeof C is "function" above class C {}. Node throws ReferenceError.
  • In bun test, a hoisted vi.mock() factory reads the moved class in its first state. Vitest throws.
  • The cause: two before.push(parts.pop()) arms in src/js_parser/parse/parse_entry.rs:1233.

Fix

  • Delete both arms: a class and an export default run where written.
  • The CommonJS wrapper and the REPL transform keep "use strict" behind another first directive. The moved class hid that.
  • Delete the dead append_part bool and AutoAccessor clause. Cache version 52.
  • Verified: 34 new tests in six files fail on main. Self-reviewed: 37 concerns raised, 28 addressed. The rest need a decision (Notes).

Background

Downsides

Notes

State of this PR. The branch holds the parser half: the deletion, the "use strict" repair that goes with it, and the tests. It is a draft and it is not complete. Two things must come before it (see "Decisions this PR waits for"): a maintainer's decision that the move goes, and a loader change that runs a CommonJS file where Node runs it. The half for bun build --compile --aot is written and measured on the tree before #47942 and is not pushed (see "The ahead-of-time compiler").

Repro (main 2596ac5, node v26.3.0)

// repro.mjs
const t = f => { try { return f(); } catch (e) { return "THROW:" + e.constructor.name; } };
console.log(t(() => typeof Pure));    // node: THROW:ReferenceError   bun: function
console.log(t(() => new Pure().m())); // node: THROW:ReferenceError   bun: constructed-before-declaration
class Pure { m() { return "constructed-before-declaration"; } }

A program can also get a wrong result with no error. This prints undefined on main. Node throws, and so does the bun build --target=bun output of the same file.

const first = make();
function make() { return new Config(); }
class Config { constructor() { this.mode = Config.defaultMode; } }
Config.defaultMode = "safe";
console.log(first.mode);

#25569 is a user report of the result. For new X() above class X { name = foo } Node prints Cannot access 'X' before initialization. A release build of main runs the constructor and prints Cannot access '' before initialization. This branch prints Node's message. (The empty name is a second bug and is not part of this PR.)

test262 (revision 3655e7464d, language/module-code, 592 module tests that a small harness can run): 13 tests fail on main and pass here, 0 the other way. They are instn-iee-bndng-cls.js, instn-named-bndng-cls.js, instn-named-bndng-dflt-cls.js, -dflt-expr.js, -dflt-named.js, -dflt-star.js and seven namespace/internals/*-uninit.js files.

What moved. class C {}, export class C {}, export default class N {}, export default class {}, export default 42, export default () => a and export default function f() {}. A class with extends, a decorator, a static block, a computed key or a static initializer that is not a constant did not move, so two classes in one file could differ.

Where it applied, and where this PR ends. Every parse with tree_shaking on and bundle off: bun run (ES modules and CommonJS), bun test, Bun.plugin onLoad text, macro and plugin modules that a build runs, bun build --no-bundle --target=bun, and Bun.Transpiler with treeShaking or exports. This PR covers those. It does not change the bundler: the linker prints a can_be_moved() class above an __esm wrapper (generateCodeForFileInChunkJS.rs), and the dev server's HMR lowering places a default export in hmr.exports (lower_esm_exports_hmr.rs). A wrapped bundle module cannot throw on an early read in any case, because module-scope let and const become var there.

Hoisted mock factories. Since #44833, bun test moves vi.mock() and jest.mock() above the imports of the file. docs/test/mocks.mdx says: "A hoisted factory can't use the file's top-level variables, because they aren't initialized yet." A class is the exception on main, only because of the move:

import { test, vi } from "bun:test";
import { value } from "./mod";
class Fake { static v = "first"; }
Fake.v = "second";
vi.mock("./mod", () => ({ value: Fake.v }));
test("t", () => console.log("VALUE " + value));

Main prints VALUE first and the file passes. Vitest 4.1.9 on Node fails the file with ReferenceError: Cannot access 'Fake' before initialization. With this PR a class behaves as a const already does on main: bun test reports that ReferenceError, with the note about vi.hoisted(). Three layouts keep the import of the mocked module static (the file exports the import again, the import has attributes, or the mock is in a helper module). There the factory waits for the next import of the module (mockModule in src/jsc/bindings/BunPlugin.cpp), and the file prints VALUE real, as const Fake = class {} does on main. mock-hoist.test.ts has rows for class, export class and export default class, each for vi.mock and jest.mock.

History. eec1a07 and c3dc64d (April 2023) added the move for kysely-org/kysely#412 and #1961 (luxon). The author's words on the kysely issue: "This is an ESM <> CJS module load order bug in Bun (not a bug in Kysely)". Those two packages load today without the move (kysely 0.24.2 with kysely-planetscale 1.3.0 and luxon 3.2.1, kysely 0.27.3 with luxon 3.4.4), because #3104 made CommonJS native. #14419 deleted the function hoist that was in the same loop and kept the class arm. #40887 and #46021 changed the arms and kept them. #45631 copied the rule into the AoT compiler, and #47942 documents it for compiled code. This PR starts from the first revision of #35648 (4585d34) and replaces #35648 and #34933. Both were closed by the stale sweep without a review.

Programs that Node and main run and this branch breaks. Runs that print what Node prints, transpiler cache off. --aot columns are executables built by main and by the unpushed AoT half.

graph node main this PR main --aot PR --aot
1. a.mjs imports c.cjs and b.mjs, c.cjs requires b.mjs, b.mjs imports a.mjs 40 of 40 40 of 40 0 of 40 40 of 40 0 of 40
2. graph 1 with the class behind a getter of exports, as esbuild and SWC print it 10 of 10 10 of 10 0 of 10 (prints undefined, exit 0)
3. an ES module imports an ES module and then a CommonJS file that calls back into it (no cycle) 10 of 10 10 of 10 0 of 10
4. two CommonJS files in a require cycle, as tsc prints them, imported at two depths 10 of 10 10 of 10 0 of 10
5. an ES module imports two CommonJS files in a require cycle 40 of 40 40 of 40 0 of 40 40 of 40 0 of 40
6. graph 5 with const A = class {} 40 of 40 4 of 40 5 of 40 0 of 40 0 of 40
7. two pending import() calls, first module 84 bytes 40 of 40 40 of 40 37 of 40 40 of 40 40 of 40
8. two pending import() calls, first module 457 KB 40 of 40 40 of 40 0 of 40 40 of 40 40 of 40
  • The cause is in the loader. commonJSModuleSyntheticSourceCode (src/jsc/bindings/JSCommonJSModule.cpp:1813) calls evaluateCommonJSModuleOnce, so a CommonJS file that an ES module imports runs when its fetch settles, not at its place in the evaluation order. A function that the half-run file already exported then reads a class below the require(). With the move the class exists. Row 6 shows that main already fails when the class is a const.
  • Graph 2 does not throw. The loader reads the getter while c.cjs still runs, drops the ReferenceError, and the import stays undefined.
  • Graphs 1 to 4 are test.todo rows in runtime-transpiler.test.ts. They pass on main.
  • Run CommonJS files imported from ES modules in import order #42109 (CommonJS in import order) and Hand transpiled modules back to the module loader in request order #42051 (request order for transpiled modules) change the order in which fetched modules run, which is what graphs 5, 7 and 8 need. Graphs 1 to 4 need a CommonJS file to run at its evaluation slot, behind the ES modules that stand above it. module loader: evaluate ESM-imported CommonJS at its post-order slot #35971 did that. A maintainer asked for changes there: "This will break a lot of stuff. Not happening if it doesn't at least align with node." [JSC] Run a CommonJS module's generator where its importing graph evaluates it WebKit#891 is an open draft of a Node-aligned order.
  • BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 is not a workaround. With it graphs 7 and 8 pass 10 of 10 runs here, and graphs 1 to 5 pass 0 of 10.
  • The AoT module plan copies today's loader order (collect_prefix in src/aot/modules/plan.rs), so a loader change needs the same change there.

Code written only for Bun. These run on main and throw ReferenceError here:

The ahead-of-time compiler. bun build --compile --aot takes bun run as its oracle, and its front end has a copy of the move: bun_sema::hir::is_moved_to_file_start (src/sema/hir/runtime_syntax.rs:313), read by the lowering (src/aot/lower/statement.rs:42 and :60, src/aot/lower/module.rs:105), the scope walk (src/aot/lower/scope/walk/walker.rs:387), the layouts (src/aot/lower/layouts/containers.rs:688) and the dead-zone analysis (src/aot/compile/removable/dead_zone.rs:177). Since #47942, docs/compiler/compatibility.mdx has the section "A class used above its declaration", which says that bun run moves such a class and that an executable does the same. No release has shipped that yet.

The same deletion was built on the tree before #47942 (7ca0042) and is not pushed, because #47942 replaced that tree and #48120 moves it again. What that build showed:

  • Source: 9 files, about 100 lines removed, nothing added. 55 IR text tests needed their generated blocks written anew, 54 of them only because function ids follow source order.
  • test/aot/run-parallel.ts over 7,186 programs: 7,172 pass, 12 are Todo, 1 is red on main too, 1 timed out under load and passes alone. All 8 transpiler-parity files pass.
  • Compiled code of programs that never read a class early (main against the build): 37 of 289 idiom benchmarks have another IR, and 0 functions have another size. Of the seven servers in test/aot/servers, bun, node, hono and koa have 0 functions of another size. CheckInitialized per server: bun 1197, node 1919, hono 1353, koa 2680, express 2667 and elysia 5854 are equal, fastify goes from 6049 to 5888.
  • Instructions per request (test/aot/perf/compare-builds.ts, 2% threshold): 0 of 24 rows of the bun, node and hono servers move in one pair of runs. In another pair the three params rows read +4.1% to +7.3%. The exact counters for a params request are equal on both builds: tests in compiled code 663, 18 and 347, run-time lookups 18, 3 and 22. callgrind was not run: valgrind is not installed here and cannot be installed.
  • A class that captures an earlier const gets better code: the constructor captures the value and loses a CheckInitialized.

Until the AoT copy goes in the same merge, the AoT tests that compare a compiled program with bun run fail on this branch for every program that reads a class above its statement. test/aot/mocks-are-hoisted-above-the-imports.test.ts lists the six class rows of mock-hoist.test.ts as cases that differ in compiled code. They leave that list when the copy goes.

export =. An earlier push of this PR also ran TypeScript export = value last, as esbuild does. That arm is gone. Compiled code runs export = where it is written, several AoT analyses take it at its text position, and no AoT test has export = above what it names. tsc prints module.exports = Foo in place under module: preserve, which bun init writes, and last under commonjs and nodenext. So on this branch export = Foo above class Foo {} throws ReferenceError in bun run (main prints the class, because the class moved). 0 of 9,510 local TypeScript sources have export = above a class: 3 have a top-level export =, all as the last statement. #41978 decides the order of export =. It needs an AoT half. This PR does not pin the throw with a test.

Decisions this PR waits for.

  1. Does the move go at all? Main keeps it, copies it into --aot and documents it there.
  2. In what order does a CommonJS file run that an ES module imports: at Node's evaluation slot, in import order, or as today with graphs 1 to 4 accepted? Graphs 1 to 4 decide whether this PR can land.
  3. bun test: is a class one of the "top-level variables" that a hoisted mock factory cannot read? If mock hoisting ships before this PR can land, a check of the mock plan in the two arms covers that case alone.
  4. Does export = run last (js_parser: emit TypeScript export = last, keep module.exports reads after a reassignment #41978)? Then this PR lands after it.

The svelte crash of #35648. With both arms deleted, CI build 80714 had svelte.test.ts fail with Segmentation fault at address 0x0 on linux aarch64. The test loads 2,000 copies of one component. In source order, the module texts of hello.svelte?r563 and hello.svelte?i978 have the same length (173 bytes) and the same 24-bit string hash (11767515). JSC's CodeCache then gave the second module the code of the first (oven-sh/WebKit#346). The pair, loaded through node:vm, gives the same segfault on bun 1.4.2 and runs on 1.4.3, on main and on this PR.

Measurements (release builds of main 7ca0042 and of this branch's parser changes on that base. #47942 did not change the parser.)

  • reads above the statement equal to node v26.3.0: 15 of 15 rows of the test table (main: 0 of 15)
  • transformSync, instructions per transform (median of 5, instructions:u from perf_event_open, JIT and concurrent GC off): 2,000 const statements 9,414,879 to 9,396,653, 2,000 classes 10,095,196 to 9,835,605 (-130 per class), import plus export default function 71,452 to 71,154, 200 statements with export = last 986,231 to 984,447. perf is not installed on this machine.
  • Part-list allocations per module (gdb hit counts, debug builds): with a class 2 to 1, without 1 to 1, with export = last 1 to 1
  • release binary .text, parser and AoT halves together: -4,352 bytes
  • transpiler cache after the upgrade: 3 of 3 entries rewritten on the first run, 0 on the second
  • measured on the first push (main 6292c65), not repeated: package loads that flip, 0 of 1,303 by import and 0 of 1,303 by require (test/node_modules, one require chain per package, so it cannot reach graphs 5, 7 and 8). Duplicate-export error anchored on the later statement, as in Node: 29 of 29 cells (main: 13 of 29). "use client" line in --target=bun output: plain module 1 (main: 4), automatic-JSX module 2 (main: 7).

Other changes you can see

Left for later

Self-review. 37 concerns. 28 are addressed in the code, the tests or this text. 2 say that nobody with merge rights has decided in writing to remove the move. 4 ask for the loader change (decision 2). 1 says that a parked draft is closed after 90 days. 2 need no change.

Suites run. Debug build of this branch on 2596ac5: the seven changed test files, test/cli/test/coverage.test.ts and test/cli/run/run-cjs.test.ts. 0 tests fail. In mock-hoist.test.ts one --isolate=modules test takes 3 to 5 seconds on a debug build and reaches the 5 second limit in some runs. A debug build of main does the same.


no test proof · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/repl/repl.test.ts

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:28 AM PT - Nov 8th, 2026

❌ @robobun, your commit 5f77faa has 22 failures in Build #138662 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46795

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

bun-46795 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on a release build of main 7ca0042 (the parser is the same on 2596ac5): bun repro.mjs (the file in Notes) prints function and constructed-before-declaration. Node v26.3.0 prints THROW:ReferenceError twice. In bun test, a hoisted vi.mock() factory that reads a class declared below it gets the first state of the class on main. Vitest 4.1.9 fails that file with a ReferenceError, and so does this branch. 34 new tests fail on that build of main and pass on this branch.

This PR is a draft and cannot land first. Four graphs that Node and main run fail on this branch, because Bun runs a CommonJS file that an ES module imports when its text loads and not where Node evaluates it. bun build --compile --aot has a copy of the move, so the AoT tests that compare a compiled program with bun run fail on this branch. The PR body lists the decisions that this waits for.

Comment thread src/jsc/RuntimeTranspilerCache.rs Resolved
…e written

Outside the bundler, the parser moved a top-level class declaration and an
`export default` statement to the top of the module when `can_be_moved()`
was true. The move erased the temporal dead zone of the class binding and
of the default export: code that read them above their statement got a
value, where Node throws ReferenceError. It also put the statement above a
directive such as "use client", and above the hoisted `vi.mock()` and
`jest.mock()` calls of a test file, so a hoisted factory read the class in
its first state.

Delete both arms of the parts loop, so these statements stay where they
are written.

TypeScript `export = value` is now the last statement of the module, as in
tsc and esbuild. The loop had no arm for it, and the class move hid that:
`export = Foo; class Foo {}` ran only because the class moved up. The arm
is the one of #41978.

Delete what only the two arms used: the `bool` that `append_part` returned,
and the `AutoAccessor` clause of `Class::can_be_moved`. Every other caller
sees a class after its accessors are lowered to a static block.

The runtime transpiler cache version goes from 51 to 52, because a cached
module holds the moved text.
…r directive

The part loop no longer moves TypeScript `export =` to the end. It runs
where it is written, as in what tsc prints with `module: preserve`.

The CommonJS wrapper adds "use strict" unless the first statement is that
directive. It took any directive, such as "use client", for it.
…that has it

The wrapper and the REPL transform skipped the directive when the first
statement was any directive. The parser has already removed "use strict"
there, so that statement was another directive, such as "use client".

Tests: a static accessor in a CommonJS file, which the moved class broke,
and four graphs that Node runs and that need CommonJS in import order.
…ification

mock-hoist.test.ts has six more cases. Compiled code still runs a class in
front of the other statements of its file, so the six differ there.
robobun added a commit that referenced this pull request Nov 8, 2026
The file of the issue has `new X()` above the class. Which binding is
read first in its dead zone depends on where a top-level class runs
(#46795): 'foo' while Bun runs the class first, 'X' when the class runs
where it is written. The test is about the name in the message, so it
takes both. The file with the class first still expects 'foo'.

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.

2 participants