Skip to content

bundler: keep the CommonJS wrapper when a module touches exports.__proto__ - #41768

Open
robobun wants to merge 1 commit into
mainfrom
robobun/b27e5aa3/cjs-exports-proto
Open

robobun wants to merge 1 commit into
mainfrom
robobun/b27e5aa3/cjs-exports-proto

Conversation

@robobun

@robobun robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun build unwraps a CommonJS module to ESM bindings when an ES module imports it and the module only writes exports.<name>. exports.__proto__ = v was treated as an export named __proto__: it became var $__proto__ = v plus a getter in __exportCjs(exports_lib, { __proto__: () => $__proto__ }, ...). In node that statement sets the prototype of module.exports. So import lib from "./lib.cjs"; lib.inherited is undefined in the bundle and 1 in node and in bun run.
  • The cause is maybe_rewrite_property_access (src/js_parser/fold.rs:462 for exports.x, :673 for module.exports.x). It lifts every member name into a CommonJSExportIdentifier, __proto__ included.

Fix

  • A read or write of exports.__proto__ or module.exports.__proto__ (dot or bracket form) deoptimizes the named-export unwrapping, the same way delete exports.x already does. The module keeps its __commonJS wrapper and the statement runs as written.
  • Correct because the wrapper is the baseline CommonJS path: module.exports is a real object, so the Object.prototype.__proto__ accessor behaves exactly as in node. esbuild never unwraps and matches node here too.
  • Verified: test/bundler/bundler_cjs2esm.test.ts (cjs2esm/ExportsProtoMemberKeepsWrapper, fails on 1.4.3-canary with 3 of 4 files lifted). Also ran all of bundler_cjs2esm, bundler_edgecase, bundler_minify, esbuild/default, esbuild/importstar.

Background

  • "Unwrapping" (ExportsKind::EsmWithDynamicFallbackFromCjs) turns exports.a = 1 into var $a = 1; export { $a as a } so a CommonJS module imported from ESM can be tree-shaken and bound statically. Any use of exports that is not a plain member pattern sets commonjs_named_exports_deoptimized and the module falls back to the __commonJS wrapper.
  • __proto__ on an ordinary object is the Object.prototype accessor: assignment calls [[SetPrototypeOf]], a read returns the prototype. Neither creates or reads an own property, so no lifted binding can model it.
  • This is the CommonJS face of the __proto__ export-name family. Keep __proto__ object keys as written when printing and synthesizing object literals #40812 covers the ESM faces (the __export key and the shorthand property); this change does not depend on it.
Notes

Repro on 1.4.3-canary (d316760):

// lib.cjs
exports.__proto__ = { inherited: 1 };
exports.a = 1;
// main.mjs
import lib from "./lib.cjs";
console.log(Object.keys(lib), lib.inherited);
// node main.mjs / bun main.mjs:      [ 'a' ] 1
// bun build main.mjs | node:          [ 'a' ] undefined   (every target, --minify, --splitting)
// bun build --format=cjs:             [ 'a' ] 1           (no unwrapping there)

A require() consumer was already correct because a required module is never unwrapped. A module that only reads exports.__proto__ was already deoptimized by the "export that is never assigned" rule; the test includes that form so all four stay covered.

@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 545f41a9-d728-4e5b-9e89-670977226940

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and 388cf7d.

📒 Files selected for processing (2)
  • src/js_parser/fold.rs
  • test/bundler/bundler_cjs2esm.test.ts

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

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.3-canary (d316760) with the lib.cjs / main.mjs pair in the PR body: node and bun run print [ 'a' ] 1, every bun build mode except --format=cjs prints [ 'a' ] undefined. The new cjs2esm/ExportsProtoMemberKeepsWrapper test fails on the released binary (3 of the 4 CommonJS files are unwrapped) and passes with this change. The rest of bundler_cjs2esm, bundler_edgecase, bundler_minify, esbuild/default and esbuild/importstar pass.

@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 — narrow, conservative deopt with matching coverage.

Checked that both parallel lift sites in maybe_rewrite_property_access (the exports.x identifier arm and the E::Special::ModuleExports arm) get the same guard, and that name is the byte-slice already compared against literal names nearby, so == b"__proto__" is the local idiom. The change only widens the existing is_delete_target() deopt, so previously-correct modules are unaffected — the fallback is the baseline __commonJS wrapper. The new itBundled case exercises dot, bracket, module.exports., and the read-only form, asserts all four stay wrapped via cjs2esm.unhandled, and pins runtime semantics (own keys, prototype inheritance, namespace/default interop). Runtime-computed keys and Object.defineProperty already bypass the lift path, so no additional sibling sites needed the guard.

Extended reasoning...

Overview

This PR touches two files: a two-condition addition in src/js_parser/fold.rs (lines 466 and 677) that makes the CommonJS-to-ESM unwrapping optimization deoptimize when it encounters exports.__proto__ / module.exports.__proto__, and a new itBundled test case in test/bundler/bundler_cjs2esm.test.ts. The bug is that exports.__proto__ = v was being lifted to a hoisted binding named $__proto__ plus a namespace getter, but at runtime that assignment goes through the Object.prototype.__proto__ accessor and sets the prototype of module.exports — no own property is created, so a lifted binding cannot model it.

Security risks

None. This is a bundler output-correctness fix in the JS parser's constant-folding / property-access rewriting pass. No untrusted input parsing, no auth/crypto/permissions surface, no memory management. The change is purely additive to an existing deoptimization gate and can only cause modules that previously (incorrectly) unwrapped to now keep their __commonJS wrapper — the wrapper is the conservative baseline that already matches Node semantics.

Level of scrutiny

Low-to-moderate. The logic change is two || name == b"__proto__" clauses appended to conditions that already handle the analogous delete exports.x case, in both parallel arms of maybe_rewrite_property_access. The byte-slice equality matches the surrounding style (name == b"filename", name == b"path" a few lines above). REVIEW.md's "fix the whole class" is satisfied: both the exports_ref identifier path and the ModuleExports special path are updated symmetrically, and the test covers dot, bracket, module.exports., and read-only forms. Dynamic index access and Object.defineProperty already fall outside the static-member lift pattern and don't need the guard. The one-line source comment states the non-obvious semantic ("prototype of module.exports, not an export"), which is exactly the load-bearing "why" the review guidance asks for.

Other factors

The test is placed in the existing bundler_cjs2esm.test.ts alongside sibling cjs2esm/* cases and uses the itBundled harness with cjs2esm.unhandled to assert the wrapper is kept — this is the assertion that would fail on the pre-fix build (per the PR description, 3 of 4 files were lifted on canary). Runtime stdout is pinned with JSON.stringify of exact expected values including own-key sets, hasOwn checks, inherited-property reads, and namespace/default interop, so the assertion is strong rather than substring-based. No CODEOWNERS entries cover the changed paths. The bug hunt exited on dry_streak with no findings and no ruled-out candidates. The change cannot regress correct output — deoptimizing to the wrapper is always semantics-preserving, only forgoing tree-shaking for the rare module that touches __proto__.

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 PM PT - Sep 6th, 2026

@robobun, your commit 388cf7d is building: #111881

@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:59 PM PT - Sep 6th, 2026

❌ @robobun, your commit 388cf7d has 2 failures in Build #111881 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41768

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

bun-41768 --bun

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