Skip to content

resolver: mark the Node builtin polyfills as free of side effects - #46783

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/bef88464/unused-node-polyfill-import
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/bef88464/unused-node-polyfill-import

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #14164

Draft: waits for a maintainer's answer (Downsides) and #46786.

Problem

  • bun build --target=browser bundles a whole Node builtin polyfill for an unused import: 921,016 bytes for a module whose server-only export calls createHash, 107 without the import.
  • Bun's own process.browser define leaves the polyfill of the server branch that it removes (63,107 bytes against 118).
  • The resolver's polyfill branch (src/resolver/resolver.rs:1949) returns no side-effect data. kit: implement server components dev server #14025 set a field for it that no code reads.

Fix

  • The resolver marks each polyfill as free of side effects, like a "sideEffects": false package. The linker then drops an unused import statement.
  • sys keeps its side effects until bundler: keep the export * statement of a lazily loaded module when a name is read through it #46786 merges: main loses the util initializer behind its export *.
  • Verified: test/bundler/bundler_browser.test.ts (12 new tests, 9 fail on main).
  • Self-reviewed: rejected a larger variant that keeps each used polyfill where main loads it (a new SideEffects value, 128 added source lines against 14).

Background

  • With --target=browser the resolver replaces node:crypto and 22 other builtins with polyfills inside Bun. They read globals at load and write none.
  • The linker keeps a file for an import statement only when the file has side effects.

Downsides

Notes

Numbers are from builds of main (6292c65) with and without the change.

The question

The three cases where a used polyfill loses its load position

  1. With --splitting, the chunk of the polyfill can load after a chunk that a later import statement loads. import { URL } from "node:url"; import "./replace-url.js"; console.log(URL.name) in a build with four entry points prints ReplacedURL. Main prints URL. A used node:events before a file that calls Object.freeze(Object.prototype) throws TypeError: Attempted to assign to readonly property.
  2. A polyfill that a dropped polyfill loaded first loads at its own import statement. import "node:string_decoder"; import "./replace-blob.js"; import { Blob } from "node:buffer" prints ReplacedBlob. Main prints Blob. Main prints ReplacedBlob too when the first line is deleted.
  3. With --splitting, a dropped polyfill no longer puts two chunks in order, and main has no order between sibling chunks. In one fixture a file then runs before a file that the source imports first (TypeError: undefined is not an object). Main fails the same way when the unused import is deleted.

The tests browser/UsedNodePolyfillLoadsAfterSideEffectsWithSplitting and browser/UnusedNodePolyfillImportDoesNotLoadPolyfillThatItRequires pin cases 1 and 2.

A "sideEffects": false package has case 1 on main today. With console.log("pure") in such a package and import { value } from "pure"; import "./side.js" in one of three entry points, --splitting prints side before pure.

Unused imports

  • import { createHash } from "node:crypto"; console.log("x") in a .js file: 920,959 to 50 bytes.
  • A module whose server-only export uses the builtin: crypto 921,016 to 107 bytes, stream 253,398 to 107, buffer 51,911 to 107, events 13,725 to 107.
  • An unused node:crypto next to a used node:buffer: 920,985 to 53,500 bytes.
  • An HTML entry point with that script: 920,956 to 47 bytes of JavaScript.

With #46786 (a local build of both changes)

The larger variant (built and measured, not pushed)

  • A new value SideEffects::RemovableIfUnused for the polyfills. Chunk assignment and chunk ordering read it as "has side effects". Tree shaking defers an import statement of such a file until the file, or a file that it reaches through import records, is live for another reason.
  • A build that keeps a polyfill is then byte-identical to main (11,464 of 11,464 generated builds), so cases 1 and 2 do not occur. Case 3 stays: 51 of 8,611 generated entry runs with --splitting change module order when a polyfill is dropped.
  • A polyfill leaves the bundle only when nothing that it imports stays, so an unused node:crypto next to a used node:buffer stays at 920,985 bytes.
  • The review rejected it: 128 added source lines of linker state for 23 files, a value that the chunk-order rewrites (bundler(splitting): keep import order when an entry point's chunk takes no fold #44028, bundler(splitting): run an entry's earlier imports before shared code #44495) must keep honouring, and no win in the mixed build.

Limits that stay

  • A name that the polyfill does not export is a build error, used or not.
  • node:fs and the other builtins with no polyfill: an unused import leaves var {readFileSync} = (() => ({}));.
  • require() and import() keep the polyfill.
  • After an onResolve callback returns nothing, the polyfill stays (13,653 bytes against 35 for an unused node:events). bundler: resume the shared resolve pass for imports that went to onResolve plugins #45408 owns that path.
  • The dev server does not tree-shake.
  • --ignore-dce-annotations keeps every polyfill.
  • The package.json "browser" map ("crypto": false) already removes a polyfill by hand.

The resolver branch that points a Node builtin at its browser polyfill
returned a result with the default side-effect data. The bundler then
kept the whole polyfill for an import statement that nothing uses.

Each polyfill record now has a side-effects value, and the branch copies
it to the result. The side_effects field of the polyfill package.json
records had no reader, so it is removed.

The sys polyfill keeps its side effects. It is `export * from "util"`,
and the linker does not initialize a util that another polyfill loads
with require() for a binding that an importer takes through a file with
no side effects.
…e declined

When every onResolve callback returns nothing, the bundler resolves the
import itself in run_resolver. That path dropped the side-effect data of
the resolver in two places. enqueue_parse_task stored only the loader's
data for a new module, and the return for an external import did not set
IS_EXTERNAL_WITHOUT_SIDE_EFFECTS. A package with "sideEffects": false, a
Node builtin polyfill, or an external Node builtin then stayed in the
bundle for an import that nothing uses.

Both places now keep the data the resolver found, as the path with no
plugin does. Where the resolver found none, a new module still gets the
loader's data.
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:41 PM PT - Oct 25th, 2026

✅ @robobun, your commit d3c8fe8f5a7989194916954a32fcf3926771a07e passed in Build #132921! 🎉


🧪   To try this PR locally:

bunx bun-pr 46783

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

bun-46783 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for a maintainer's decision and for #46786.

A self-review rejected the larger variant that I built after the first push (a new SideEffects value and deferred imports in tree shaking). The branch is back to the resolver-only change. That change moves the load position of a used polyfill in three cases, so a maintainer must accept it first. The question is in the PR description.

How I reproduced it, on main (6292c65):

echo 'import { createHash } from "node:crypto"; console.log("no use of createHash");' > unused.js
echo 'console.log("no import");' > none.js
bun build unused.js --target=browser --outfile=out-unused.js   # 920,959 bytes
bun build none.js --target=browser --outfile=out-none.js       # 37 bytes

With this PR out-unused.js is 50 bytes. 9 of the 12 new tests in test/bundler/bundler_browser.test.ts fail on main and pass with this PR.

@coderabbitai

coderabbitai Bot commented Oct 24, 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 179664e and 2f772af.

Files selected for processing: 5
  • src/bundler/bundle_v2.rs
  • src/resolver/node_fallbacks.rs
  • src/resolver/resolver.rs
  • test/bundler/bundler_browser.test.ts
  • test/bundler/bundler_plugin.test.ts

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

Walkthrough

The resolver now carries side-effects metadata for Node fallback modules. The bundler uses resolver metadata to set external-module flags and choose input-file side-effects. Tests cover tree shaking across browser, plugin, and Node builds.

Changes

Side-effects Metadata and Tree Shaking

Layer / File(s) Summary
Fallback side-effects metadata
src/resolver/node_fallbacks.rs, src/resolver/resolver.rs
Fallback specs now supply side-effects values, and the resolver copies the selected fallback module’s value into the resolution result. The sys fallback is marked as having side effects.
Bundler side-effects handling
src/bundler/bundle_v2.rs
The bundler uses resolver metadata to set the external-without-side-effects flag. It uses loader metadata for input files when resolver metadata indicates side effects.
Tree-shaking behavior tests
test/bundler/bundler_browser.test.ts, test/bundler/bundler_plugin.test.ts
Tests cover omitted unused imports and retained used imports, including code splitting, dynamic imports, require(), and plugin resolution callbacks.

Suggested reviewers: jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · up to 2f772

No concrete issue remains that needs resolution before merging; normal validation still applies.

Pre-merge checks: Passed 4
Check name Status Explanation
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.
Title check Passed The title clearly identifies the main change: marking Node builtin polyfills as free of side effects to enable removal of unused imports.
Description check Passed The description follows the repository template with both required sections, "What does this PR do?" and "How did you verify your code works?". It states the problem, the fix, and the background, and …
Full details: Description check

Explanation

The description follows the repository template with both required sections, "What does this PR do?" and "How did you verify your code works?". It states the problem, the fix, and the background, and it names the behavior changes and limits. It also gives concrete measurements and names the tests that cover the change. The author marks the PR as a draft. The author also asks a maintainer one open question about load order, and the notes say that question stays open.

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

Beyond the inline findings, I also checked the two refactor-shaped edits in src/resolver/node_fallbacks.rs: dropping side_effects: SideEffects::False from the synthetic PackageJSON is behavior-preserving (the only reader of package_json.side_effects is the real-directory path in resolver.rs around line 1623, which the fallback branch returns before reaching), and the zip(&SPECS) pairing is sound because MODULES is built from SPECS.iter() in the same order. The no-plugin external branch at bundle_v2.rs:7190 already stores the same side-effects bit, so the new run_resolver store matches it rather than introducing a divergent third site.

Extended reasoning...

The change adds a per-polyfill side-effects value in src/resolver/node_fallbacks.rs, copies it into the resolver result on the node-fallback branch, and makes the plugin-declined run_resolver/enqueue_parse_task path in src/bundler/bundle_v2.rs honor resolver-declared side-effects data for external records and new input files. It touches no security-sensitive surface. Three confirmed findings (splitting chunk-order change for used polyfills, order-dependent side-effects on mixed answered/declined imports, and a pre-existing ExternalRewritePath miss) are posted inline, so a human look is already signaled; this body only records what else was examined and ruled out.

Findings marked 🟡 are optional suggestions and need no follow-up push.


macro_rules! fallback_spec {
($name:literal, $code_path:literal) => {
fallback_spec!($name, $code_path, SideEffects::NoSideEffectsPureData)

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.

🔴 Browser builds with --splitting that use a Node polyfill can now run the polyfill after a later-imported file with side effects, so source evaluation order is no longer honored. Every polyfill except sys is declared NoSideEffectsPureData at src/resolver/node_fallbacks.rs:88, and the linker's chunk_of_file in findAllImportedPartsInJSOrder.rs:30 treats such a chunk as running nothing and moves its import behind chunks that do. Polyfills like url, util, buffer and timers read globals at load, so a setup file imported after them can now replace or freeze what they capture. Fix: keep the tree-shaking value for dropping unused imports but have chunk ordering treat polyfill files as running code at load, so a used polyfill keeps its source position.

Why this was flagged

A browser build with splitting: true and several entry points where entry.js has import { EventEmitter } from "node:events"; import "./setup.js"; and setup.js freezes a prototype or replaces a global such as globalThis.URL. src/resolver/node_fallbacks.rs:88 marks the events, url, util, buffer, timers and 18 other polyfills NoSideEffectsPureData. In src/bundler/linker_context/computeCrossChunkDependencies.rs:360-367 a chunk whose files all satisfy loading_file_has_no_side_effects is marked inert, and its import is placed after chunks that run code. The polyfill chunk is therefore evaluated after setup.js even though the source imported it first. On the base branch the polyfill package.json carried no usable side-effect data, so the chunk kept source order for these eight polyfills and the polyfill captured the original global or ran before the freeze. The new test test/bundler/bundler_browser.test.ts polyfill-first.js case asserts "ReplacedURL", locking in the reordered order.

Verification: src/resolver/node_fallbacks.rs:88 defaults every polyfill to SideEffects::NoSideEffectsPureData. The cross-chunk import ORDER is decided in src/bundler/linker_context/findAllImportedPartsInJSOrder.rs:29-33, which populates chunk_of_file only for files where !loading_file_has_no_side_effects(source_index). So the polyfill chunk's import is printed after every chunk that was reached through a side-effectful file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still open, and it is the question in the PR description. The branch is back to the resolver-only change, so a used polyfill orders like a "sideEffects": false package under --splitting. browser/UsedNodePolyfillLoadsAfterSideEffectsWithSplitting pins that.

I built the variant that you describe: a separate value that chunk ordering reads as code that runs at load. A self-review rejected it. It needs 128 added source lines of linker state, and it keeps the polyfill in a build that uses a polyfill which the unused one requires. The notes in the description have its numbers. A maintainer decides which of the two to take.

Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs
@robobun
robobun marked this pull request as draft October 26, 2026 03:20
Comment on lines +126 to +127
// Keeps its side effects: it is `export * from "util"`, and the linker does not
// initialize a `require()`d `util` for a binding imported through a file without them.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The exception for sys is a workaround for a linker gap on main: a name read through export * does not initialize a lazily loaded target. #46786 fixes that gap. This PR is a draft until it merges. The exception and its comment go then.

@robobun robobun changed the title bundler: drop unused Node builtin polyfills, keep side effects after a declined onResolve resolver: mark the Node builtin polyfills as free of side effects Oct 26, 2026

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