Skip to content

bundler: resolve a barrel's import records once - #39874

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/ac2a9dbe/barrel-resolve-once
Aug 21, 2026
Merged

bundler: resolve a barrel's import records once#39874
Jarred-Sumner merged 3 commits into
mainfrom
farm/ac2a9dbe/barrel-resolve-once

Conversation

@robobun

@robobun robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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

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:

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.

schedule_barrel_deferred_imports resolved a barrel again whenever the
requested record had no source_index. An external record, a record whose
resolution failed, and a record waiting for an onResolve plugin never get
one, so every importer of such a re-export ran the resolver (and the
onResolve plugins) over the barrel again. A pass that did un-defer a
record also resolved those records again, because resolve_import_records
only skipped records with a source_index.

Resolve a barrel only when the BFS item un-deferred a record in it, and
mark every record that resolve_import_records handles with
RESOLVE_STARTED so a later pass skips it. The barrels_to_resolve map and
the trailing loop are gone: each un-deferral resolves inline.
@coderabbitai

coderabbitai Bot commented Aug 21, 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: Pro

Run ID: 31af4914-f4e2-40ec-9218-ef82e802444d

📥 Commits

Reviewing files that changed from the base of the PR and between 72ec6e2 and 822248b.

📒 Files selected for processing (3)
  • src/bundler/barrel_imports.rs
  • src/bundler/bundle_v2.rs
  • test/bundler/bundler_barrel.test.ts

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


Walkthrough

Changes

Barrel un-deferral now resolves only newly selected import records and patches their source indices immediately. Standard parsing keeps full-record resolution. Regression tests cover duplicate diagnostics, external resolution reuse, deferred records, and fallback behavior.

Barrel resolution

Layer / File(s) Summary
Selective import-record resolution
src/bundler/bundle_v2.rs
Resolution and source-index patching now accept optional sorted record selections. Normal parsing passes no selection.
Targeted barrel propagation
src/bundler/barrel_imports.rs
Barrel traversal resolves newly un-deferred records immediately and reads refreshed source indices during propagation.
Barrel resolution regression coverage
test/bundler/bundler_barrel.test.ts
Tests cover duplicate diagnostics, single external plugin resolution, deferred-record un-deferral, and resolver fallback.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses barrel-resolution behavior, but linked issue #39 requires Node.js-compatible build output and lists unrelated blockers. Link this PR to a barrel-resolution issue, or extend the changes to address the requirements in issue #39.
Out of Scope Changes check ⚠️ Warning The barrel-resolution changes are outside the scope of linked issue #39, which concerns generating build output that runs on Node.js. Update the linked issue to match the barrel-resolution objective or remove these changes from the issue #39 scope.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: resolving a barrel's import records only once.
Description check ✅ Passed The description explains the problem, fix, implementation details, regressions, and verification suites, although it does not use the template headings.

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

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:20 AM PT - Aug 21st, 2026

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


🧪   To try this PR locally:

bunx bun-pr 39874

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

bun-39874 --bun

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on Bun 1.4.0 in two ways:

  • No plugins: a sideEffects: false barrel with one broken re-export reports Could not resolve: "./missing.js" twice when a submodule un-defers it together with a second name (15 of 15 runs). The script is in the PR description under Notes.
  • Plugin: an onResolve plugin that marks a barrel's react re-export external ran 2 to 3 times for the one record.

Current shape: the barrel BFS resolves only the records it just un-deferred and passes their indices to the resolve and patch passes (only_records). The first revision used an ImportRecord flag bit instead. It was replaced because bit 10 is the last free bit and two open PRs take it.

The four new tests in test/bundler/bundler_barrel.test.ts fail on a debug build of main and pass with this branch.

CI on e94e9a0: every lane is green except Windows 2019 x64, where test/js/bun/http/bun-server.test.ts fails. That test fails on main as well and is tracked separately. It does not touch the bundler.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. The RESOLVE_STARTED idempotency flag and the switch from "resolve on invalid source_index" to "resolve only on un-deferral" both check out, and the three new tests pin exact call/error counts. Because this reworks control flow in the bundler's resolution path — including a dev-server behavior change that leans on a non-local dedup argument — a human look would still be worthwhile.

Checked: all three un_defer_record sites now resolve immediately, so the removed trailing loop is dead; bundleErrors in expectBundled.ts rejects unexpected errors, so the third test does pin "reported once"; the export-star and named-resolution paths previously read .source_index directly (not via record_target), so the dev-server read path there is unchanged.

Extended reasoning...

Overview

Adds ImportRecordFlags::RESOLVE_STARTED (bit 10) and sets it in resolve_import_records so a record is never resolved twice. In barrel_imports.rs, drops the barrels_to_resolve map and its trailing loop; the BFS now calls resolve_barrel_records only when un_defer_record actually flipped a record. Three new tests in bundler_barrel.test.ts cover: external re-export resolved once across multiple importers, external record skipped when a later un-deferral re-runs the pass, and an unresolvable re-export reported exactly once.

Security risks

None. This is bundler bookkeeping — no untrusted input parsing, no auth/crypto, no filesystem writes outside the existing resolution machinery.

Level of scrutiny

High. resolve_import_records is on the critical path for every bundled module, and the barrel BFS has ordering-sensitive interactions with the dev server (records there carry no source_index after normal resolution). The PR description reasons through the dev-server case — a request for a never-deferred record used to force-resolve and produce a duplicate work item that dedup dropped, so skipping it is equivalent — but that argument depends on phase-1 seeding behavior in the barrel's own parse, which a maintainer familiar with bake/ should confirm. The author reports bake/dev/bundle and bake/dev/plugins pass.

Other factors

The mechanics look correct: every site that returns true from un_defer_record now calls resolve_barrel_records immediately, so the removed trailing loop was redundant; the new flag makes resolve_import_records idempotent for external / failed / plugin-pending records that legitimately lack a source_index. Tests use established harness patterns (the plugins(builder) callback form already appears in barrel/ResolvePlugin), and expectBundled's bundleErrors handling fails on unexpected errors, so the "reported once" test is not vacuous. No CODEOWNERS cover these paths. The PR notes a bit-10 collision with #35053's proposed IS_EXTERNAL; that's a coordination note for whichever lands second, not a defect here.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

For the dev server point, the code that carries the argument is all in schedule_barrel_deferred_imports, in the barrel's own call (the call made when the barrel itself finishes parsing):

  • Phase 1 (src/bundler/barrel_imports.rs:462-510) walks the barrel's own named_imports. For each record that was not deferred it looks the target up in the path map when source_index is unset, records the alias in requested_exports[target], and persists it with persist_barrel_export.
  • Phase 2 (:550-586) pushes the same (target, alias) pairs as initial work items, so a target that is already parsed un-defers at once. A target parsed later reads requested_exports in apply_barrel_optimization.

The removed pass ran in a later importer's call. Its only output was a work item for the same (target, alias) pair, which is a propagated item and is dropped by the requested_exports check at :692-722. Un-deferred records take the same path as before: resolve_barrel_records (:322) still patches with force_save.

The dev server tests that cover barrel chains (test/bake/dev/bundle.test.ts, the #27521 test and the namespace re-export cycle test) pass on this branch.

Replace the RESOLVE_STARTED import record flag with an index list. The
BFS knows which records it just un-deferred, so resolve_barrel_records
hands exactly those to resolve_import_records and to
patch_import_record_source_indices (only_records on both contexts, which
also replaces force_save). The barrel's other records are not resolved
or patched again, and no ImportRecord flag bit is used.

Add a plugin-free test: un-deferring a second record of a barrel used to
report its unresolvable sibling a second time.
Comment thread src/bundler/barrel_imports.rs Outdated
Comment thread src/bundler/barrel_imports.rs Outdated
Comment thread src/bundler/barrel_imports.rs
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/barrel_imports.rs
Comment thread src/bundler/barrel_imports.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
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.

I reviewed this PR and didn't find any bugs. Because it reworks the barrel BFS resolve path — replacing the barrels_to_resolve drain and the !rec_si.is_valid() inline-resolve fallback with per-index only_records calls, and leaning on the dev-server invariant that the barrel's own schedule_barrel_deferred_imports call already covered every never-deferred record — a human familiar with the barrel optimization should also take a look.

What was reviewed:

  • Traced that every un_defer_record → true site now resolves exactly that index; the removed drain loop and the !rec_si.is_valid() fallback are subsumed.
  • Checked the dev-server argument for the removed star-record fallback: export * targets are seeded to RequestedExports::All via record_target() in the barrel's own call, so a later BFS push would have been dropped by the dedup check anyway.
  • Verified only_records is sorted at every call site (single-element or the ascending 0..len loop) so binary_search is sound; force_save's only caller now passes Some, so save_import_record_source_index is unchanged.
  • Confirmed bundleErrors in expectBundled does exact-count matching, so the two "reported once" tests fail on a duplicate error.
Extended reasoning...

Overview

The PR changes how the barrel BFS in src/bundler/barrel_imports.rs resolves un-deferred import records. Previously, un-deferring a record put the barrel into a barrels_to_resolve set; when the BFS needed a source_index and it was invalid, it re-ran resolve_import_records on the barrel's entire record list (skipping only records that already had a source_index). External, failed, and plugin-pending records never get one, so they were re-resolved — duplicate error diagnostics, and onResolve plugins fired 2–3× for one record.

The fix threads an only_records: Option<&[u32]> through ResolveImportRecordCtx / PatchImportRecordsCtx (src/bundler/bundle_v2.rs) and, at each of the three un-defer sites in the BFS, immediately calls resolve_barrel_records with just the index(es) it un-deferred. The barrels_to_resolve map and its drain loop are deleted, and force_save on the patch ctx is replaced by only_records.is_some() (same single caller). Four new itBundled tests pin the exactly-once behavior for both the plugin-free and plugin cases.

Security risks

None. This is bundler resolution ordering; no untrusted input parsing, auth, crypto, or filesystem-boundary logic is touched.

Level of scrutiny

Medium-high. The barrel BFS is one of the more parse-order-sensitive parts of the bundler, and the correctness of removing the !rec_si.is_valid() fallback on the dev-server path rests on a cross-function invariant: the barrel's own call to schedule_barrel_deferred_imports already seeded its export * targets via record_target() (which has the path-map fallback), so the propagated item a later importer would have pushed hits RequestedExports::All in the dedup check and is dropped. I traced this and it holds, but it is exactly the kind of non-local reasoning a maintainer who owns this code should confirm. The item_is_star propagation arm already uses record_target() and is unchanged in that respect.

Other factors

  • The only_records slice is sorted at every call site (either a single-element literal or built by for idx in 0..len), matching the binary_search in only_selected_record and the two debug_assert!(is_sorted) guards.
  • bundleErrors in expectBundled.ts matches by exact count (unexpected extras fail, leftover expectations fail), so the two "reported once" tests genuinely assert one diagnostic, not at-least-one.
  • The plugin tests reset the resolved array in plugins() and assert toEqual(["react"]), so they pin exactly-once and won't leak state across the harness's build invocations.
  • All the comment-cop bot threads and my earlier note about the stale description are resolved. CI was still building at the last status update.
  • The PR author ran the barrel, plugin, edgecase, splitting, regressions, and bake/dev bundle suites on a debug build; the four new tests fail on main and on the debug build with the src/ changes stashed.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

For a human reviewer, the places to check on the current head (e94e9a0). The earlier pointer comment has line numbers from the first revision.

  • The three un-defer sites, each followed by a resolve of exactly the records it un-deferred: src/bundler/barrel_imports.rs:732 (namespace request, collects the indices), :834 (name that comes from an export *), :854 (named re-export). resolve_barrel_records is at :320.
  • The filter: only_selected_record at src/bundler/bundle_v2.rs:5954, used by the resolve loop at :6013 and by the path map loop at :6840.
  • The argument for the removed re-resolve of never-deferred records: the barrel's own call records what it needs from its targets in phase 1 (barrel_imports.rs:458-506) and pushes the same pairs as initial work items in phase 2 (:546-582). A later importer's request for such a record produced only a propagated item, which the check at :688-718 drops. This holds in dev server mode too, where phase 1 uses the path map because the records carry no source_index.

CI on this head: 178 of 179 jobs green. The one failure is test/js/bun/http/bun-server.test.ts on Windows 2019, which fails on main as well.

@Jarred-Sumner
Jarred-Sumner merged commit 95d406c into main Aug 21, 2026
9 of 10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/ac2a9dbe/barrel-resolve-once branch August 21, 2026 22:11
robobun added a commit that referenced this pull request Aug 22, 2026
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.
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