Skip to content

bundler: resume the shared resolve pass for imports that went to onResolve plugins - #45408

Open
robobun wants to merge 3 commits into
mainfrom
robobun/a45d98d6/plugin-answers-resume-shared-pass
Open

robobun wants to merge 3 commits into
mainfrom
robobun/a45d98d6/plugin-answers-resume-shared-pass

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19951, fixes #22838

Problem

  • An import or entry point that matches an onResolve filter builds differently, also when every callback returns nothing. Example: Could not resolve: "react/jsx-dev-runtime".
  • run_resolver (src/bundler/bundle_v2.rs:2678 on main) resolves a declined import as a hand copy of resolve_import_records. on_resolve makes the module of an answered import. Both miss rules of the shared pass, such as the tsconfig JSX settings.

Fix

  • on_resolve sends a declined or answered import back through resolve_import_records. A declined Bun.build entry point takes the code of an unmatched one. run_resolver is gone.
  • An import record at the plugins has the flag AT_ON_RESOLVE_PLUGINS: only their answer gives it a module. A file that a callback names gets no tsconfig or package.json settings, as in esbuild.
  • Verified: 47 new tests in five files fail on main. One builds 26 projects with and without a declining plugin.
  • Self-reviewed: 18 concerns raised, 10 addressed. Notes name the rest.

Background

Downsides

  • Builds without plugins: +16 instructions per import record, -101 per parsed file. Release .text is 2,304 bytes smaller.
  • Plugin users see other error text, output hashes and output names.
  • Not fixed: a failing build can list more errors with a declining plugin.
Notes

Covers these PRs

Not taken from #43190: settings for a file that an onResolve callback names

  • esbuild gives such a file no tsconfig.json or package.json data. esbuild has sideEffects in the onResolve result and build.resolve(). Bun has neither.
  • To ask the resolver about the named path lists its directory at that time. A plugin that writes another file into that directory later then gets Could not resolve: "./c.js". The test onResolve can write a file after it named another file of the directory covers this.
  • This needs a maintainer's decision. The test file that onResolve names is compiled with the options of the build pins the behaviour of today.

Visible changes

  1. Declined import: an error has the text of the shared pass.
  2. The hashed name of an importer changes when a declined or answered import is an asset. The asset is now booked on the importer.
  3. Declined Bun.build entry point: it is found in files, an HTML entry of a server build is a browser page, the output has the name it has without the plugin, and the outputs keep the order of entrypoints. It is still an output when an import of another entry point made its module first (main drops it with no error).
  4. Answered import: with { type } decides the loader, an HTML file in a server build is a manifest, and an HTML url asset (manifest.json) is copied.
  5. require.resolve() of a declined or answered import prints what a build without plugins prints.
  6. Dev server: a declined import of an HTML file is the error Browser builds cannot import HTML files., as without plugins (main: no error). When an onLoad plugin also matches the file, the import waits for onLoad. It is a module if onLoad returns one, else the same error. An import that no onResolve plugin saw does not wait. That is unchanged.
  7. bun build --app: a declined import of a stylesheet is still registered in every graph, as run_resolver did. A declined import of another asset is no longer registered there. An import that no plugin saw is unchanged.
  8. The barrel pass after onResolve answers runs once per importer (second commit).
  9. An external answer is external also when the specifier is the absolute path of a module of the build. 1.4.3 bundles that module.
  10. A redirect file (module.exports = require(x)) whose require() went to the plugins: a file that imports it later gets the module of the answer. 1.4.3 gives it the module that the specifier names.

Still different from a build without plugins

Self-review: the concerns that stay

  • The contract "a declining plugin changes nothing" is not complete: see the list above. Each item is the same on main and needs its own change.
  • html_import_after_on_load makes a graph row by hand in on_load. The row of the page can only be made after onLoad answered.
  • Two of the three answer wins variants pass on 1.4.3. They guard the late pass: each failed on an earlier state of this branch.
  • The export * bookkeeping in on_parse_task_complete and the barrel code of the dev server still look up the path of a record that is at the plugins. The only effect: the barrel optimization defers less for the module with that path.
  • A barrel-deferred record whose specifier is the absolute path of a module of the build gets that module from the patch. No plugin is involved, and main does the same.
  • A declined import that the resolver marks external keeps the specifier as written, and the patch can match it. A build without plugins does the same.
  • The check that an answered record has no module yet is a debug_assert!. In a release build the flag alone keeps that true.

Other design considered: an extracted per-record function that resolve_import_records and on_resolve both call. It adds one call per record to every build.

After the first review round

  • An onResolve answer now wins when the specifier as written already names a module of the build (an import by absolute path). The first patch of the importer had set that module, and the late pass skipped the record. Test: onResolve answer wins over a module that the specifier names as written.
  • Dev server, an HTML import that waits for onLoad: it does not take the cached-file rule, a later import of the same file gets the error too, and the parse task that is not scheduled is dropped.
  • A server build marks the import as html_manifest also when the manifest is made after onLoad.
  • The stylesheet rule of bun build --app keeps a row that another graph already has.

After the second review round

  • The late pass does not patch the answered record when the pass gave it a module that exists. The path of that record is still the specifier as written, and it can name another module. Test: onResolve answer wins over a module that the specifier names as written (a module that the build has already).
  • The flag ImportRecordFlags::AT_ON_RESOLVE_PLUGINS replaces the reset of source_index before the late pass. patch_import_record_source_indices leaves a record with the flag alone, so no code sees a module on a record that is at the plugins. Visible changes 9 and 10 come from this. Tests: the (an external module) variant and onResolve answer for the require() of a redirect file.
  • New test in bundler_files.test.ts: an answer that names an in-memory HTML page gives a server build the manifest of the page.
  • This round is the third commit.

Other open PRs on this code: #44493 and #39488 touch on_resolve or call run_resolver, so they conflict. Bit of AT_ON_RESOLVE_PLUGINS in ImportRecordFlags: 12 open PRs add a flag at 1 << 19 (2026-10-26), and one of them lands first and keeps that bit. This PR takes 1 << 24 at its next push. Check first that main and no other open PR use that bit by then. Two different flags with one bit compile with no error and no warning, and each then reads as the other.

Measurements without plugins (release builds of main 179664e and this branch 4115e85, linux x64)

  • The instructions that each function executes itself, counted with gdb single steps. Two projects of 201 files: one has 398 import records, the other 1,180. The two totals give the cost per record and the cost per file.
Function Per import record Per parsed file
resolve_import_records 177.8 (main 159.8) 231.3 (main 238.4)
patch_import_record_source_indices 10.0 (main 12.0) 53.8 (main 73.7)
process_resolve_queue -0.5 (main -0.5) 258.9 (main 259.9)
on_parse_task_complete -0.1 (main -0.1) 444.8 (main 517.8)
Sum of the differences +16.0 -101.0
  • The test of the new flag is 2 instructions (testb, jne) for each record that the patch matches to a module. That is one record per module of the build. The table includes it.
  • Binary: .text 82,162,313 B (main 82,164,617). on_resolve 12,476 B to 5,517 B. enqueue_parse_task 3,152 B to gone. resolve_import_records 14,340 B to 12,946 B. patch_import_record_source_indices 1,613 B to 701 B. process_resolve_queue 2,638 B to 2,679 B. resolve_barrel_records 1,139 B to resolve_records_of_parsed_file 1,934 B. on_load 3,140 B to 3,567 B. New symbols: html_import_after_on_load 2,911 B, html_import_manifest_ast 3,582 B (split out of generate_server_html_module), enqueue_unmatched_entry_point 755 B.

Measurements with plugins (release builds of main 116dd64 and the first two commits before the rebase, not repeated after it)

  • Struct sizes: ParseTask 408 B and MiniImportRecord 72 B (both unchanged), ResolveImportRecordCtx 64 B (main 48), PatchImportRecordsCtx 40 B (main 48).
  • Allocator calls per onResolve answer (40 imports): declined 7.72 allocations and 5.67 frees (main 11.10 and 8.05), answered 7.88 and 5.80 (main 10.22 and 8.18).
  • One file with 8,000 plugin-routed imports, build time in ms, three interleaved rounds (each the minimum of 5 builds): declined 144 / 157 / 100 (main 1,386 / 1,565 / 1,588), answered 120 / 138 / 110 (main 1,549 / 1,666 / 1,561), no plugin 148 / 115 / 100 (main 193 / 143 / 119). Main grows with the square of the import count. This branch grows in a line.
  • openat calls under the project, 5 modules: no plugin 19, declined 19, answered 18. Main has the same three numbers.
  • onResolve callbacks per build: R for R plugin-routed imports, on main and on this branch.

Tests (at 4115e85)

  • Debug build (ASAN): the five changed test files, bundler_barrel, bundler_plugin_chain, bundler_loader, test/bake/dev/css.test.ts, test/bake/dev/bundle.test.ts, test/bake/deinitialization.test.ts, test/bake/serve-plugins-dev-server.test.ts, test/js/bun/http/bun-serve-html.test.ts, and the regression test 40606.
  • Release build: all of test/bundler (7,847 pass, 2 fail in bundler_bytecode_portable.test.ts: one fails on main too, the other passes when the file runs alone) and all of test/bake (277 pass).
  • Release build of main 179664e with the five changed test files: 47 tests fail (30, 7, 2, 4 and 4).

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

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:49 PM PT - Oct 25th, 2026

✅ @robobun, your commit 4115e85b0bca4fd4f1e2a9bf6be6f1497bc1d1fb passed in Build #132759! 🎉


🧪   To try this PR locally:

bunx bun-pr 45408

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

bun-45408 --bun

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduction: run USE_SYSTEM_BUN=1 bun test test/bundler/bundler_plugin.test.ts -t "declines changes nothing" on 1.4.3. 14 of the 26 projects build differently with onResolve({ filter: /.*/ }, () => undefined) than with no plugin. The project with tsconfig JSX settings fails with Could not resolve: "react/jsx-dev-runtime". With this branch, bun bd test passes for the five changed test files. A release build of main fails 47 of their tests.

Comment thread src/bundler/ParseTask.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

#38605 now covers only the files route: an import that Bun.build({ files }) answers takes the shared steps of resolve_import_records. Its run_resolver and on_resolve hunks are gone, so the onResolve routes are here alone.

Two notes for this PR:

  1. The removed hunks chose manifest or module from the .html extension, before onLoad ran. With an onResolve hook that declines or answers the import and an onLoad hook that returns the page as JS, bun 1.4.2 and main give the module of the plugin. Those hunks gave { files } with no index and a stray page.js. This PR chooses after onLoad, which keeps that case.
  2. Two tests for an in-memory page behind an onResolve plugin left bundler: resolve files imports through the shared steps (HTML import manifest, url assets) #38605 and need a home here. On main and on bundler: resolve files imports through the shared steps (HTML import manifest, url assets) #38605 both builds fail with No matching export in "/app/page.html" for import "default". They go in the HTML imports block that bundler: resolve files imports through the shared steps (HTML import manifest, url assets) #38605 adds to test/bundler/bundler_files.test.ts and use its helpers. They need import type { BunPlugin } from "bun".
The two tests
async function expectManifestWithPlugin(specifier: string, plugin: BunPlugin) {
  const result = await Bun.build({
    entrypoints: ["/app/server.js"],
    target: "bun",
    files: {
      "/app/server.js": `import page from "${specifier}"; export default page;`,
      "/app/page.html": pageHtml,
      "/app/page.css": pageCss,
      "/app/client.js": clientJs,
    },
    plugins: [plugin],
  });

  const server = await outputText(result, "server.js");
  expect(server).not.toContain(`from "${specifier}"`);
  const [manifest, ...extra] = manifestsIn(server);
  expect(extra).toBeEmpty();
  expect(manifest.index).toMatch(/page\.html$/);
  expect(manifest.files.map(f => f.loader).toSorted()).toEqual(["css", "html", "js"]);
  expect(result.outputs.filter(o => o.path.endsWith(".html"))).toHaveLength(1);

  const { path: jsPath } = manifest.files.find(f => f.loader === "js")!;
  const client = await outputText(result, jsPath);
  expect(client).toContain(clientJs);
  expect(client).not.toContain("// @bun");
}

test("imported through an onResolve plugin that declines the import", async () => {
  await expectManifestWithPlugin("./page.html", {
    name: "decline-relative-imports",
    setup(build) {
      build.onResolve({ filter: /^\.\// }, () => undefined);
    },
  });
});

test("imported through an onResolve plugin that returns the in-memory file's path", async () => {
  await expectManifestWithPlugin("app:page", {
    name: "resolve-to-in-memory-page",
    setup(build) {
      build.onResolve({ filter: /^app:page$/ }, () => ({ path: "/app/page.html" }));
    },
  });
});

Merge order: #38605 first. This PR then rebases over it, and the old files branch of resolve_import_records goes away in that rebase.

robobun and others added 2 commits October 25, 2026 18:02
…solve plugins

An import that matched an onResolve filter did not go through
resolve_import_records. A declined import went through run_resolver, a
hand copy of that pass. An answered import went through a third module
maker in on_resolve. Both missed rules of the shared pass: tsconfig JSX
settings, side effects, the files option, HTML imports, loaders of
import attributes, the hash of an asset in the name of its importer.

on_resolve now sends the one record back through
resolve_import_records, process_resolve_queue and
patch_import_record_source_indices, with the helper that barrel
un-deferral already used. run_resolver and enqueue_parse_task are gone.
A declined entry point of Bun.build takes the code of an entry point
that no plugin matched, and keeps its place in the order of the outputs.

A file that an onResolve callback names still gets the options of the
build and no tsconfig.json or package.json settings.

Co-authored-by: ankit9999999999 <69192484+TechDev009@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…wers are back

Each onResolve answer ran the barrel seeding pass over every import
record of the importer. A file with R plugin-routed imports paid R
passes over R records. The pass now runs once, when the last answer of
the file is back.
Comment thread src/bundler/bundle_v2.rs Resolved
…record a module

An import record that is at the onResolve plugins keeps the specifier as
written in `path`. `patch_import_record_source_indices` matches `path` to
the modules of the build. So a specifier that is an absolute path got the
module with that path: before the plugins answered, and again after they
answered with a module that the build has already.

- `ImportRecordFlags::AT_ON_RESOLVE_PLUGINS` marks the record from the
  dispatch to the answer. The patch leaves a record with the flag alone.
- `resolve_records_of_parsed_file` does not patch the answered record when
  the pass gave it a module that exists.

An external answer is now external also when the specifier names a module
of the build. A redirect file (`module.exports = require(x)`) whose
require() went to the plugins no longer sends a later importer to the
module that the specifier names.

@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 the latest push and didn't find any new bugs; commit 4115e85 addresses the answered-record overwrite flagged in the previous round. Because this is a ~2,000-line rewrite of the bundler's onResolve pipeline with user-visible changes to error text, output names and hashes for plugin users, a human look is still worthwhile.

What was reviewed:

  • The AT_ON_RESOLVE_PLUGINS lifecycle: inserted at dispatch on the graph's own record, removed on both the answer path in resolve_records_of_parsed_file and the external early-return in on_resolve, and honored by the patch closure in patch_import_record_source_indices.
  • The has_module guard: skipping the trailing patch also skips the redirect put into path_to_source_index_map, but a later importer then lands on the redirect row and the reachable-file visitor follows redirects, so the new redirect tests (existing module and external answer) exercise the real behavior.
  • The rewritten "answer wins" tests: they now run the output and assert stdout, stderr and exit code together, and the external variant checks mock.js is absent from the bundle.
Extended reasoning...

The PR replaces the bundler's bespoke onResolve fall-through resolver and inline parse-task construction in src/bundler/bundle_v2.rs with a late re-entry into the shared resolve_import_records / process_resolve_queue / patch pass, adds an AT_ON_RESOLVE_PLUGINS import-record flag in src/ast/import_record.rs, trims src/bundler/barrel_imports.rs to a call into the shared pass, and adds tests across five files. It touches no auth, crypto or input-parsing surface; the risk is bundler correctness for plugin users (which module an import links to, entry-point ordering, dev-server HTML imports). The latest commit changes the flag lifecycle and the patch skip that the previous round's inline finding targeted, and the hunt ran dry on this version. The size of the rewrite and the documented user-visible output changes are why a human reviewer should still look rather than this being approved automatically.

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.

onResolve() for bundler plugins has strange interactions with react-refresh fullstack dev server (bake) + onResolve plugin breaks

2 participants