Skip to content

bundler: keep "use strict" inside the wrapper of a bundled file in cjs and iife output - #46578

Open
robobun wants to merge 3 commits into
mainfrom
robobun/1b65cfc5/cjs-wrapper-use-strict
Open

robobun wants to merge 3 commits into
mainfrom
robobun/1b65cfc5/cjs-wrapper-use-strict

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --format=cjs and --format=iife (also --bytecode) drop the "use strict" of a bundled CommonJS file, so it runs as sloppy code. The repro in Notes prints SLOPPY | SLOPPY: no throw. node and esbuild print strict | strict: TypeError.
  • Cause: generateCodeForFileInChunkJS.rs:263 printed the directive only in a chunk that is not an entry point chunk. Without code splitting no such chunk exists.

Fix

  • The gate tests the file (Chunk::is_entry_point_file), as the gate of esbuild does. A wrapped file prints its directive first in its wrapper. The entry point of a chunk keeps it at the chunk top.
  • append_directive keeps the directive ahead of the init_*() calls that the linker inserts. An ES module with no wrapper symbol prints no closure, so the gate skips it.
  • Verified: 15 new cases in test/bundler/bundler_cjs.test.ts (13 fail on 1.4.3) and one in bundler_compile.test.ts. Notes list the other suites.
  • Self-reviewed: 19 concerns raised, 19 addressed.

Background

Downsides

  • A cjs bundle grows 16 bytes (iife 18, minified 13) for each wrapped "use strict" file: +1,153 bytes (0.19%) on undici. esm output is byte-identical.
  • A "use strict" file with with or delete name now makes the whole chunk a SyntaxError at load, and the build reports nothing (parser: report strict-mode early errors and accept legacy sloppy syntax #40827). 0 of 67 bundled packages changed behavior.
  • When bun runs cjs output for bun, a strict dependency gets tail calls, as under bun run: a stack trace through return f() calls loses frames.
Notes

Repro

// node_modules/ext/package.json: { "name": "ext", "version": "1.0.0", "main": "index.js" }
// node_modules/ext/index.js:
"use strict";
exports.self = function () { return this === undefined ? "strict" : "SLOPPY"; };
exports.frozen = function () { try { Object.freeze({ x: 1 }).x = 2; return "SLOPPY: no throw"; } catch (e) { return "strict: " + e.name; } };
// e.mjs:
import ext from "ext"; const f = ext.self; console.log(f(), "|", ext.frozen());

bun build ./e.mjs --format=cjs --target=node --outdir o && node o/e.js

build run by bun 1.4.3 and main this branch
--format=cjs node SLOPPY | SLOPPY: no throw strict | strict: TypeError
--format=iife node SLOPPY | SLOPPY: no throw strict | strict: TypeError
--format=cjs --minify node SLOPPY | SLOPPY: no throw strict | strict: TypeError
a CommonJS entry with require("ext"), cjs node SLOPPY | SLOPPY: no throw strict | strict: TypeError
--format=cjs --target=bun bun SLOPPY | SLOPPY: no throw strict | strict: TypeError
--format=esm node strict | strict: TypeError the same, output byte-identical

After the change the wrapper reads var require_ext = __commonJS(function(exports2) { "use strict"; ... }). The same holds for fn.caller and fn.arguments of a function in such a file: node throws a TypeError for the read, the bundle from main returns the caller, and the bundle from this branch throws again.

When bun runs the output, this change shows only in CommonJS output for bun: --target=bun --format=cjs, --bytecode, and --compile with either. bun decides the mode of any other bundle as a whole when it loads it. As an ES module each file is strict. This is the case for bun o/e.js above, on main too, because the file has no top-level module, exports or require. As CommonJS each file is sloppy (the same bytes as e.cjs, or under "type": "commonjs"), because bun's parser drops a "use strict" inside a function body (#29533, #31807). A bundle that bun bundles again loses the directive for the same reason. The new tests run the node-target bundles with node for this reason.

Reference

esbuild decides at the same place in its linker: if repr.Meta.Wrap != graph.WrapNone && !file.IsEntryPoint() (internal/linker/linker.go:4654-4662 in v0.21.5, :4680-4688 on main). The comment above bun's gate is a copy of the comment above that line. bun's copy, from #14232, tested the chunk. "As esbuild does" in this description means that test, not the whole output: the table below has the differences.

bun needs the test per chunk and not per file. Without code splitting each entry point has its own chunk (#41263), and a file that is an entry point can also be a wrapped dependency in the chunk of another entry point. Chunk::is_entry_point_file already makes the two neighbouring decisions (convertStmtsForChunk.rs:72 and a few lines below the gate).

The clauses of the gate, and the case that fails without each

  • The per-file test: 13 of the 15 new cases in bundler_cjs.test.ts and compile/BytecodeKeepsUseStrictOfWrappedCommonJS fail on main.
  • append_directive: the linker inserts the init_*() call of a wrapped import at a cursor that starts at index 0. A plain push of the directive leaves it after those calls, where it is a string statement. Without the cursor step, the two cjs/UseStrictBeforeInitCalls* cases fail.
  • !ast.wrapper_ref.is_empty(): the parser makes no wrapper symbol for an ES module whose statements all hoist out of the __esm closure (needs_wrapper_ref in src/js_parser/p.rs). With a directive pushed for such a file the linker prints a closure that has no name. A debug build fails debug_assert!(!ast.wrapper_ref.is_empty()) in the __esm arm of the same function. cjs/UseStrictEsmFileWithoutWrapperSymbol aborts there when the check is removed.
  • flags.wrap != WrapKind::None: without it, a strict ES module that has no wrapper gets a stray directive in the middle of the chunk. The three cases that count the directives fail (target_bun, target_bun_bytecode, minify).
  • !output_format.is_always_strict_mode() is unchanged. cjs/UseStrictInCommonJSWrapper_format_esm pins it.

What runs strict in cjs and iife output

One bundle with each kind of file, run by node. cjs/UseStrictModeTable_format_cjs and _iife pin the first ten rows. The other four rows have no directive of the file's own, or are decided by the parser, so this gate does not read them. The self-review asked for a pin of all fourteen. A pin of those four would record behavior that #40926 and #40838 change.

file main this branch esbuild 0.21.5
the entry point, an ES module sloppy sloppy sloppy
import of a CommonJS file with "use strict" sloppy strict strict
import of a CommonJS file with no directive sloppy sloppy sloppy
import of an ES module with "use strict": body sloppy sloppy sloppy
the same: a function declaration sloppy sloppy sloppy
import of a "use strict" script with no module syntax sloppy sloppy sloppy
require() of a CommonJS file with "use strict" sloppy strict strict
require() of an ES module with "use strict": body sloppy strict strict
the same: a function declaration sloppy sloppy sloppy
require() of a "use strict" script with no module syntax: body sloppy strict strict
import of an ES module with no directive: body sloppy sloppy sloppy
require() of an ES module with no directive: body sloppy sloppy sloppy
the same: a function declaration sloppy sloppy sloppy
a function with its own "use strict" in a CommonJS file sloppy sloppy strict

Cases that stay sloppy

case this branch node on the source / esbuild 0.21.5 tracker
a function with its own "use strict" (UMD wrappers, the isES5 test of bluebird) sloppy strict / strict #29533, #31807, #40838
export const f = function () {} or = () => {} in a require()d "use strict" ES module: bun hoists the initializer out of the __esm closure sloppy strict / strict none. The hoist is deliberate (#12729). #45529 (draft) moves var stores back into the wrapper and leaves const values outside
export function f() {} in the same module sloppy strict / sloppy none
a named function of a require()d "use strict" script with no module syntax: the bundler wraps that file in __esm and hoists the function sloppy, the body is strict strict / strict none
an ES module that is not wrapped, an ES module entry point, and an ES module with no directive in __esm sloppy strict / sloppy #40926
a TypeScript file under tsconfig alwaysStrict or strict: bun has no such input sloppy, 0 directives not applicable / strict none
a bundle with no // @bun pragma that bun loads as CommonJS sloppy under bun #29533, #31807

Sites that this change leaves as they are:

  • esm output: the directive in a wrapper stays omitted, because an ES module is strict. esbuild prints it there.
  • The dev server format (internal_bake_dev): its branch returns before the gate.
  • Other directives such as "use client" (bun build doesn't hoist "use strict", "use client", "use server", etc. directives #6854, js_parser: keep "use strict" and the directive prologue in the output #40838).
  • ParserOptions::keep_directives is on main and only bun pm diff sets it. Turning it on for the bundler changes what the parser prints for each file. That is the function-level case above, not this gate.
  • A chunk that is already strict through its entry point gets the directive in each wrapper too, as esbuild prints it. A code banner can push the directive at the chunk top out of the prologue, and then the wrappers still hold theirs.
  • An __esm closure can now hold the directive alone: var init_lib = __esm(() => { "use strict"; }) where main prints var init_lib = () => {} (+24 bytes, esbuild prints such a closure too). cjs/UseStrictAloneInEsmWrapper pins it.

Syntax that strict mode forbids

legacy.cjs is "use strict"; with ({ a: 1 }) { module.exports = a; }. The entry requires it only inside try { if (process.env.LOAD_LEGACY) ... }.

runner default LOAD_LEGACY=1
node on the source app ran null app ran caught SyntaxError
cjs bundle from main, node app ran null app ran 1
cjs bundle from this branch, node SyntaxError at load SyntaxError at load
esbuild 0.21.5 build error

bun build exits 0 and prints nothing in the two builds (#40827, #40861). With --bytecode the build from this branch writes entry.js and no entry.js.jsc, and exits 0 (#36708). delete name and a strict reserved word as a label behave like with. A legacy octal literal does not: the printer writes 010 as 8.

Stack traces under bun

JavaScriptCore makes tail calls only in strict code. dep.cjs starts with "use strict" and has run call a, a call b, b call c, each as return f(). c throws.

runner frames
node, source or any bundle c < b < a < run < ...
bun entry.cjs on the source c < <anonymous>
--target=bun --format=cjs or --bytecode from main, run by bun c < b < a < run < <anonymous>
the same from this branch c < <anonymous>
--target=bun --format=esm, main and this branch c < <anonymous> < ...

Measurements

Release builds of the merge base 5c6d4e2 and of this branch, same tree, same flags. Packages from test/node_modules.

Output bytes, bun build e.mjs --target=node, where e.mjs imports the package:

bundle base this branch delta
undici, cjs 616,181 617,334 +1,153 (72 directives of 16 bytes, 1 blank line)
undici, cjs, --minify 376,851 377,787 +936 (72 of 13 bytes)
undici, iife 647,016 648,313 +1,297 (72 of 18 bytes, 1 blank line)
undici, esm 615,786 615,786 identical (sha256)
react-dom, cjs 931,808 931,904 +96 (6 of 16 bytes)
react-dom, esm 931,910 931,910 identical (sha256)
lodash, cjs, iife and esm identical (sha256)

In the output that is not minified, each added line is the directive or a blank line. No line is removed.

Instructions. valgrind, perf, bloaty and strace are not installed in the build container, and it cannot reach a package mirror. The counts are user-space instructions of the whole bun build process, read with perf_event_open: GOMAXPROCS=2, one CPU, the median of 15 interleaved runs, a generated graph of 400 wrapped CommonJS files, about 41M instructions for each build. A/A is a second series of the base binary.

graph, format this branch minus base A/A
no file has the directive, cjs -831 -12,885
no file has the directive, esm +4,573 -7,573
each file has the directive, esm -266 -7,598
each file has the directive, cjs +282,364 -3,446

A file that gets no directive shows no delta above the A/A floor. Each printed directive costs 706 instructions.

Allocations: not measured, because dhat is not available. By the code, a file that gets the directive takes one AST store slot for the S::Directive. The Vec that holds it is reused for each file. A file that gets no directive takes nothing.

Binary size (size): text 107,635,854 to 107,636,110 (+256 bytes). data and bss are equal.

Pinned bytecode snapshot (bundler_bytecode_portable.test.ts). 6 entries move. Each is a bundle with wrapped CommonJS files that start with "use strict":

entry js bytes jsc bytes
all.js +80 +80
--minify all.js +65 +176
libraries.js (64 packages) +41,385 (0.25%) +21,264 (0.10%)
happy-dom/lib/index.js +80 -24
react-dom/cjs/react-dom.development.js +64 0
undici/index.js +1,137 +88

The other entries are byte-identical. The cache-load and --compile --bytecode cases of that file pass with the new output. The 64 packages of libraries.js load with 2,586 wrappers that are now strict.

Bundles that stop loading. Each of 67 packages (the 64 of libraries.js, undici, svelte/compiler and lodash) was bundled alone to cjs for node with the two builds. node --check fails for 0 of 67. The type and the key list of the require() result differ between the two builds for 0 of 67. 3,593 of the 4,038 __commonJS wrappers in these bundles now start with the directive. The total size goes from 25,967,806 to 26,025,305 bytes (+0.22%).

__esm closures. A cjs bundle of import("axios") has 43 __esm wrappers. 32 start with the directive. One holds only the directive (+24 bytes, esbuild prints the same closure). The output holds no __INVALID__REF__.

Earlier pull requests

Suites

All runs are bun bd test on the debug build.

On this branch, rebased onto 5c6d4e2:

  • bundler_cjs (77 pass), the new case of bundler_compile, bundler_bytecode_portable (22 pass, --timeout 1500000).
  • bundler_cjs2esm (113), bundler_npm (2), bundler_bun (22), esbuild/extra (222), bun-build-api (90) and cli (43), the last two with --timeout 300000.
  • esbuild/default (154 of 155) and bundler_edgecase (187 of 188). default/TopLevelAwaitForbiddenRequireSourceMapCLI and edgecase/DeepImportDiamondDAG timed out while another job used the machine. Each passes when it runs alone.
  • The 12 files under css/ and resolver/ (200 cases, all pass).
  • bun-build-compile-sourcemap (9) and bundler_compile_splitting (30) with --timeout 600000. bun-build-compile (79 of 80) with --timeout 900000: bytecode from a compiled executable is not copied into private memory exceeds its own 60 s limit on the debug build. It passes on the release builds of main and of this branch (3.8 s and 3.5 s).

Before the rebase, on 38afee6: 96 of the 108 test files under test/bundler (all but css/ and resolver/). The failures were 13 cases in 5 files (bun-build-api, bun-build-compile, bun-build-compile-sourcemap, bundler_compile_splitting, cli), each a --compile or bytecode case that ran longer than the default 5 s limit, or than its own 60 s limit, on the debug build.

Mutation checks on the debug build:

  • Without !ast.wrapper_ref.is_empty(), cjs/UseStrictEsmFileWithoutWrapperSymbol aborts.
  • Without the cursor step in append_directive, the two UseStrictBeforeInitCalls cases fail.
  • Without flags.wrap != WrapKind::None, target_bun, target_bun_bytecode and minify fail.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/bundler_compile.test.ts, test/bundler/bundler_bytecode_portable.test.ts

…cjs and iife output

The linker printed the directive of a wrapped file inside its `__commonJS`
or `__esm` wrapper only when the chunk was not an entry point chunk. Code
splitting needs esm output, so every cjs and iife chunk is an entry point
chunk and the directive was never printed. A CommonJS file that starts with
"use strict" ran as sloppy code after bundling.

The gate now tests the file: every wrapped file except the entry point of
the chunk gets the directive, and that entry point keeps it at the top of
the chunk. `InsideWrapperPrefix::append_directive` keeps the directive ahead
of the `init_*()` calls the linker inserts at the start of the wrapper body.
An ES module that has no wrapper symbol prints no closure, so it gets no
directive.
… --compile --bytecode

Six of the bundles in bundler_bytecode_portable.test.ts hold wrapped CommonJS
files that start with "use strict". Their text and bytecode move. The other
entries are unchanged.
…ach file kind

An ES module with "use strict" and no wrapper gets no directive. An __esm
closure can hold the directive alone. A read of fn.caller throws again. One
bundle with each kind of file records which ones run strict in cjs and iife
output.
@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:18 AM PT - Oct 23rd, 2026

✅ @robobun, your commit eb3afaa15d45d86ffd56b731d986377ec7c7857d passed in Build #131236! 🎉


🧪   To try this PR locally:

bunx bun-pr 46578

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

bun-46578 --bun

@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on bun 1.4.3 and on main 5c6d4e2. A CommonJS package that starts with "use strict" is imported from an ES module entry and built with bun build --format=cjs --target=node (the same with --format=iife). Under node the bundle prints SLOPPY | SLOPPY: no throw. The unbundled file, esbuild 0.21.5 and this branch print strict | strict: TypeError.

PR: #46578

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 5c6d4e2 and eb3afaa.

📒 Files selected for processing: 5
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/generateCodeForFileInChunkJS.rs
  • test/bundler/bundler_bytecode_portable.test.ts
  • test/bundler/bundler_cjs.test.ts
  • test/bundler/bundler_compile.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

The linker now inserts explicit "use strict" directives inside eligible wrappers and advances the synchronous-dependency insertion boundary. New tests cover directive placement and runtime modes across bundler outputs and compiled bytecode.

Changes

Wrapped directive insertion

Layer / File(s) Summary
Directive insertion conditions
src/bundler/LinkerContext.rs, src/bundler/linker_context/generateCodeForFileInChunkJS.rs
The linker checks entry-point status, wrapper presence, and output format before adding a directive with append_directive. That method appends the directive and advances the synchronous-dependency insertion boundary.
Bundler and bytecode coverage
test/bundler/bundler_cjs.test.ts, test/bundler/bundler_compile.test.ts, test/bundler/bundler_bytecode_portable.test.ts
Tests check directive placement and runtime modes across CJS, ESM, IIFE, minified, and bytecode outputs. Portability snapshots record updated JavaScript and bytecode fingerprints.

Suggested reviewers: jarred-sumner, dylan-conway, sosukesuzuki

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to eb3af

No merge-blocking issue is established. Complete the normal test and build checks before merging.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes the primary change: preserving "use strict" inside wrappers for bundled CJS and IIFE output.
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.
Description check Passed The description explains the problem, implementation, tradeoffs, and verification results. It does not use the template headings exactly, but it provides the required information, including how the co…
Full details: Description check

Explanation

The description explains the problem, implementation, tradeoffs, and verification results. It does not use the template headings exactly, but it provides the required information, including how the code was verified.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked whether the debug_assert! in append_directive (src/bundler/LinkerContext.rs:5220) leaves release builds exposed to a directive landing after init_*() calls: its only call site (generateCodeForFileInChunkJS.rs:268) runs directly after stmts.reset() at line 240, so the prefix is empty and sync_dependencies_end is 0 regardless of build mode. The !ast.wrapper_ref.is_empty() clause matches the existing checks at lines 859 and 923 that decide whether an __esm closure is printed at all.

Extended reasoning...

The change is a 2-file linker edit (a gate rewrite from chunk-level to file-level is_entry_point_file, a wrapper-symbol check, and a new append_directive that advances the sync-dependency cursor) plus ~380 lines of bundler tests and bytecode-portability hash updates; it touches no security-sensitive surface. One confirmed finding was posted inline (a pre-existing sloppy-mode gap for hoisted function expressions in required ES modules), so a human should weigh whether it belongs in this PR, and the bytecode hash updates certify new output a reviewer should sanity-check.

@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

#41601 changed at 9eda6aac4d6. It is an order-only change now. The dependency statements of a wrapper keep source order when none of them is for an async dependency, and a list with an async dependency is printed as on main. It no longer awaits an export *, no longer replaces __promiseAll, no longer changes the import() form, and no longer sets the top-level await flag.

The sync_dependencies_end cursor stays. A directive is only printed in cjs and iife output, where no dependency is async. There #41601 keeps the statements in the order they were appended, so a directive that is appended first stays first without append_directive. Without #41601 this PR needs append_directive.

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