Skip to content

Reduce the number of closures in generated bundler code - #27022

Merged
Jarred-Sumner merged 6 commits into
mainfrom
jarred/reduce-closures
Feb 15, 2026
Merged

Jarred-Sumner merged 6 commits into
mainfrom
jarred/reduce-closures

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Feb 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The bundler's __toESM helper creates a new getter-wrapped proxy object every time a CJS
module is imported. In a large app, a popular dependency like React can be imported 600+
times — each creating a fresh object with ~44 getter properties. This produces ~27K
unnecessary GetterSetter objects, ~25K closures, and ~25K JSLexicalEnvironment scope
objects at startup.

Additionally, __export and __exportValue use var-scoped loop variables captured by
setter closures, meaning all setters incorrectly reference the last iterated key (a latent
bug).

Changes

  1. __toESM: add WeakMap cache — deduplicate repeated wrappings of the same CJS
    module. Two caches (one per isNodeMode value) to handle both import modes correctly.
  2. Replace closures with .bind() — () => obj[key] becomes __accessProp.bind(obj, key). BoundFunction is cheaper than Function + JSLexicalEnvironment, and frees the for-in
    JSPropertyNameEnumerator from the closure scope.
  3. Fix var-scoping bug in __export/__exportValue — setter closures captured a
    shared var name and would all modify the last iterated key. .bind() eagerly captures
    the correct key per iteration.
  4. __toCommonJS: .map() → for..of — eliminates throwaway array allocation.
  5. __reExport: single getOwnPropertyNames call — was calling it twice when
    secondTarget was provided.

Impact (measured on a ~23MB single-bundle app with 600+ React imports)

Metric Before After Delta
Total objects 745,985 664,001 -81,984 (-11%)
Heap size 115 MB 111 MB -4 MB
GetterSetter 34,625 13,428 -21,197 (-61%)
Function 221,302 197,024 -24,278 (-11%)
JSLexicalEnvironment 70,101 44,633 -25,468 (-36%)
Structure 40,254 39,762 -492

@robobun

robobun commented Feb 14, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:16 AM PT - Feb 15th, 2026

❌ @Jarred-Sumner, your commit 14e0298 has 1 failures in Build #37337 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 27022

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

bun-27022 --bun

@coderabbitai

coderabbitai Bot commented Feb 14, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

Refactors runtime internals: adds shared __accessProp, per-module WeakMap caching for __toESM, bound getter/setter helpers (__exportSetter, __exportValueSetter, __returnValue), and caches in __toCommonJS. Test snapshots and source map expectations updated to reflect emitted-code changes. No public API signature changes.

Changes

Cohort / File(s) Summary
Runtime
src/runtime.js
Introduces __accessProp for bound getters, adds two WeakMap caches for __toESM with canCache logic, binds getters/setters via .bind(...), adds __exportSetter, __exportValueSetter, and __returnValue, refactors __toCommonJS to use cached __moduleCache. Internal-only changes.
Bundler tests — edgecase / npm / promiseall_deadcode
test/bundler/bundler_edgecase.test.ts, test/bundler/bundler_npm.test.ts, test/bundler/bundler_promiseall_deadcode.test.ts
Updated snapshots and source-map expectations to new generated mappings and sizes; __export setter behavior now uses bound helper (__exportSetter), and debugId/snapshot markers adjusted. Review source-map and filesize assertions.
HTML manifest test
test/bundler/html-import-manifest.test.ts
Updated first manifest entry: generated client filename and HTTP ETag changed. Verify manifest path/etag assertions.
Regression — cyclic imports
test/regression/issue/cyclic-imports-async-bundler.test.js
Snapshot updated to include __returnValue and __exportSetter helpers and to use bound export setters; debugId in snapshot changed. Review snapshot expectations.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (25 files):

⚔️ src/bun.js/api/server/NodeHTTPResponse.zig (content)
⚔️ src/bun.js/bindings/webcore/SerializedScriptValue.cpp (content)
⚔️ src/http/websocket_client.zig (content)
⚔️ src/http/websocket_client/WebSocketProxyTunnel.zig (content)
⚔️ src/runtime.js (content)
⚔️ src/shell/builtin/cp.zig (content)
⚔️ src/shell/builtin/ls.zig (content)
⚔️ src/shell/builtin/mkdir.zig (content)
⚔️ src/shell/builtin/seq.zig (content)
⚔️ src/shell/builtin/touch.zig (content)
⚔️ src/shell/states/CondExpr.zig (content)
⚔️ test/bundler/__snapshots__/bun-build-api.test.ts.snap (content)
⚔️ test/bundler/bundler_edgecase.test.ts (content)
⚔️ test/bundler/bundler_npm.test.ts (content)
⚔️ test/bundler/bundler_promiseall_deadcode.test.ts (content)
⚔️ test/bundler/html-import-manifest.test.ts (content)
⚔️ test/js/bun/http/fixtures/cert.key (content)
⚔️ test/js/bun/http/fixtures/cert.pem (content)
⚔️ test/js/bun/test/parallel/test-https-should-work-when-sending-request-with-agent-false.ts (content)
⚔️ test/js/node/http/node-http.test.ts (content)
⚔️ test/js/web/websocket/websocket-proxy.test.ts (content)
⚔️ test/js/workerd/html-rewriter.test.js (content)
⚔️ test/regression/fixtures/cert.key (content)
⚔️ test/regression/fixtures/cert.pem (content)
⚔️ test/regression/issue/cyclic-imports-async-bundler.test.js (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Reduce the number of closures in generated bundler code' directly summarizes the main objective of the PR, which focuses on closure reduction and memory optimization in bundler helpers.
Description check ✅ Passed The PR description follows the template with both required sections: 'Problem' clearly outlines issues, and 'Changes' with 'Impact' provide verification. However, the 'How did you verify your code works?' section is addressed through metrics/impact table rather than explicit testing steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


No actionable comments were generated in the recent review. 🎉


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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime.js (1)

48-78: ⚠️ Potential issue | 🔴 Critical

Guard WeakMap caching against primitive and non-object exports.
WeakMap only accepts object/function keys. If a CJS module exports a primitive (e.g., module.exports = 42), cache.set() throws TypeError. Add type guards to skip caching for non-objects. Note: __toCommonJS (line 86–97) has the identical issue.

🩹 Proposed fix for __toESM
-  if (mod != null) {
-    var cache = isNodeMode ? (__toESMCache_node ??= new WeakMap()) : (__toESMCache_esm ??= new WeakMap());
-    var cached = cache.get(mod);
-    if (cached) return cached;
-  }
+  var cache;
+  if (mod && (typeof mod === "object" || typeof mod === "function")) {
+    cache = isNodeMode ? (__toESMCache_node ??= new WeakMap()) : (__toESMCache_esm ??= new WeakMap());
+    var cached = cache.get(mod);
+    if (cached) return cached;
+  }
   target = mod != null ? __create(__getProtoOf(mod)) : {};
   const to =
     isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
@@
-  if (mod != null) cache.set(mod, to);
+  if (cache) cache.set(mod, to);
   return to;

@claude

claude Bot commented Feb 14, 2026 •

Copy link
Copy Markdown
Contributor

Code Review

Newest first

✅ b4543 — Looks good!

Reviewed 7 files across src/ and test/: Optimizes bundler runtime helpers by adding WeakMap caching to __toESM to deduplicate repeated CommonJS-to-ESM wrappings, replacing closure-based getters/setters with more memory-efficient .bind()-based BoundFunctions, and fixing a latent var-scoping bug in export setters.


✅ 7f12f — Looks good!

Reviewed 1 file in src/: Reduces memory usage in bundled output by replacing closure-based getters/setters with Function.prototype.bind() calls and adding WeakMap caching to __toESM and lazy initialization to __moduleCache.


Powered by Claude Code Review

@claude

claude Bot commented Feb 14, 2026

Copy link
Copy Markdown
Contributor

Code Review

Newest first

✅ 93218 — Looks good!

Reviewed 7 files across src/ and test/bundler/: Reduces memory usage in bundled code by replacing per-property closure creation with shared bound functions and adding WeakMap caching to the CommonJS-to-ESM module conversion helper.


Powered by Claude Code Review

@claude

claude Bot commented Feb 15, 2026

Copy link
Copy Markdown
Contributor

Code Review

Newest first

✅ 3472a — Looks good!

Reviewed 7 files across src/ and test/bundler/: Reduces memory allocation in Bun's bundler runtime by replacing closure-based property getters/setters with shared functions using .bind() and adding WeakMap-based caching to avoid redundant CJS-to-ESM module conversions.


✅ b4543 — Looks good!

Reviewed 7 files across src/ and test/: Optimizes bundler runtime helpers by adding WeakMap caching to __toESM to deduplicate repeated CommonJS-to-ESM wrappings, replacing closure-based getters/setters with more memory-efficient .bind()-based BoundFunctions, and fixing a latent var-scoping bug in export setters.


✅ 7f12f — Looks good!

Reviewed 1 file in src/: Reduces memory usage in bundled output by replacing closure-based getters/setters with Function.prototype.bind() calls and adding WeakMap caching to __toESM and lazy initialization to __moduleCache.


Powered by Claude Code Review

@Jarred-Sumner
Jarred-Sumner merged commit 77ca318 into main Feb 15, 2026
3 of 5 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the jarred/reduce-closures branch February 15, 2026 08:37
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
### Problem

The bundler's `__toESM` helper creates a new getter-wrapped proxy object
every time a CJS
module is imported. In a large app, a popular dependency like React can
be imported 600+
times — each creating a fresh object with ~44 getter properties. This
produces ~27K
unnecessary `GetterSetter` objects, ~25K closures, and ~25K
`JSLexicalEnvironment` scope
objects at startup.

Additionally, `__export` and `__exportValue` use `var`-scoped loop
variables captured by
setter closures, meaning all setters incorrectly reference the last
iterated key (a latent
  bug).

### Changes

1. **`__toESM`: add WeakMap cache** — deduplicate repeated wrappings of
the same CJS
module. Two caches (one per `isNodeMode` value) to handle both import
modes correctly.
2. **Replace closures with `.bind()`** — `() => obj[key]` becomes
`__accessProp.bind(obj,
key)`. BoundFunction is cheaper than Function + JSLexicalEnvironment,
and frees the for-in
  `JSPropertyNameEnumerator` from the closure scope.
3. **Fix var-scoping bug in `__export`/`__exportValue`** — setter
closures captured a
shared `var name` and would all modify the last iterated key. `.bind()`
eagerly captures
the correct key per iteration.
4. **`__toCommonJS`: `.map()` → `for..of`** — eliminates throwaway array
allocation.
5. **`__reExport`: single `getOwnPropertyNames` call** — was calling it
twice when
`secondTarget` was provided.

### Impact (measured on a ~23MB single-bundle app with 600+ React
imports)

| Metric | Before | After | Delta |
|--------|--------|-------|-------|
| **Total objects** | 745,985 | 664,001 | **-81,984 (-11%)** |
| **Heap size** | 115 MB | 111 MB | **-4 MB** |
| GetterSetter | 34,625 | 13,428 | -21,197 (-61%) |
| Function | 221,302 | 197,024 | -24,278 (-11%) |
| JSLexicalEnvironment | 70,101 | 44,633 | -25,468 (-36%) |
| Structure | 40,254 | 39,762 | -492 |
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
### Problem

The bundler's `__toESM` helper creates a new getter-wrapped proxy object
every time a CJS
module is imported. In a large app, a popular dependency like React can
be imported 600+
times — each creating a fresh object with ~44 getter properties. This
produces ~27K
unnecessary `GetterSetter` objects, ~25K closures, and ~25K
`JSLexicalEnvironment` scope
objects at startup.

Additionally, `__export` and `__exportValue` use `var`-scoped loop
variables captured by
setter closures, meaning all setters incorrectly reference the last
iterated key (a latent
  bug).

### Changes

1. **`__toESM`: add WeakMap cache** — deduplicate repeated wrappings of
the same CJS
module. Two caches (one per `isNodeMode` value) to handle both import
modes correctly.
2. **Replace closures with `.bind()`** — `() => obj[key]` becomes
`__accessProp.bind(obj,
key)`. BoundFunction is cheaper than Function + JSLexicalEnvironment,
and frees the for-in
  `JSPropertyNameEnumerator` from the closure scope.
3. **Fix var-scoping bug in `__export`/`__exportValue`** — setter
closures captured a
shared `var name` and would all modify the last iterated key. `.bind()`
eagerly captures
the correct key per iteration.
4. **`__toCommonJS`: `.map()` → `for..of`** — eliminates throwaway array
allocation.
5. **`__reExport`: single `getOwnPropertyNames` call** — was calling it
twice when
`secondTarget` was provided.

### Impact (measured on a ~23MB single-bundle app with 600+ React
imports)

| Metric | Before | After | Delta |
|--------|--------|-------|-------|
| **Total objects** | 745,985 | 664,001 | **-81,984 (-11%)** |
| **Heap size** | 115 MB | 111 MB | **-4 MB** |
| GetterSetter | 34,625 | 13,428 | -21,197 (-61%) |
| Function | 221,302 | 197,024 | -24,278 (-11%) |
| JSLexicalEnvironment | 70,101 | 44,633 | -25,468 (-36%) |
| Structure | 40,254 | 39,762 | -492 |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants