Skip to content

bundler: use onResolve-returned path for external imports - #35053

Open
robobun wants to merge 12 commits into
mainfrom
farm/1cc9f314/onresolve-external-path-rewrite
Open

bundler: use onResolve-returned path for external imports#35053
robobun wants to merge 12 commits into
mainfrom
farm/1cc9f314/onresolve-external-path-rewrite

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • An onResolve answer of { path, external: true } loses its path. The output keeps the source specifier, so a plugin cannot rewrite react to a CDN URL. esbuild emits the returned path. Fixes implement build api define and loaders #2805.
  • The external arm of BundleV2::on_resolve (bundle_v2.rs:4849 on main) drops result.path and never updates the importer's ImportRecord, which is what the printer emits.
  • Adjacent: { external: true } with no path hits if (!path) continue in runOnResolvePlugins (BundlerPlugin.ts:444) and ends in Could not resolve. esbuild externalizes the source specifier.

Fix

  • on_resolve parks the plugin path on free_list and passes a PendingImport::ExternalPath to apply_or_defer_pending_import. The helper writes the importer's record now, or queues it until the records are on the graph. The source index arm uses the same helper.
  • ExternalPath also resets source_index. The path map is keyed by path text, and its pass runs while a plugin answer is pending, so an import whose specifier equals the text of a module another answer bundled got that module. Released Bun then prints var __INVALID__REF__ = __commonJS(function(exports) {}); and __toESM(, 1). A queued answer is applied after the map pass for the same reason.
  • runOnResolvePlugins falls back to the input specifier when path is empty and external is set.
  • Verified: test/bundler/bundler_plugin.test.ts, plugin/ResolveExternal* (import forms, require, no path, same path, specifier matched to a module, barrel un-defer). All but the same path case fail on released Bun. Other suites in Notes.

Background

  • An external record is one with no source_index. This PR only changes the path it prints.
  • resolve_tasks_waiting_for_import_source_index (bundle_v2.rs:139) holds answers that arrive before the importer's records are on the graph. patch_import_record_source_indices drains it, then assigns a source_index to each record whose path text is in the path map. PendingImport is its element type, now an enum of the two answer kinds.
  • free_list holds plugin-owned boxes whose bytes the graph points into.
Notes

Repro on released Bun: the script in #2805 prints import React from "react". With this branch it prints the plugin path.

Rebased over #39874 and #39799. #39799 carried the entry point error (The entry point "x" cannot be marked as external) from an earlier revision of this PR. This PR only reorders that arm ahead of the rewrite.

#39874 supersedes the IS_EXTERNAL flag an earlier revision added. Before it, a barrel (sideEffects: false) was resolved and patched again as a whole on each un-defer, and those passes hit the rewritten record: the first resolved the rewritten specifier natively (Could not resolve: "react-vendored"), the second linked it to a bundled module with the same path text (No matching export in "virt:react-vendored"). #39874 limits both passes to the un-deferred records, so neither reaches it, and it leaves bit 10, the last free ImportRecordFlags bit, unused. The flag is dropped here. plugin/ResolveExternalRewriteSurvivesBarrelRevisit stays and pins both cases: a rewritten external re-export, a bundled virtual module that shares its path text, and a consumer that un-defers a third record after both answers landed. It passes on this branch through #39874's index list and fails on released Bun.

29264.test.ts: its plugin returned { external: true } for every bare specifier, including the entry point, and relied on the old fall-through to NoMatch. With the fallback, the entry point answer fails the build with the #39799 error before index.js is parsed. The test now skips entry-point-build and adds import "other", for which the plugin returns undefined, so the NoMatch -> run_resolver path that #29264 guards stays live. Verified by commenting that store out: the test panics again in run_resolver.

The queued ExternalPath arm has no test of its own. Both callers of resolve_import_records dispatch the plugin and store the records in the same synchronous call, and the answer comes back through two task hops, so today the queue is not reached. The queued SourceIndex arm is in the same position. The after-map ordering is what keeps the queued arm correct if that changes.

An answer that returns the specifier unchanged also goes through ExternalPath, so it clears a stale source_index too. ResolveExternalClearsModuleMatchedBySpecifier* pins both answer shapes: an earlier answer bundles a virt module at path text react, and a file loaded after that answer imports react, which the plugin externalizes. The debug build asserted !ref_.is_empty() in print_code_for_file_in_chunk_js on this input before 1b41dce.

Suites run on the rebased debug build: bundler_plugin, bundler_plugin_chain, bundler_barrel (including the #39874 tests), bundler_edgecase, bundler_browser, bundler_npm, bun-build-api, bake/dev/plugins, regression/issue/29264. Earlier revisions also ran bundler_plugin_chain, bundler_bun, bundler_cjs, bundler_splitting, bundler_regressions, bundler_jsx, bundler_comments, bundler_html, metafile, bun-build-api, native-plugin, bake/dev/bundle, bake/dev/css.

The extra ESM cases and the same path case are adapted from #35577, which was reduced to the docs change for #11652 after this PR was opened.


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

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Build 103083 (1b41dce) finished. bundler_plugin.test.ts and 29264.test.ts pass on all 11 test platforms. The only test that stays red is test/cli/install/bun-audit.test.ts, which fails on main as well: its snapshot contains the advisory range <1.4.1, and the snapshot normalizer now rewrites that as the Bun version. It is reported separately. Everything else in the build passed on retry.

What the PR does now: an onResolve answer of { path, external: true } rewrites the printed import (#2805), { external: true } with no path externalizes the specifier, and an external answer clears a source_index the path map pass set from the specifier (released Bun prints __INVALID__REF__ and __toESM(, 1) for that input). Rebased over #39874 and #39799, so it adds no ImportRecordFlags bit and no entry point arm of its own.

Repro on released Bun:

$ bun repro.ts
has rewritten specifier: false
still has original: true

With this branch:

has rewritten specifier: true
still has original: false

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:58 PM PT - Aug 21st, 2026

@robobun, your commit 1b41dce has 1 failures in Build #103083 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35053

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

bun-35053 --bun

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

No bugs found, but this touches unsafe lifetime-erased path storage in bundle_v2.rs plus the builtin BundlerPlugin.ts resolve flow, so it's worth a human look.

What was reviewed:

  • Memory ownership in the new external branch — the 'static-erased path is only stored into import_record.path on the arm that also parks both backing boxes on free_list; every other arm drops the boxes only after path is dead.
  • The { external: true } no-path fallback in runOnResolvePlugins — round-trips inputPath to the native side, hits the eql_long equal-path arm, and leaves out_source_index = None so the record stays external without a rewrite.
  • The onResolve/parse race (import-records not yet populated) was raised and refuted — the new branch silently no-ops in that window rather than corrupting state.
Extended reasoning...

Overview

The PR fixes #2805: onResolve plugins returning { path, external: true } had the returned path discarded, so the emitted external import kept the original specifier. Three files change:

  • src/bundler/bundle_v2.rs — hoists the Fs::Path construction out of the !result.external branch and adds an else if arm that writes the plugin-returned path back into the importer's ImportRecord (mirroring the native resolver's is_external_and_rewrite_import_path handling at ~L6278). Backing Box<[u8]> allocations are moved onto free_list when the erased 'static slice is stored.
  • src/js/builtins/BundlerPlugin.ts — moves the external type check before the !path short-circuit, and falls back to inputPath when external is set with no path (previously fell through to continue → native resolve → "Could not resolve").
  • test/bundler/bundler_plugin.test.ts — three new itBundled cases (ESM import rewrite, CJS require rewrite, external-without-path).

Security risks

None identified. This is bundler output-path rewriting driven by a user-supplied plugin; no auth, crypto, or filesystem-escape surface.

Level of scrutiny

Medium-high. The Rust change is small but sits inside an unsafe lifetime-erasure block with a hand-maintained SAFETY invariant (the free_list parking pattern). I traced each of the four terminal arms of the new else if and confirmed the 'static path is either (a) stored into import_record.path on the same arm that pushes both boxes onto free_list, or (b) dead before the boxes drop. The path.namespace field is either the b"file" literal or result_ns_static, and in the stored case both boxes go to free_list, so the namespace slice is kept alive too. The updated SAFETY comment accurately reflects the new branch.

The builtin JS change is small but has one observable side effect: { external: <non-boolean> } with no path now throws a TypeError where it previously hit continue and fell through. That's arguably a correctness improvement (esbuild parity) but is a behavior change a maintainer should sign off on.

Other factors

  • Finder agents raised, and verifiers refuted, a race where on_resolve completes before the importer's parse populates import_records (the len() <= import_record_index guard). In that window the new code drops the boxes and skips the rewrite rather than deferring via resolve_tasks_waiting_for_import_source_index the way the out_source_index arm does. The verifiers concluded this is not reachable / not a bug; I'm noting it because the guard is the one arm without a deferred-apply, and a maintainer familiar with the resolve/parse ordering should confirm.
  • Tests look solid (assert both presence of the rewritten specifier and absence of the original), placed alongside existing plugin tests, with the issue URL comment.
  • The ImportKind::EntryPointBuild exclusion is correct — entry points have no importer ImportRecord to rewrite.

Given the unsafe-Rust surface and the minor JS-side validation-order change, I'm deferring rather than approving.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

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

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 1 minute), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ba7baf00-047d-48ba-b1e6-69ad3bf1c8f0

📥 Commits

Reviewing files that changed from the base of the PR and between ae42135 and 1b41dce.

📒 Files selected for processing (4)
  • src/bundler/bundle_v2.rs
  • src/js/builtins/BundlerPlugin.ts
  • test/bundler/bundler_plugin.test.ts
  • test/regression/issue/29264.test.ts

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 finding, I also checked: the new EntryPointBuild-external error branch is covered by the decrement_scan_counter_on_drop guard at the top of on_resolve, so it won't hang the build; and the free_list handling in the external rewrite mirrors the existing !found_existing branch (boxes parked only when import_record.path actually borrows them). The candidate concern about the rewrite being dropped when source_import_records.len() <= import_record_index was examined and ruled out.

Extended reasoning...

This PR touches unsafe Rust with 'static lifetime erasure and manual free_list ownership in BundleV2::on_resolve, plus adds new user-facing behavior (entry-point-external now errors; {external: true} without path now externalizes instead of falling through). The change looks correct and well-tested, but the memory-safety surface and API behavior change warrant a human look. The inline nit about test/regression/issue/29264.test.ts is the only finding.

Comment thread test/regression/issue/29264.test.ts

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

Thanks for addressing the 29264 test coverage — 9b8e0ca restores the NoMatch → run_resolver path so the original crash guard is still live. I didn't find further issues, but this touches bundler memory ownership (unsafe 'static erasure + free_list parking) and changes user-visible onResolve semantics ({ external: true } without path now succeeds instead of falling through to a resolve error), so a maintainer should sign off.

What was reviewed:

  • New external branch mirrors the native resolver's is_external_and_rewrite_import_path pattern at bundle_v2.rs:6290-6297 (same eql_long guard, same path_as_static write).
  • free_list ownership: boxes are only dropped on paths where path is dead; on the rewrite path they're parked and outlive the borrow.
  • Verifiers ruled out a race where onResolve completes before import_records is populated — the new branch bounds-checks before indexing.
Extended reasoning...

Overview

The PR fixes #2805: onResolve plugins returning { path, external: true } had the returned path discarded, so the emitted external import kept the original specifier. It also fixes the adjacent case where { external: true } with no path produced a spurious "Could not resolve" error, and adds an explicit error when a plugin marks an entry point external (previously a chunks.len() > 0 panic). Four files touched: bundle_v2.rs (~50 lines net in on_resolve), BundlerPlugin.ts (reordered external type check + inputPath fallback), four new itBundled cases, and the 29264 regression test adapted to keep exercising NoMatch → run_resolver.

Security risks

None identified. This is bundler plugin dispatch — no auth, crypto, or untrusted-input parsing beyond what already exists. The path string is written verbatim into the output bundle as an import specifier; it's not used for filesystem access.

Level of scrutiny

Moderate-to-high. The Rust change hoists an existing unsafe 'static-erasure block out of the !external branch so both branches can build Fs::Path from the plugin-returned boxes, and adds a new consumer of the free_list ownership-parking pattern. I traced every branch: on the rewrite path (!eql_long) the boxes are pushed to free_list before the 'static borrow escapes into import_record.path; on the eql_long, bounds-check-fail, and EntryPointBuild branches the boxes are dropped and path is not read afterward. The write pattern (import_record.path = path_as_static(&path)) matches the native resolver's external rewrite at bundle_v2.rs:6290-6297. This looks correct, but unsafe lifetime erasure in bundler core warrants a maintainer's eyes.

Other factors

  • Behavior change: { external: true } without path previously fell through as NoMatch (→ native resolver → "Could not resolve"); it now externalizes the input specifier. This matches esbuild and is clearly the intended semantics, but it's a user-visible change to plugin API behavior.
  • Prior feedback addressed: my earlier inline note about 29264.test.ts becoming inert was fixed in 9b8e0ca — the author added import "other" returning undefined and verified the test panics again with the load-bearing store reverted.
  • Test coverage: ESM import, CJS require, no-path, and entry-point-error are all covered; existing bundler_plugin.test.ts and bun-build-api.test.ts reported passing locally.
  • Ruled out: verifier agents examined whether the external rewrite could be silently dropped when onResolve completes before import_records is stored — the bounds check at 4713 handles that case (drops the boxes, no crash, no rewrite), and in normal operation import_records is populated before deferred plugin tasks resolve.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

I opened #35577 with an overlapping fix before noticing this one and have since reduced that PR to the docs clarification for #11652 only. A couple of things I ran into while working on the same code path, in case they're useful here:

  • The else branch drops result.path/result.namespace when source_import_records.len() <= import_record_index. That's the same race resolve_tasks_waiting_for_import_source_index exists for on the non-external side (the comment on the field at bundle_v2.rs:107 spells it out), so an onResolve that wins the race would silently lose its rewrite. In practice the race window seems hard to hit from the JS API, but it's reachable in principle.
  • The bundled-path collision: writing the plugin path onto import_record.path before patch_import_record_source_indices' second loop runs means a plugin-returned path that byte-matches an already-enqueued source's path can pick up a source_index from path_to_source_index_map and get bundled instead of externalized. The immediate branch here avoids it by writing after graph.ast.set, so the two paths already diverge; just noting it since the deferred variant would need to apply the rewrite after the map lookup to stay consistent.
  • My branch had tests covering export { x } from, export * from, and dynamic import() in addition to static import / require(); feel free to lift them from 787a7da if you want the extra coverage.

robobun added a commit that referenced this pull request Aug 15, 2026
…ld when no entry point survives

The onResolve external arm duplicated the error that #35053 already adds,
so it is dropped here. In its place the CLI and Bun.build drivers fail
with "None of the entry points could be bundled" when parsing ends with
an empty entry point list, so any remaining way of dropping every entry
point without logging becomes a build error instead of indexing an empty
chunk list. The builtin entry point message no longer suggests another
target, since a builtin cannot be an entry point for any target.
@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #39799 adds the The entry point "x" cannot be marked as external error in on_resolve (the same arm this PR adds, same wording), together with the other producers of the zero entry point abort. Once #39799 lands, this PR only needs to drop its copy of that arm and the plugin/ResolveExternalEntryPointErrors case (#39799 pins it in bundler_plugin.test.ts). The import path rewrite here is unaffected.

Jarred-Sumner pushed a commit that referenced this pull request Aug 21, 2026
…linking zero entry points (#39799)

### Problem
- `bun build` and `Bun.build()` abort with `panic: index out of bounds:
the len is 0 but the index is 0` in `generate_chunks_in_parallel`
(`generateChunksInParallel.rs:64`, `chunks[0]`) when every entry point
is dropped (Sentry BUN-3RAS). With a live entry point beside it, the
build exits 0 and silently emits fewer outputs.
- Three producers drop an entry point without a log entry, so the
drivers link with `graph.entry_points` empty: (a) a result with every
path disabled (`"browser": {"./a.ts": false}`, or `fs` / `node:*` under
the browser target); (b) an onResolve plugin that returns `external:
true` for it; (c) an over-long specifier, which `resolve_entry_point`
returned before it logged.

### Fix
- `resolve_entry_point` rejects a disabled result: `"./a.ts" is disabled
due to "browser" field in package.json (entry point)` or `Cannot use
Node.js builtin "fs" as an entry point`. This covers the CLI,
`Bun.build()` and the plugin fallback. It makes two no-path arms
unreachable, so they are deleted: the `Ok(None)` return in
`enqueue_entry_item` (the old drop site) and the `Worker entry point is
missing` arm in `web_worker.rs`.
- `on_resolve` logs `The entry point "x" cannot be marked as external`
(esbuild's error). The length guard now only skips the cache bust, so an
over-long entry point logs `ModuleNotFound` like any missing one.
- Backstop: both drivers fail with `None of the entry points could be
bundled` when no entry point survives parsing. The linker's
`debug_assert` stays. The CLI drivers check the log after
`wait_for_parse()`, as the JS driver did, so no parse task is in flight
at teardown.
- Verified: `test/bundler/bundler_browser.test.ts`,
`bundler_plugin.test.ts`, `bun-build-api.test.ts`,
`test/js/web/workers/worker.test.ts` (new cases, all red on 1.4.0).
Other suites: see notes.

### Background
- A disabled module is how the resolver represents `"browser": false`
and browser-stubbed builtins: `Result::path()` is `None` and an import
of it becomes `{}`. An entry point has nothing to emit in that state.
- `enqueue_entry_item` appends each resolved entry point to
`graph.entry_points` (a plugin answer arrives in `on_resolve` instead).
The drivers wait for parsing, fail if the log has errors, then link. The
linker needs one entry point, so every drop has to log.

Supersedes #38778 and #38391. Carries the entry point arm of #35053,
whose import path rewrite is independent.

<details><summary>Notes</summary>

Repros on 1.4.0 (each exits 134, now exits 1 or returns `success: false`
with one message):

```sh
bun -e 'await Bun.build({entrypoints:["node:fs"]})'
bun build node:fs
bun build fs
echo '{"browser":{"./a.ts":false}}' > package.json; bun build --target=browser ./a.ts
bun -e 'await Bun.build({entrypoints:["./b.ts"], plugins:[{name:"x", setup(b){ b.onResolve({filter:/b\.ts$/}, a => ({path:a.path, external:true})) }}]})'
bun -e 'await Bun.build({entrypoints:["a".repeat(5000)]})'
```

Local debug build only: three `terminate()` tests in `worker.test.ts`
(message flood, preload with un-awaited `import()`, `fs.readFile`
completions) fail in this container, and fail the same way with the
unmodified main sources built here. `production > works with sourcemaps`
in `test/bake/dev/production.test.ts` hits its 5 s budget here and
passes in 5.07 s with a longer one, with the expected `oh no!` output.
The release binary passes all four. None of them involve entry point
resolution.

Other suites run on the debug build: `bundler_edgecase`,
`bundler_naming`, `bundler_html`, `cli`, `test/bake/dev-and-prod`, and
the three bundler files above in full.

1.3.x had the same drops and returned `success: true, outputs: []`. The
port added the bounds check, so the drop now aborts.

Silent drop on 1.4.0: `bun build --target=browser ./a.ts ./b.ts
--outdir=out` exits 0 and writes only `b.js`. Now it exits 1 with the
`./a.ts` error and writes nothing. The plugin test and the browser tests
pin this form too.

Long directory form of (c): a cwd of 3835 bytes plus a 500 byte relative
entry point (`top_level_dir + entry + 4 > MAX_PATH_BYTES`) aborts on
1.4.0 alone and is silently dropped next to a valid entry point. With
this branch both report `ModuleNotFound resolving "./eee...js" (entry
point)` from the CLI and from `Bun.build()`. The same entry point as a
4337 byte absolute path still aborts in `load_as_file`
(`src/resolver/resolver.rs:5888`), the resolver overflow #39626 is for.
A specifier longer than the buffer inside a package with a `browser`
field aborts in `check_browser_map` (`resolver.rs:5108`), which #37532
is for. Neither is an entry point drop.

Worker: the length guard also made `new Worker(longName)` fire its error
event with `BuildMessage: undefined`. The worker test pins the message.

Unchanged: `--external ./b.ts` or `external: ["*"]` on an entry point
still bundles it (entry points are exempt from external patterns,
#12734). An absolute entry point path is never looked up in the browser
map, so the bake and dev server callers of `resolve_entry_point`, which
pass absolute paths, cannot hit the new error. `node:path` under the
browser target still bundles its polyfill.

Backstop reachability: the only known route left is `bun build
--target=bun bun:wrap` in a release build (the specifier collides with
the runtime's `bun:wrap` map key, so `enqueue_entry_item` returns
`Ok(None)`). A debug build trips `assert_file_path_is_absolute` on that
input first, so the backstop has no debug-runnable test of its own.
Builtin specifiers as entry points under `--target bun`/`node` are a
separate, pre-existing problem and are reported separately.

Teardown: `enqueue_entry_points_common` schedules the runtime parse task
before any entry point is resolved. #38778 saw ASAN crashes in
`Worker::deinit_soon` on the CLI error path while the drivers still
returned before `wait_for_parse()`. With this branch under the ASAN
debug build, 30/30 runs of `bun build --target=browser ./a.ts` exit 1
with the message, and 20/20 runs with two bad entry points report both
errors.

`USE_SYSTEM_BUN=1` (1.4.0): the two new bun-build-api tests fail (the
child aborts), the plugin test fails (the child aborts), 4 of the 5 new
bundler_browser cases fail (the `--target=bun` control passes both ways
by design), the worker test fails with `BuildMessage: undefined`.

#38752 (`--no-bundle`) keeps its own message in the transform path,
which this change does not touch.

</details>

<!-- robobun:evidence:begin -->

---

**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/bun-build-api.test.ts test/bundler/bundler_plugin.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun
robobun force-pushed the farm/1cc9f314/onresolve-external-path-rewrite branch from 9b8e0ca to 03bcf96 Compare August 21, 2026 01:46
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs
Comment thread src/ast/import_record.rs Outdated
Comment thread src/ast/import_record.rs Outdated
Comment thread src/bundler/bundle_v2.rs

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

f14a1db addresses my last note — the map pass in patch_import_record_source_indices now skips IS_EXTERNAL, and the barrel-revisit test was extended so the external and the bundled virt module share the same path.text, which pins that case. I found no further issues this pass; given this reworks core bundler resolution (unsafe 'static erasure, a new ImportRecordFlags bit, PendingImport becoming an enum) and went through several real-bug iterations, a human look is still worthwhile.

What was reviewed:

  • on_resolve external arm: boxes parked on free_list before path is stored; SAFETY comment matches the four current arms.
  • apply_or_defer_pending_import and the pending-drain loop: ExternalPath applied before the map pass, map pass skips IS_EXTERNAL, so a plugin path cannot be looked up as a bundled module in either the immediate or queued case.
  • resolve_import_records: every in-loop external site now sets IS_EXTERNAL; the estimate and skip conditions include it.
  • 29264.test.ts still exercises NoMatch → run_resolver via the added "other" import.
Extended reasoning...

Overview

The PR makes onResolve plugins that return { path, external: true } actually rewrite the emitted specifier (fixes #2805) and makes { external: true } without path externalize the original specifier instead of falling through to native resolution. It touches src/bundler/bundle_v2.rs (the on_resolve Success arm, resolve_import_records, patch_import_record_source_indices, and a new apply_or_defer_pending_import helper), adds ImportRecordFlags::IS_EXTERNAL in src/ast/import_record.rs, tweaks runOnResolvePlugins in src/js/builtins/BundlerPlugin.ts, and adds five bundler-plugin tests plus adjusts the #29264 regression test.

Security risks

None identified. The change is bundler resolution bookkeeping; the only unsafe is a hoist of an existing 'static lifetime erasure whose invariant (bytes parked on free_list before path is stored, or dropped without storing) I traced across all four arms.

Level of scrutiny

High. This is core bundler resolution: it introduces a new flag bit consumed by resolve_import_records and the path-map pass, converts PendingImport from a struct to an enum, and rewrites how external plugin answers are applied. Four prior review passes each found a real bug (inert regression test, silent drop on the not-yet-populated race, barrel-revisit re-resolving the rewritten specifier, and the map-pass collision), all of which the author fixed. That history argues for a human sign-off even though this pass found nothing.

Other factors

  • f14a1db is exactly the fix my last comment asked for and also folds the deferred ExternalPath application into the single pending loop (so the after-map block is gone), and the ResolveExternalRewriteSurvivesBarrelRevisit test now shares "react-vendored" between the external and a bundled virt module — that is the collision case.
  • The IS_EXTERNAL bit is distinct from IS_EXTERNAL_WITHOUT_SIDE_EFFECTS (the author's rationale — a plugin external may have side effects — is sound); I checked that the linker's drop-if-unused path keys on the latter, not the former.
  • The PR body's Notes are candid about untested surface (the queued ExternalPath arm is not reachable today; schedule_barrel_deferred_imports' extra synchronous re-resolve predates this PR).

Jarred-Sumner pushed a commit that referenced this pull request Aug 21, 2026
### Problem
- A `sideEffects: false` barrel is resolved again, as a whole, when an
importer asks for a re-export without `source_index` or un-defers one of
its records. With no plugins, Bun 1.4.0 reports `Could not resolve:
"./missing.js"` twice for one broken re-export. An `onResolve` plugin
for an external re-export runs 2 to 3 times for one record.
- `schedule_barrel_deferred_imports`
(`src/bundler/barrel_imports.rs:852`, `:887` on main) reads a missing
`source_index` as "never resolved". An external, failed, or
plugin-pending record never gets one.
- `resolve_barrel_records` passes the whole list.
`resolve_import_records` (`src/bundler/bundle_v2.rs:6002`) skips only
records with a `source_index`.

### Fix
- The BFS resolves a barrel only when the current item un-deferred a
record in it, and passes those indices. The `barrels_to_resolve` map and
the final loop are gone.
- `ResolveImportRecordCtx` and `PatchImportRecordsCtx` get
`only_records`. Both loops skip every other record. It replaces
`force_save`, which covered the same case. No `ImportRecord` flag is
added.
- Correct because records are deferred before the barrel is resolved.
Every other record was resolved with the barrel, which also requested
its target. An un-deferred record is in the list and resolves as before.
- Verified: four new tests in `test/bundler/bundler_barrel.test.ts`,
each fails on main. Other suites in Notes.

### Background
- A barrel is a module of a `sideEffects: false` (or `optimizeImports`)
package whose exports are all re-exports. Before it is resolved,
`apply_barrel_optimization` marks the re-exports nobody asked for yet
`IS_UNUSED`: they are deferred.
- When a later file imports such a name,
`schedule_barrel_deferred_imports` clears the flag (un-defers) and calls
`resolve_barrel_records`: `resolve_import_records`, then
`patch_import_record_source_indices`, which writes the `source_index` of
each module found. The BFS follows it into the next barrel.
- Only a record that points at a module of the bundle gets a
`source_index`. An external record gets none, a failed one is disabled,
and an `onResolve` match is answered later by the JS thread.

<details><summary>Notes</summary>

Plugin-free repro. Bun 1.4.0 prints 2, this branch prints 1. `a.js` is
found through the barrel, so its request always arrives after the barrel
deferred `Broken` and `C`:

```sh
mkdir -p /tmp/b5/node_modules/lib && cd /tmp/b5
echo '{"name":"lib","main":"index.js","sideEffects":false}' > node_modules/lib/package.json
printf 'export { A } from "./a.js";\nexport { Broken } from "./missing.js";\nexport { C } from "./c.js";\n' > node_modules/lib/index.js
printf 'import { Broken, C } from "lib";\nexport const A = "a:" + Broken + C;\n' > node_modules/lib/a.js
echo 'export const C = "c";' > node_modules/lib/c.js
printf 'import { A } from "lib"; console.log(A);\n' > entry.js
bun -e 'const r = await Bun.build({ entrypoints: ["./entry.js"], throw: false }); console.log(r.logs.length)'
```

When the second importer is an ordinary file instead, the count depends
on whether that file is parsed before or after the barrel, so the same
build reports 1 or 2 errors from run to run.

Plugin repro: a barrel with `export { default as React } from "react"`
and an `onResolve` plugin that returns `{ path, external: true }`. The
plugin runs when the barrel is resolved, again from the barrel's own
`schedule_barrel_deferred_imports` call (the seeded request for `React`
finds no `source_index`), and again for each later importer. The plugin
case that needs the index list and not only the barrel change is a later
importer that un-defers a different record:
`barrel/ExternalReExportNotResolvedAgainOnUnDefer`.

The second pass could also fail. `export { x } from "bun:whatever"` in a
barrel, target `bun`, fails on main with `Could not resolve:
"whatever"`: the `bun:` arm strips the prefix, and the second pass
resolves the stripped name as a package. It builds with this change. No
test for it, the same pass is what the other tests pin.

Relation to #35053: its barrel test hits the same second pass and adds
`IS_EXTERNAL` so that pass skips its rewritten record. With this change
the pass does not reach that record. This PR takes no flag bit.

Dev server: import records there get no `source_index` from the normal
parse path, so before this change a request for a never-deferred record
also resolved the barrel again, and `force_save` wrote indices onto all
of its records. The work item that pass produced duplicated the request
the barrel's own call made in phase 1 (`barrel_imports.rs:462-510` and
`:550-586`) and was dropped by the `requested_exports` check.
Un-deferred records still get their indices written. The barrel tests in
`test/bake/dev/bundle.test.ts` pass.

Not changed: a plugin answer that arrives after the BFS does not request
anything from the module it resolves to. Same before and after.

The first revision of this PR used an
`ImportRecordFlags::RESOLVE_STARTED` bit instead of the index list. Bit
10 is the last free bit of the `u16`, and #35053 and #38461 both take
it, so the self-review asked for this shape. The barrel change alone
fixes two of the four tests. The index list is what fixes
`barrel/UnDeferReportsUnresolvableSiblingOnce` and
`barrel/ExternalReExportNotResolvedAgainOnUnDefer`.

Suites run on the debug build: `bundler_barrel`, `bundler_plugin`,
`bundler_plugin_chain`, `bundler_edgecase`, `bundler_npm`,
`bundler_browser`, `bundler_bun`, `bundler_cjs`, `bundler_splitting`,
`bundler_regressions`, `bundler_allow_unresolved`, `metafile`,
`bun-build-api`, `native-plugin`, `bake/dev/bundle`, `bake/dev/plugins`,
`bake/dev/esm`, `bake/dev/hot`, `regression/issue/29264`. With the
`src/` changes stashed, the new tests fail on the same debug build. The
plugin-free test failed 15 of 15 runs on Bun 1.4.0.

</details>
robobun added 11 commits August 22, 2026 00:24
When an onResolve plugin returns { path, external: true }, the returned
path was discarded and the emitted import kept the original specifier,
so plugins could not rewrite externalized imports (bare specifier to a
CDN URL, vendored path, etc.). BundleV2::on_resolve dropped result.path
and result.namespace in the external branch without writing them back
to the import record. The external branch now mirrors the native
resolver's external handling and rewrites the import record's path when
it differs from the original.

Also fixes the adjacent case where { external: true } with no path
produced a 'Could not resolve' error; runOnResolvePlugins now falls
back to the input specifier, matching esbuild.

Fixes #2805
Main (#39799) already logs "The entry point ... cannot be marked as
external" from on_resolve. Restructure so that arm comes first and the
import path rewrite is the plain else, instead of a nested check inside
the fallthrough.

Adapt the 29264 regression test: now that { external: true } without a
path no longer falls through to NoMatch, its catch-all filter must skip
the entry point or the build fails on the new entry point error.
Add a third bare import whose onResolve returns undefined so the
deferred NoMatch -> run_resolver path (and its unchecked
import_records index) is still reached while ./src supplies the
last_error trigger. Without this the adapted test would pass with
the error-path import_records store reverted.
…ueue

on_resolve wrote the plugin path straight into the importer's record and
dropped it when the records were not on the graph yet, while the sibling
source index arm queues for patch_import_record_source_indices in that
case. PendingImport is now an enum with both kinds of answer, and both
arms go through apply_or_defer_pending_import. Queued external paths are
applied after the path map pass so they are never looked up as modules.

Compare the plugin path against the dispatched specifier instead of the
record, which is the same bytes and does not need the record present.

Tests: the ESM case also covers export-from, export-star and import(),
and a same-path answer leaves the import unchanged.
… again

resolve_import_records runs over a barrel's whole record list each time a
consumer un-defers one of its records. It skipped records with a module
(source_index) but had no way to recognise a record already left external,
so every external was resolved again. That was harmless while the path was
unchanged, but after an onResolve plugin rewrote the path the second pass
matched no plugin filter and failed on the rewritten specifier:

    Could not resolve: "react-vendored"

Add ImportRecordFlags::IS_EXTERNAL, set it at each external arm of
resolve_import_records and in PendingImport::ExternalPath, and skip records
that carry it. Every plugin external answer now goes through ExternalPath,
so the same path case is flagged too and no longer re-dispatches.

The test pins the rewrite across a barrel revisit. It fails on released Bun
(no rewrite) and on the previous commit (the resolve error above).
patch_import_record_source_indices runs again on a barrel revisit, and
its path map pass matched a rewritten external whose path text equals a
bundled module's key, linking the import to that module:

    No matching export in "virt:react-vendored" for import "default"

Skip IS_EXTERNAL records there as resolve_import_records does. Queued
external paths no longer need a separate pass after the map, so both
kinds of pending answer are applied in one loop.

The barrel test now also re-exports a bundled virtual module that shares
the external's path text.
A barrel un-defer now resolves and patches only the un-deferred records
(only_records), so neither pass reaches a record an earlier answer left
external. The flag no longer guards anything, and bit 10 is the last free
ImportRecordFlags bit, which #39874 chose not to take.

This restores the external arm to its previous shape: an unchanged
specifier is left alone, and a queued ExternalPath is applied after the
path map pass so that a plugin path is never looked up as a module. The
barrel test stays. It now pins that #39874's index list covers the
rewritten record and the bundled module that shares its path text.
@robobun
robobun force-pushed the farm/1cc9f314/onresolve-external-path-rewrite branch from f14a1db to af0a3e4 Compare August 22, 2026 00:39
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs Outdated
… specifier

The path map is keyed by path text alone, and the map pass runs over
records whose plugin answer is still pending. When an earlier plugin
answer bundled a module at the same text as a later import's specifier,
that import got the module's source_index, and the external answer left
it there. Released Bun prints

    var __INVALID__REF__ = __commonJS(function(exports) {});
    var import_react = __toESM(, 1);

for that input. The debug build asserts in print_code_for_file_in_chunk_js.

PendingImport::ExternalPath::apply now resets source_index, and every
external answer goes through it, including one that returns the
specifier unchanged. The applied-after-the-map-pass order makes the
queued case come out the same.

Tests cover both answer shapes. Both fail on released Bun.

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

Reviewed again after 1b41dce — no new findings. Both prior nits are addressed: ExternalPath::apply now sets source_index = Index::INVALID, every external answer (including the unchanged-specifier case) routes through it, and plugin/ResolveExternalClearsModuleMatchedBySpecifier{WithPath,WithoutPath} pin the clear. The PR description now matches the shipped mechanism. Given this touches core bundler resolve/patch logic with an unsafe 'static erasure, a human look is still worthwhile.

What was reviewed: the simplified external arm in on_resolve (all externals now park on free_list and go through apply_or_defer_pending_import); the updated SAFETY comment against the four remaining arms — accurate; ExternalPath::apply clearing a stale map-pass source_index in both immediate and deferred paths; the { external: true } no-path fallback in runOnResolvePlugins and its interaction with the entry-point error and 29264.test.ts.

Extended reasoning...

Overview

1b41dce is the only commit since my last review. It addresses both nits I left on 2026-08-22: (1) PendingImport::ExternalPath::apply now writes import_record.source_index = Index::INVALID so a stale index written by the initial map pass (from the raw specifier) is cleared; (2) the separate unchanged-specifier eql_long arm is gone — every non-entry-point external answer now parks the boxes on free_list and goes through ExternalPath, so the clear applies uniformly. Two new tests (ResolveExternalClearsModuleMatchedBySpecifier{WithPath,WithoutPath}) force the map-pass collision via an onLoad gate on late.js that waits for the virt answer, and assert the output has no __INVALID__REF__ / __toESM(,. The PR description was rewritten to describe #39874's only_records filtering instead of the dropped IS_EXTERNAL flag.

Security risks

None. No untrusted-input parsing, auth, or network surface. The unsafe block is the pre-existing 'static erasure of plugin-owned Box<[u8]>; I re-checked the SAFETY comment against the four remaining disposition sites (!found_existingfree_list; external non-entry → free_list before the path is stored in PendingImport; found_existing and EntryPointBuilddrop with path never stored) and it is accurate after the simplification. The free_list.push calls precede apply_or_defer_pending_import, so the queued Path<'static> borrows bytes owned by free_list for the graph's lifetime.

Level of scrutiny

High. This is core bundler resolve/patch logic in bundle_v2.rs with unsafe lifetime erasure, and the PR history shows four separate real issues found and fixed across review rounds (silent race drop, barrel-revisit re-resolve, map-pass collision on rewritten path, stale source_index from raw specifier). Each was confirmed by the author with a failing test. That subtlety is why I'm deferring rather than approving despite a clean bug-hunter run.

Other factors

Test coverage is thorough for the feature (static/re-export/star/dynamic import, require, no-path, same-path, barrel revisit, specifier-keyed map collision × 2). The 29264.test.ts adjustment keeps the original NoMatch → run_resolver guard live via import "other", verified by the author against the original panic. All prior review threads are resolved by code changes; the two unresolved inline comments from my last run are addressed by 1b41dce even though no reply was posted.

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