Skip to content

bundler: keep exported and using declarations initialized by an unwrapped require() - #39244

Open
robobun wants to merge 2 commits into
mainfrom
farm/d7a6c272/unwrap-require-keep-exported-decl
Open

robobun wants to merge 2 commits into
mainfrom
farm/d7a6c272/unwrap-require-keep-exported-decl

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build (ESM output, the default) drops the export of a declaration initialized by a require() of a package on the CommonJS unwrap list (react, react-dom, scheduler, ...). Same on 1.4.0 and main:
    // lib.js
    export const React = require("react");
    // entry.js
    import { React } from "./lib.js";   // error: No matching export in "lib.js" for import "React"
    Bundling lib.js alone prints var React = __toESM(require_react(), 1); with no export { React }.
  • Affected the same way: export var/export let, the other declarators of a multi-declarator export const, TypeScript export import React = require("react"), export const inside a TypeScript namespace (the NS.React = ... assignment disappears), and all of these when the required file converts to ESM instead of staying wrapped.
  • using x = require("react") / await using lose their disposal the same way: the bundle prints a hoisted var and the value is never disposed.
  • Cause: visit_decls (src/js_parser/visit/mod.rs) visits every initializer with ExprIn.is_immediately_assigned_to_decl. transpose_require (src/js_parser/p.rs, unwrap branch) answers the flag with an E::RequireString marker, and visit_decls consumes it by renaming the pending import * as ns to the declared name and removing the declarator. With every declarator gone, s_local (src/js_parser/visit/visit_stmt.rs) drops the whole S::Local, and with it the export keyword (scan_imports records exports from the S::Local statements that survive the visit) or the using kind (nothing is left to lower). For a plain const x = require("react") that is the intended rewrite; an import statement cannot stand in for an exported or using declaration.

Fix

  • The bool becomes a three-state RequireUnwrap (src/js_parser/parser.rs), carried through ExprIn and TransposeState exactly where the bool was. visit_decls now takes the statement's kind and is_export (it took was_const, now derived from kind) and picks the mode once per statement:
    • exported: Namespace. transpose_require takes its existing other branch and returns the import namespace identifier, which is what a require() of an unwrapped package already becomes in every non-declaration position. The declaration survives with that initializer, so the export is recorded. Exports inside a namespace need nothing extra: is_export is set there too, and s_local's namespace branch emits the NS.x = ... assignment once the declaration survives.
    • using / await using: Disabled. transpose_require ANDs it into should_unwrap_require next to the existing try/catch condition, so the require() stays an ordinary require and the declaration binds the module's own exports value. It flows through require(a ? "x" : "y") as well, since maybe_transpose_if_require passes the same state to both branches.
    • everything else: IntoDecl, the previous behavior. visit_expr still downgrades it to Namespace when the argument is not a string literal (the old && first is EString).
  • Why these shapes:
    • The marker exists only so visit_decls can replace the declaration, so the decision belongs where the marker is produced. Gating the consumer instead would leave the marker in the tree for the printer, whose fallback for a surviving marker is the path bundler: read destructured require() of an unwrapped package from its import namespace #39184 is fixing.
    • For exports, binding the namespace is correct for both target shapes and is what every other position prints: wrapped gives var react = __toESM(require_react(), 1); var React = react;, converted gives var React = exports_react;. Keeping the fold and synthesizing an export { x } instead would save the alias and, when the export is unused by other files, let the file's own x.foo reads tree-shake the package (measured: 3 exports retained vs 1). That case was only "optimal" before because the export was silently missing; when the export is consumed, both shapes materialize the namespace object (measured identical apart from the alias line); and folding export let x = require(..) would extend the existing mishandling of later x = ... assignments to exports. Not worth a second code path in this fix.
    • For using, binding the namespace is not enough: __toESM() copies only string-keyed own properties, as getters, and a converted module's namespace object has no symbols at all, so module.exports = { [Symbol.dispose]() {} } or exports[Symbol.asyncDispose] = ... (the shapes a disposable CommonJS module actually has; both stay wrapped) would throw TypeError: Object not disposable in the bundle while working unbundled. Treating the site like a try/catch is the existing mechanism for "this require() must stay a require()", and the bundle then prints exactly what the source does unbundled (verified: the new test's expected stdout is the unbundled output of its entry).
    • Plain declarations are unchanged: pinned by the unexported assertion in the new converted test and by the existing cjs2esm/UnwrappedModuleRequireAssigned and npm/ReactSSR tests (the React packages have no export keywords, and their exports.x = require(...) statements are rewritten in s_expr after the expression visit, so they never reach this path; npm/ReactSSR's exact output is unchanged).
  • Verified with test/bundler/bundler_cjs2esm.test.ts; the three new tests fail on the current release and pass with this change:
    • cjs2esm/UnwrappedModuleRequireExported: export const/var/let, a multi-declarator export const, export import x = require() and an export inside a namespace, imported and read from an entry (before: No matching export for each).
    • cjs2esm/UnwrappedModuleRequireExportedConverted: export const against a package that converts to ESM prints var exported = exports_react;; a non-exported declaration in the same file is still folded into the import.
    • cjs2esm/UnwrappedModuleRequireUsing: using, await using and a using of a conditional require() against modules with own Symbol.dispose / Symbol.asyncDispose hooks: the bound values are identical to the modules' exports (=== against an import of the same packages), the hooks run at block exit with the module as this, and the output equals the unbundled run (before: TypeError: Object not disposable).
  • Also ran bundler_cjs2esm, bundler_cjs, bundler_edgecase, bundler_regressions, bundler_npm, bundler_bun, bundler_minify, esbuild/default and transpiler/transpiler.test.js with the debug build: no failures.
  • Related: bundler: read destructured require() of an unwrapped package from its import namespace #39184 (open) restricts the same site to identifier bindings (destructuring, a different symptom); in terms of this PR that is IntoDecl only for B::Identifier bindings. Whichever lands second rebases the one site in visit_decls plus the ExprIn comment.

Background

  • CommonJS unwrap list: when bundling to ESM, files inside the packages in DEFAULT_UNWRAP_COMMONJS_PACKAGES (src/bundler/options.rs) are parsed with exports.x = ... turned into ESM exports, and every require() resolving into one of those packages, from any file, becomes an import * as ns from "..." statement (emitted at the end of the parse from imports_to_convert_from_require) plus a reference to ns. This is what lets React tree-shake. A require() inside a try/catch is the existing exception: it stays a plain require because an import cannot be caught.
  • Wrapped vs converted target: a required file that assigns module.exports (or anything else the converter cannot express, such as a symbol-keyed exports[...] = ...) stays CommonJS and is emitted as var require_x = __commonJS(...); a plain require() of it prints require_x() and its import namespace prints __toESM(require_x(), 1). A file that only uses exports.x = ... converts, and its namespace prints as the linker's generated namespace object (exports_x).
  • E::RequireString.unwrapped_id: the marker transpose_require returns in IntoDecl mode; it indexes the pending import, and visit_decls uses it to rename that import's namespace to the declared binding and drop the declarator, turning const React = require("react") into import * as React from "react". Nothing else produces or reads it.

[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/bundler/bundler_cjs2esm.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/167] gen JS modules (bundle-modules)
Preprocess modules (9205ms)
Bundle modules (54ms)
Postprocesss modules (175ms)
Bundle Functions (653ms)
Generate Code (12ms)

[10.12s] Bundled "src/js" for development
  2825 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[1/166] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

[98/166] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcrypto-2.cpp.o
FAILED: obj/unified/UnifiedSource-src_jsc_bindings_webcrypto-2.cpp.o 
/usr/bin/ccache /usr/lib/llvm-21/bin/clang++ -march=nehalem -O0 -glldb -g3 -gz=zstd -fno-standalone-debug -fsanitize=address -fno-exceptions -fno-c++-static-destructors -fno-rtti -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -fvisibility=hidden -fvisibility-inlines-hidden -fno-unwind-tables -fno-asynchronous-unwind-tables -
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (eabb96de7)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [27.43ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [14.14ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [14.53ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [13.87ms]
(pass) bundler > cjs2esm/ExportsFunction [14.99ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [14.55ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [13.84ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [17.62ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [16.68ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [16.62ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [16.44ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireDestructuredAndInTry [15.05ms]
1363 |             }
1364 | 
1365 |             return testRef(id, opts);
1366 |           }
1367 | 
1368 |           throw new Error("Bundle Failed\n" + [...allErrors].map(formatError).join("\n"));
                           ^
error: Bundle Failed
/entry.js:1:10: No matching export in "lib.js
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.0 (cdd2d05c6)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [893.18ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [442.29ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [422.11ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [407.01ms]
(pass) bundler > cjs2esm/ExportsFunction [416.19ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [405.07ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [398.94ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [587.96ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [598.13ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [424.41ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [489.29ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireDestructuredAndInTry [796.37ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireExported [456.27ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireExpor
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 713ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/130] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[2/130] gen cpp.rs (cppbind)
[3/130] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2Fra
... (truncated)
diff hotspot
src/js_parser/p.rs                   |  12 ++--
 src/js_parser/parser.rs              |  21 +++++--
 src/js_parser/visit/mod.rs           |  17 +++--
 src/js_parser/visit/visit_expr.rs    |  12 +++-
 src/js_parser/visit/visit_stmt.rs    |   5 +-
 test/bundler/bundler_cjs2esm.test.ts | 118 +++++++++++++++++++++++++++++++++++
 6 files changed, 165 insertions(+), 20 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/js_parser/p.rs                        3      3      0
src/js_parser/parser.rs                   5      6      0
src/js_parser/visit/mod.rs                4      6      0
src/js_parser/visit/visit_expr.rs         4      3      0
src/js_parser/visit/visit_stmt.rs         2      2      0
test/bundler/bundler_cjs2esm.test.ts      3      2      0

@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 seconds

Limit details: You’ve used all 5 included reviews currently available under your plan.

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: 33c61482-7cfd-4269-95fc-ddbf40952c44

📥 Commits

Reviewing files that changed from the base of the PR and between 83d65fa and 4afe33b.

📒 Files selected for processing (6)
  • src/js_parser/p.rs
  • src/js_parser/parser.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/bundler_cjs2esm.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed (a094395), waiting on CI.

Reproduced on the 1.4.0 release with export const React = require("react") in a file bundled by bun build (ESM output): No matching export in "lib.js" for import "React", and no export { React } when the file is bundled alone. using x = require("react") loses its disposal the same way. The three new tests in test/bundler/bundler_cjs2esm.test.ts (cjs2esm/UnwrappedModuleRequireExported, ...ExportedConverted, ...Using) cover export const/var/let, multi-declarator, export import x = require(), an export inside a namespace, a converted target, and using/await using; all three fail on the release and pass with this branch.

After self-review, using declarations now keep a plain require() instead of binding the import namespace (the namespace has no symbol-keyed properties, so disposal would throw); see the PR description.

@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.

I reviewed this PR and didn't find any bugs. The gate on is_immediately_assigned_to_decl is small and well-reasoned, but since it changes the bundler's CommonJS-unwrap → declaration-replacement handshake in the visit pass, a human look would still be worthwhile.

What was reviewed:

  • Confirmed visit_decls has only the two s_local call sites, so the was_const → kind/is_export signature change is complete.
  • Traced the flag to its sole consumer (transpose_require via TransposeState in p.rs:1193) — clearing it falls through to the existing namespace-identifier branch, so no marker is left in the tree.
  • Verified export import x = require() is parsed as S::Local{is_export, origin: TsImportEquals}, so it reaches this path; LocalKind::is_using() covers both KUsing and KAwaitUsing.
  • The unexported output-shape assertion in the new Converted test pins that plain declarations still take the replace-with-import path.
Extended reasoning...

Overview

The PR changes ~15 production lines across three files in src/js_parser/: the visit_decls signature now takes kind: LocalKind and is_export: bool (instead of the derived was_const), and sets ExprIn.is_immediately_assigned_to_decl only when the declaration is neither exported nor a using/await using. The two call sites in s_local are updated to pass the new arguments, and the field's doc comment is rewritten. Three new itBundled tests in bundler_cjs2esm.test.ts cover export const/var/let, multi-declarator, TS export import = require(), namespace export, a converted-to-ESM target (with a regression pin that non-exported declarations still fold), and using/await using disposal.

Security risks

None. This is a bundler AST-transform correctness fix with no I/O, network, auth, or user-input parsing implications.

Level of scrutiny

Moderate-to-high. The parser's visit pass is a critical bundler code path, and the is_immediately_assigned_to_decl / unwrapped_id handshake between visit_decls and transpose_require is subtle. That said, the change is strictly a narrowing of when the replace-with-import optimization fires — the previous behavior for the affected inputs (export const x = require(unwrapped), using x = require(unwrapped)) was demonstrably broken (dropped export, dropped disposal), and the fallback is the same identifier expression every non-declaration position already produces. I confirmed visit_decls is called only from s_local, so the signature change is complete; was_const is derived identically inside; LocalKind::is_using() covers both using kinds; and the flag's only downstream reader is the !state.is_require_immediately_assigned_to_decl branch in transpose_require, which returns the namespace identifier — exactly what the PR describes.

Other factors

The tests are runtime-asserting (run: { stdout }) rather than snapshot-only, and the Converted test's expect(code).not.toContain("unexported") pins that the plain-declaration fast path is unchanged. The PR description reports the wider bundler suite (bundler_cjs2esm, bundler_npm including ReactSSR's exact-output check, esbuild/default, etc.) passes. The description also flags a one-line rebase interaction with #39184 on the same field. Given the transform's subtlety and the note that #39184 touches the same line, a maintainer sign-off is appropriate even though the change itself looks correct.

@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:52 PM PT - Aug 15th, 2026

@robobun, your commit 4afe33b is building: #99147

Comment thread src/js_parser/parser.rs Outdated
Comment thread src/js_parser/parser.rs Outdated
Comment thread src/js_parser/parser.rs Outdated
Comment thread src/js_parser/parser.rs Outdated
Comment thread src/js_parser/visit/visit_expr.rs Outdated
…pped require()

visit_decls replaces `const x = require("pkg")` with the import the
require() becomes when pkg is on the CommonJS unwrap list, dropping the
declaration. That lost the `export` keyword of exported declarations
(including TypeScript `export import x = require()` and exports inside a
namespace) and the disposal of `using` declarations.

The bool that asked transpose_require for the replaceable-declaration
marker becomes a three-state RequireUnwrap, chosen by visit_decls from
the statement's kind and export flag: exported declarations bind the
import namespace like any other expression and survive, `using` and
`await using` declarations keep an ordinary require() so the value they
dispose is the module's own exports, and everything else folds into the
import as before.
@robobun
robobun force-pushed the farm/d7a6c272/unwrap-require-keep-exported-decl branch from ce03bea to a094395 Compare August 16, 2026 04:30

@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.

I reviewed this PR and didn't find any bugs. Because it changes how the cjs→esm require() unwrap is chosen in the parser visit pass — a path that affects React tree-shaking — and introduces a design choice (three-state RequireUnwrap, using > export priority, namespace-binding vs. synthesized export {}) that also intersects with #39184, a human look would still be worthwhile.

What was reviewed:

  • Traced RequireUnwrap through ExprIn → TransposeState → transpose_require; Namespace default is equivalent to the old false, and s_local is the only visit_decls caller.
  • Checked is_using() covers both KUsing/KAwaitUsing, and that Disabled survives through maybe_transpose_if_require for the conditional-require case.
  • Confirmed unwrap_commonjs_to_esm is bundler-only (set in ParseTask.rs), so no runtime transpiler cache bump needed.
  • The three new itBundled tests cover the exported/converted/using variants and pin the unchanged non-exported path; CI passed.
Extended reasoning...

Overview

This PR fixes a bundler bug where export const X = require("react") (and export var/let, multi-declarator, TS export import =, namespace exports, and using/await using) lose their export or disposal semantics when the required package is on the CommonJS unwrap list. The fix replaces the bool is_immediately_assigned_to_decl with a three-state RequireUnwrap enum (Namespace/IntoDecl/Disabled) threaded through ExprIn, TransposeState, and transpose_require. visit_decls now takes the statement's kind and is_export and picks the mode per-statement. Five parser files touched plus three new bundler tests.

Security risks

None. This is a pure code-transformation change in the bundler's AST visit pass; no user input parsing, no I/O, no auth/crypto.

Level of scrutiny

High. The cjs→esm unwrap path is what enables React tree-shaking in bun build, and a regression here would break real-world React bundles. The change is small in line count but introduces new semantics: a three-valued mode with a priority order (is_using() checked before is_export), and a deliberate choice to bind the import namespace for exports rather than synthesize an export { x } clause. The PR description argues each choice carefully, but these are the kind of design tradeoffs a maintainer should confirm.

Other factors

  • Correctness verified locally: visit_decls has exactly one caller (s_local); TransposeState::default() with RequireUnwrap::Namespace is semantically identical to the old is_require_immediately_assigned_to_decl: false; is_using() covers both using kinds; the IntoDecl→Namespace downgrade for non-string args in visit_expr.rs correctly preserves Disabled so using x = require(cond ? a : b) stays un-unwrapped.
  • No cache bump needed: unwrap_commonjs_to_esm is only set in bundler/ParseTask.rs, so the runtime transpiler cache is unaffected.
  • Test coverage: three new itBundled tests exercise export const/var/let, multi-declarator, TS import-equals, namespace export, converted vs wrapped targets, and using/await-using with actual disposal verification. The existing UnwrappedModuleRequireAssigned test pins the unchanged non-exported IntoDecl path. CI (Build #98831) passed.
  • Open coordination: the PR notes #39184 touches the same visit_decls site for destructuring bindings; whichever lands second rebases.
  • Comment-cop feedback on paragraph-length doc comments was addressed in a094395 and all threads are resolved.

@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.

I reviewed this PR and didn't find any bugs. Because it changes bundler output for the React unwrap path and the description notes a pending rebase interaction with #39184 at the same visit_decls site, a human look would still be worthwhile.

What was reviewed:

  • Confirmed visit_decls has a single caller (s_local) and the old is_immediately_assigned_to_decl/is_require_immediately_assigned_to_decl fields are fully removed.
  • Traced export import x = require() → S::Local { is_export } → RequireUnwrap::Namespace, and Disabled propagation through maybe_transpose_if_require for the conditional-using case.
  • Checked LocalKind::is_using() covers both KUsing and KAwaitUsing; using cannot be exported, so its precedence over is_export in the mode selection is fine.
Extended reasoning...

Overview

This PR fixes export const X = require("react") (and export var/let, multi-declarator, TS export import = require(), namespace exports, and using/await using) dropping the export or disposal when the required package is on the CommonJS unwrap list. The bool is_immediately_assigned_to_decl on ExprIn/TransposeState becomes a three-state RequireUnwrap enum (Namespace/IntoDecl/Disabled); visit_decls now takes kind + is_export and picks the mode once per statement; transpose_require gates should_unwrap_require on != Disabled and the marker return on == IntoDecl. Three new itBundled tests in bundler_cjs2esm.test.ts cover the variant matrix.

Security risks

None. This is bundler AST-rewrite logic with no auth, crypto, filesystem, or network surface. The change is a pure refactor of how a require-unwrap flag is threaded through the visit pass.

Level of scrutiny

Medium-high. The JS parser visit pass and transpose_require are on the bundler hot path and directly shape emitted output for React (the primary consumer of the unwrap list). The mechanical change is small and well-contained — a bool→enum widening threaded through the exact same three sites — but the semantic decision (bind exported declarations to the namespace identifier rather than synthesizing an export { x } after folding) has a stated tree-shaking tradeoff the author measured and argued. That's the kind of shape choice a maintainer should confirm.

Other factors

The PR description is unusually thorough: root cause traced to the unwrapped_id marker consumption in visit_decls, alternatives weighed (synthesized export { x } rejected for extending an existing let-reassignment mishandling to exports; namespace binding for using rejected because __toESM drops symbol-keyed properties), and unchanged paths pinned by named existing tests (UnwrappedModuleRequireAssigned, npm/ReactSSR). The comment-cop feedback was addressed (docs trimmed to one line per variant). I verified visit_decls has only the one s_local caller, the old field names are gone repo-wide, export import x = require() lowers to S::Local { is_export } so it reaches the fix, Disabled propagates through maybe_transpose_if_require for conditional requires, and is_using() covers both KUsing and KAwaitUsing. The explicit note that #39184 touches the same visit_decls site and whichever lands second must rebase is another reason to have a maintainer coordinate the merge order.

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.

1 participant