Skip to content

bundler: honor onResolve { external: true } with no path, and clear a stale source_index on external answers - #35053

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

robobun wants to merge 2 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 { external: true } with no path is ignored. runOnResolvePlugins hits if (!path) continue (src/js/builtins/BundlerPlugin.ts:444 on main), the native resolver runs, and the build fails with Could not resolve: "lodash". esbuild keeps the import external under its original specifier.
  • An external onResolve answer can leave the import linked to a bundled module. The path map is keyed by path text, and its pass in patch_import_record_source_indices runs while a plugin answer is pending. If an earlier answer bundled a module whose path text equals this import's specifier, the import gets that module's source_index, and the external arm of on_resolve (src/bundler/bundle_v2.rs:5089 on main) never clears it. Released Bun prints var __INVALID__REF__ = __commonJS(function(exports) {}); and __toESM(, 1). The debug build asserts !ref_.is_empty() in print_code_for_file_in_chunk_js.

Fix

  • runOnResolvePlugins falls back to the input specifier when external is set and path is empty, and validates external before that check.
  • The external arm of on_resolve sets source_index = Index::INVALID after it stores the returned path. An external record is one with no source_index, so this is the same invariant the arm already relies on.
  • test/regression/issue/29264.test.ts relied on the old fall-through: its catch-all filter also answered for the entry point, which now reaches the entry point error. The plugin skips entry points, and a third bare import that the plugin declines keeps the NoMatch -> run_resolver path that Bun segfaults when there are both external and unresolved/missing non-external imports #29264 guards live. Verified by commenting out the error-path store: the test panics in run_resolver again.
  • Verified: test/bundler/bundler_plugin.test.ts, plugin/ResolveExternalWithoutPath and plugin/ResolveExternalClearsModuleMatchedBySpecifier{WithPath,WithoutPath} fail on main and pass here. Also bundler_barrel, bundler_plugin_chain, bundler_edgecase, bake/dev/plugins, 29264, 40606.

Background

  • onResolve answers run on the bundle thread as posted tasks, after on_parse_task_complete has stored the importer's records and run the path map pass over them. That pass gives every record whose path.text is a key in path_to_source_index_map a source_index. Bundled plugin answers insert their returned path text into that map.
  • The printed path fix that this PR started as landed in bundler: print an external onResolve import with the returned path #41230. What remains here is the no-path fallback and the source_index clear.
Notes

ResolveExternalClearsModuleMatchedBySpecifier*: entry.js imports virt, which the plugin bundles at path text react in namespace virt. late.js is loaded through onLoad only after that answer is queued, and imports react, which the plugin externalizes. So the map pass over late.js always sees react in the map. One variant answers { path: args.path, external: true }, the other { external: true }. Released Bun fails the first with the invalid output above and the second with Could not resolve.

ResolveExternalSamePath and ResolveExternalRewriteSurvivesBarrelRevisit pass on main. They pin cases that broke in earlier revisions of this PR: an answer that returns the specifier unchanged, and a rewritten external re-export in a sideEffects: false barrel that a later consumer un-defers, next to a bundled module that shares its path text. #39874 and #41230 cover both today.

History: this PR first carried the printed path fix, the entry point error, an IS_EXTERNAL flag, and a PendingImport enum. #39799 took the entry point error, #39874 made the flag unnecessary, #41230 took the path fix, and the enum only served a queue (resolve_tasks_waiting_for_import_source_index) that is not reached today, as #41230 notes. The branch was rebuilt on main with the two remaining fixes.

Earlier revisions linked issue 2805 by mistake. That issue is about define and loaders in Bun.build. No issue tracks either fix here.

In this container, bun-build-api.test.ts > bytecode: function record on an encoder page boundary and bytecode: repeated builds don't retain the generated code exceed their 5 s budget under the ASAN debug build with main's sources as well. They do not involve plugins.


no test proof · iteration 8 · 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. Rebuilt on main (289cb2a) after #41230 landed the printed path fix that this PR started as. Two commits remain:

  • { external: true } with no path externalizes the specifier instead of falling through to Could not resolve (BundlerPlugin.ts, plus the 29264.test.ts adaptation that change requires).
  • An external answer resets source_index, so an import whose specifier matched a bundled plugin module in the path map is not linked to that module (released Bun prints __INVALID__REF__ and __toESM(, 1) for that input). Two lines in bundle_v2.rs.

Build 112448 finished with 180 of 181 jobs passed. bundler_plugin.test.ts and 29264.test.ts pass on all 11 test platforms. The one red job is test/js/node/test/parallel/test-crypto-dh-leak.js on x64-asan, which fails on main as well and does not touch this diff. Reported separately. Everything else in the build passed on retry.

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:23 PM PT - Sep 7th, 2026

❌ @robobun, your commit 289cb2a has 1 failures in Build #112448 (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

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 920d0dea-6ab2-4ee0-838d-88ff181ae3ac

📥 Commits

Reviewing files that changed from the base of the PR and between fd10aef and b92936d.

📒 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

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


Walkthrough

Summary

The bundler now preserves external plugin paths, defers updates until importer records are available, and applies them after path-map processing. Plugin validation and regression tests cover ESM, dynamic imports, CommonJS, collisions, and missing paths.

Changes

External resolution handling

Layer / File(s) Summary
Plugin resolution result handling
src/js/builtins/BundlerPlugin.ts, src/bundler/bundle_v2.rs
Validates external, preserves plugin-provided paths, uses the original path when needed, and rejects external entry points.
Deferred pending-import application
src/bundler/bundle_v2.rs
PendingImport now supports source indexes and external paths. Centralized logic applies or defers updates, reschedules barrel imports, and applies external paths after path-map processing.
External resolution regression coverage
test/bundler/bundler_plugin.test.ts, test/regression/issue/29264.test.ts
Tests cover rewritten external paths, missing paths, collisions, deferred barrel imports, CommonJS output, entry points, and unresolved imports.

Suggested reviewers: jarred-sumner, dylan-conway

Merge Risk: ⚪ Minimal · up to b9293

Plugin-provided external paths are now preserved in emitted imports, while pathless external results retain the original specifier. Regression coverage includes the relevant import forms and collision cases, with no remaining merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses external import path behavior, but linked issue [#2805] primarily requires Build API define and loader support, removal of --jsx-production, format handling, and error tracking. The p… Link the PR to an issue that covers external onResolve path handling, or update the linked issue with this requirement and acceptance criteria. Otherwise, implement the requirements listed in [#2805].
Out of Scope Changes check ⚠️ Warning The implementation and tests are focused on external onResolve path handling. This scope is not explicitly covered by the linked issue [#2805], whose stated objectives concern define, loaders, --jsx-p… Link this PR to the correct issue for external import path rewriting, or provide explicit scope evidence connecting these bundler changes to [#2805].
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: preserving pathless external onResolve results and clearing stale source_index values.
Description check ✅ Passed The description explains the problem, implementation, verification steps, regression coverage, and relevant background. It does not use the template headings exactly, but it includes the required cont…
Full details: Linked Issues check

Explanation

The PR addresses external import path behavior, but linked issue [#2805] primarily requires Build API define and loader support, removal of --jsx-production, format handling, and error tracking. The provided issue does not explicitly include the external import behavior.

Full details: Out of Scope Changes check

Explanation

The implementation and tests are focused on external onResolve path handling. This scope is not explicitly covered by the linked issue [#2805], whose stated objectives concern define, loaders, --jsx-production, format, and Build API error tracking.

  • Fix all pre-merge checks with AI

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 Outdated
Comment thread src/ast/import_record.rs Outdated
Comment thread src/ast/import_record.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated

@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
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 Outdated
Comment thread src/bundler/bundle_v2.rs Outdated

@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_existing → free_list; external non-entry → free_list before the path is stored in PendingImport; found_existing and EntryPointBuild → drop 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.

Jarred-Sumner pushed a commit that referenced this pull request Aug 22, 2026
### Problem
- GitHub closes only the first reference after a keyword, so "Fixes #1,
#2" leaves #2 open. "Supersedes #3" links nothing, and no reference
closes a pull request.
- The last 1000 merged PRs name 274 such references. PR #32292 is open
although merged #36135 says "Supersedes #32292".

### Fix
- `.github/workflows/close-linked-issues.yml` runs on
`pull_request_target` `closed` (a merge into the default branch of
`oven-sh/bun`) and on `workflow_dispatch` with a PR number and
`dry_run`. Everything is inline in one `actions/github-script` step,
with no checkout.
- Each open target is closed as `completed` with the comment "Closed as
completed by #N." or "Superseded by #N.". Closed or missing targets, the
PR itself and other repositories are skipped.
- The parser has no regex. A closing keyword (close, fix, resolve,
supersede, replace, any tense) must lead the reference, alone or in a
list. A negated, hedged or noun keyword, or one whose subject is another
reference, does not count ("may fix", "the rm fix #1", "#100 supersedes
#1").
- Verified: `test/internal/close-linked-issues.test.ts` (333 cases) runs
the YAML's script against fake `github`, `context` and `core`. Also the
1000-PR parse (Notes).

### Background
- GitHub's own keywords are close, fix and resolve (-s, -ed). Each links
one reference, and only a merge into the default branch closes it.
- `pull_request_target` runs in the base repository with a write token,
also for fork PRs. That is safe only when no PR-controlled code runs.
Here the description is the only PR input, parsed as text.

<details><summary>Notes</summary>

A close through the API does not create the "closed this in #N" timeline
link that GitHub makes for its own closes. The comment carries the PR
number instead.

How the parser was calibrated. I pulled the descriptions of the last
1000 merged PRs and listed every line with a keyword next to a
reference. The keyword families, list shapes and reference forms in the
script are the ones that appear there. A reference is `#1`,
`owner/repo#1`, an issue or pull URL (bare or in `<>`), or a markdown
link. Four lines would have been wrong with a plain
keyword-then-reference rule, and each led to a rule:

- "the open `rm` fix #37521" (#38379): "fix" as a noun. Base forms (fix,
close, resolve, supersede, replace) count only at the start of a
sentence or line, or after will, should, does, and, and a few similar
words. "to" is not one of them ("unable to fix #1", "how to fix #1").
- "May also fix #12318 / #10046, untested" (#38242): hedged. may, might,
could, would, partially and the negations disqualify the keyword,
looking past adverbs such as "also".
- "Supersedes the closed #26040" (#36289) and "a comment on closed
#35351" (#35365): "closed" as an adjective. A determiner or preposition
before the keyword disqualifies it.
- "supersedes #33130's optimisation" (#35843): a number that continues
into a word is not a reference.

Review added: a reference before the keyword is the subject ("#100
supersedes #1"), also through "which" or "that" ("reverts #100, which
fixed #1") and across a removed span ("#100 ~~also~~ fixes #1"). A hedge
two words before the keyword disqualifies it ("hopefully this fixes #1",
"could this fix #1?"). A clause that starts with if, when, once, until
or unless is not a statement. The tokenizer keeps a line break as a
token so that "Fixes #1" on one line and "Fixes #2" on the next stay two
statements. Code spans, fences, indented code, blockquotes, HTML
comments and strikethrough are skipped. The block stripping follows
CommonMark for fences (also inside a blockquote), indented code,
blockquotes with lazy continuation, setext underlines and HTML comments,
and GFM for `~~` flanking.

Result over the 1000 descriptions: 274 distinct references in 135 PRs. I
checked the current state of all of them through GraphQL. All but one
are closed (202 issues completed, 5 duplicates, 66 pull requests). The
one open target is PR #32292, superseded by merged #36135. No open
target is a false positive. Every review change kept this result.

Patterns that are deliberately not handled: a bulleted list under
"Closes:" on its own line (not seen in the sample), references separated
by whitespace only ("#1 #2"), "fix for #1", and GH-1 style references. A
`?` after the list is not treated as a question. The block parser tracks
no list containers, so a second paragraph of a list item indented by
four spaces is read as an indented code block and skipped. A removed
span or inline comment reads as one word, so "Fixes <!-- n --> #1" finds
nothing.

The test suite covers: the phrases above, stopping at the right place in
real sentences, CRLF descriptions, URLs with fragments or a `/files`
suffix, case-insensitive `Owner/Repo#1`, the fake API where a lookup, an
update or a comment fails, the `dry_run` input, an invalid `pr_number`
input, an unmerged PR, a PR merged into a non-default branch, the merge
event body against a later edit, and a description with no closing
statement.

The first revision of this PR checked out the repository and ran
`scripts/close-linked-issues.ts`. Jarred asked for no checkout and no
script file, so the script moved inline into the workflow and the test
now reads it out of the YAML.
</details>

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

---

**[stamp-90s]** gate passed · iteration 9 · 2 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/internal/close-linked-issues.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/close-linked-issues.test.ts
bun test v1.4.1 (4448a2e)

test/internal/close-linked-issues.test.ts:
(pass) finds "Fixes #39852" [176.21ms]
(pass) finds "Closes #31772. Fixes #31771." [22.28ms]
(pass) finds "- Fixes #39930" [12.28ms]
(pass) finds "Fixes: #30429" [10.46ms]
(pass) finds "FIXES #1" [7.86ms]
(pass) finds "(Fixes #1)" [8.97ms]
(pass) finds "**Fixes #1**" [10.20ms]
(pass) finds "__Fixes #1__" [9.83ms]
(pass) finds "_Fixes #1_" [11.25ms]
(pass) finds "Fixes **#1**" [9.72ms]
(pass) finds "**Fixes** #1" [7.13ms]
(pass) finds "**Fixes:** #1" [8.11ms]
(pass) finds "Fixes #1 and **#2**" [11.47ms]
(pass) finds "Fixes **#1**, **#2**" [9.13ms]
(pass) finds "## Why (fixes #13771, closes #30543)" [16.08ms]
(pass) finds "Closes #11418" [19.46ms]
(pass) finds "Resolves #1. Resolved #2. Resolve #3." [12.09ms]
(pass) finds "Fixes #34055, #30327, #24394, #20816, #32403, #11898, #10056." [17.11ms]
(pass) finds "Fixes #18192 and #31675 as a consequence" [10.45ms]
(pass) finds "Fixes #1, #2, and #3" [10.96ms]
(pass) finds "Fixes #1 & #2" [7.63ms]
(pass) finds "Closes #33280,  Closes #32864 and Closes #29696 (the timer in #32949 is orthogonal)" [20.29ms]
(pass) finds "Closes #33182 and #32947 on top of current main (which already has #36304 for catalogs)." [16.12ms]
(pass) finds "Fixes #1,\n#2" [7.76ms]
(pass) finds "Fixes #1, #2,\nand #3" [9.27ms]
(pass) finds "Fixes #1\nand #2" [8.57ms]
(pass) finds "Fixes #1\n& #2" [6.80ms]
(pass) finds "Fixes #1 and\n#2" [7.31ms]
(pass) finds "Supersedes #39908 (same change, moved from a fork branch)" [13.21ms]
(pass) finds "Supersedes #38778 and #38391. Carries the entry point arm of #35053." [14.43ms]
(pass) finds "Supersedes #39193 and keeps its three tests." [11.48ms]
(pass) finds "This supersedes #33306 and #32803. Their tests are kept here." [13.73ms]
(pass) finds "- This replaces #33793. Its 
... (truncated)
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
.github/workflows/close-linked-issues.yml | 950 ++++++++++++++++++++++++++++++
 test/internal/close-linked-issues.test.ts | 598 +++++++++++++++++++
 2 files changed, 1548 insertions(+)
```

</details>

**gate history** · 29 passed · 0 rejected · iteration 9

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
.github/workflows/close-linked-issues.yml      6     12      0
test/internal/close-linked-issues.test.ts      3     11      0
```

</details>

<!-- robobun:evidence:end -->
@robobun
robobun force-pushed the farm/1cc9f314/onresolve-external-path-rewrite branch from 1b41dce to deab78a Compare August 28, 2026 06:43

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

Code review found no issues

No high-confidence issues detected in this change.

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

Code review found no issues

No high-confidence issues detected in this change.

dylan-conway added a commit that referenced this pull request Sep 3, 2026
…41230)

### Problem
- An `onResolve` result of `{ path, external: true }` keeps the import
external, but the output prints the original specifier. For `import { v
} from "./b.js"` and `path: "/somewhere/else/b.js"`, `Bun.build` emits
`require("./b.js")` (cjs) or `from "./b.js"` (esm). esbuild emits the
returned path.
- The external arm of `BundleV2::on_resolve`
(`src/bundler/bundle_v2.rs:5035` on main) drops `result.path`. It never
updates the import record, and the printer emits the record's path.

### Fix
- The external arm stores the returned path on the import record. The
path bytes move into `free_list`, like the bytes of a bundled plugin
path.
- The path construction moves above the `external` check, so both arms
share one lifetime erasure. The entry point error is unchanged.
- Correct because the record still has no `source_index`, so it stays
external. Only the printed text changes.
- Verified: `test/bundler/bundler_plugin.test.ts`, four new
`plugin/ResolveExternalRewrites*` cases and two
`plugin/RewriteExternal*` cases from esbuild. All six fail on 1.4.1.
Also ran the barrel, plugin chain and dev server plugin suites.

### Background
- An import record is one `import`, `require` or `export from` in a
parsed file. A record with no `source_index` is external, and the
printer emits its `path.text`.
- An `onResolve` answer runs on the bundle thread as a posted task.
`on_parse_task_complete` stores the importer's records before that task
can run.
- `free_list` holds plugin-owned byte buffers that the graph points
into. `deinit_without_freeing_arena` frees them.

<details><summary>Notes</summary>

- This is the path fix from #35053, alone. #35053 also externalizes `{
external: true }` with no path, clears a `source_index` that the path
map set from the specifier, and extends the pending import queue. This
PR does none of those.
- The new arm indexes the importer's records directly. `run_resolver`
does the same for a plugin answer with no match.
`resolve_tasks_waiting_for_import_source_index` is not reached today: it
dates from 994c715, when resolution ran on worker threads. This PR
leaves it alone.
- The record takes the namespace of the result, as a bundled plugin path
does. The bundler never prints a namespace for an external record, so
the output does not depend on it.
- Metafile: the import now reports `"path": "/somewhere/else/b.js"` and
`"original": "./b.js"`.
- The `plugin/RewriteExternal*` cases are `rewriteExternalWithNamespace`
and `rewriteExternalWithoutNamespace` from esbuild's
[`scripts/plugin-tests.js`](https://github.com/evanw/esbuild/blob/f6058f8364fe7ab91ca57a83e02577ed74c9cae4/scripts/plugin-tests.js#L680-L760).
The third case there, `rewriteExternalWithFileNamespace`, is not
translated. With an explicit `namespace: "file"`, esbuild writes the
path relative to the output directory. Bun's plugin runner sets the
namespace to `"file"` when the plugin omits it, so the bundler cannot
tell the two cases apart. esbuild's Go bundler tests have no external
`onResolve` case.
- Suites run on the debug build: `bundler_plugin`,
`bundler_plugin_chain`, `bundler_barrel`, `regression/issue/29264`,
`regression/issue/40606`,
`regression/issue/bundler-plugin-onresolve-entrypoint`,
`bake/dev/plugins`.

</details>

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

---

**no test proof** · iteration 2 · 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:evidence:end -->

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
…th no path

`{ external: true }` with no `path` hit `if (!path) continue` in
runOnResolvePlugins, so the answer was ignored and the native resolver
ran, usually failing with "Could not resolve". esbuild keeps the import
external under its original specifier. Fall back to the input path when
`external` is set, and validate `external` before that check.

test/regression/issue/29264.test.ts relied on the old fall-through: its
catch-all filter also answered for the entry point, which now reaches the
"cannot be marked as external" error. The plugin skips entry points, and
a third bare import that the plugin declines keeps the NoMatch ->
run_resolver path that #29264 guards exercised.
…the specifier

The path map is keyed by path text alone, and the map pass in
patch_import_record_source_indices runs over records whose plugin answer
is still pending. When an earlier plugin answer bundled a module whose
path text equals a later import's specifier, that import picked up the
module's source_index, and the external answer left it in place. The
linker then treated the import as that module. Released Bun prints

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

and the debug build asserts `!ref_.is_empty()` in
print_code_for_file_in_chunk_js.

The external arm of on_resolve now resets source_index when it stores the
returned path. Two tests pin the interleaving with an onLoad gate, one per
answer shape. Two more cover cases found while reviewing earlier revisions
of this change and pass on main: an answer that returns the specifier
unchanged, and a rewritten external re-export in a sideEffects:false
barrel that a later consumer un-defers next to a bundled module sharing
its path text.
@robobun
robobun force-pushed the farm/1cc9f314/onresolve-external-path-rewrite branch from 90a9587 to 289cb2a Compare September 8, 2026 01:47
@robobun robobun changed the title bundler: use onResolve-returned path for external imports bundler: honor onResolve { external: true } with no path, and clear a stale source_index on external answers Sep 8, 2026

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants