Repository navigation
macros: resolve macro imports with a resolver for Bun, so the "macro" export condition applies - #46802
Conversation
… export condition applies A macro import was resolved by the resolver of the importing build. That resolver has the conditions of the output target, so the documented "macro" export condition never matched. `bun build` for the browser also ran the "browser" build of a package as the macro, and `--external` or `--packages=external` changed which copy of the package the macro ran. The macro context now resolves with a second resolver, made from the importing one on the first macro call. What a target decides is set as Bun's runtime sets it: the conditions "macro", "bun" and "node" plus the user's conditions, Bun's main fields and extensions, no "browser" field, and no externals. Every other option stays as the importing resolver has it. A thread keeps the resolver for the next module of the same build. A builtin module behind the `macro:` prefix is found before the resolver runs, and a macro file named `bun.js` is no longer taken for the `bun` builtin. The docs example lists "macro" first, because the first matching key decides the file.
|
Updated 11:29 PM PT - Oct 24th, 2026
❌ @robobun, your commit c1be659 has 2 failures in
The baseline build crashed or failed tests under 🧪 To try this PR locally: bunx bun-pr 46802That installs a local version of the PR into your bun-46802 --bun |
|
Status Reproduced with a package whose PR: #46802 CI on
This PR is stacked on #46652. That PR must land first. |
…s own require.extensions list - The macro resolver has the "development" condition when the importing resolver has it. The user's `--conditions` and `--main-fields` apply as before. The extension order is a copy of the importing resolver's: a macro import reads only the `esm` lists, which no target changes. `from_api` is as on the base again. - A transpile job on another thread read `require.extensions` through a bytewise copy of `vm.transpiler`. The JS thread frees that list when a script assigns an extension, and the macro resolver reads the list on each macro call. The job now has its own copy of the list. - Tests: the page of an HTML import, `--conditions=browser`, `--main-fields`, a "macro" entry that gives no file, the dev server, `require.extensions` replaced during an import, and a Worker in a child of its own. That child runs with leak checking off: a worker that called a macro does not free its tables of macros when it ends. That leak is on main (#46228). - Docs: the first version is v1.4.5, the subpath route has a full `exports` example, and the conditions that also apply are listed.
|
25a04e8 answers the four review comments and two more findings. From the comments:
Found after the first push:
The PR body has the new measurements and the merge order against #46652, #46750 and #46548. |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
The second commit addresses the development-condition and thread-local idle-slot points from my earlier review. Beyond the inline nit, I also checked the ptr::read/ptr::write handoff of extra_cjs_extensions in TranspilerJob::run (src/jsc/RuntimeTranspilerStore.rs:724-731): the bytewise transpiler copy is ManuallyDrop and never dropped, the macro resolver only clone_froms that list, and the only assignments to it (src/jsc/NodeModuleModule.rs:166 and :192) go through vm.transpiler on the JS thread, so the job remains the single owner and the list is freed once with the job on every path, including when run() is never reached.
Extended reasoning...
The change adds a lazily built second resolver for macro imports (Target::BunMacro conditions, Bun main fields, no externals) on MacroContext, a derived BundleOptions literal in transpiler.rs, a Resolver::for_target wrapper, a per-job snapshot of require.extensions in RuntimeTranspilerStore, and a tighter "bun" builtin check in entry_points.rs, with docs and ~680 lines of tests. It touches unsafe raw-pointer and lifetime-erased code in the transpiler/resolver path but no auth, crypto, or injection surface. The new commit removed the thread-local resolver slot and now carries the importing resolver's development condition, resolving two of three earlier findings; the remaining one (browser-only packages no longer resolve as macros) is a documented behavior change the author chose deliberately, so a human maintainer should weigh that trade-off.
… the main thread The first macro of the fixture waits for the main thread. When the import is transpiled on the main thread (`NODE_COMPILE_CACHE`, or the async transpiler is off), that wait cannot end. The macro now returns a message in that case, the main script stops waiting when the import settles, and the child runs without the two settings.
|
c1be659 takes the nit about the
On the open point of the first review (a package that only has a browser file is not found as a macro): that is the behaviour change a maintainer has to accept or reject. The PR body has it under Downsides and in the scope note at the top of Notes. |
|
The third review has one note, marked pre-existing and not blocking: the bytewise copy of |
Part of #8779
Problem
"macro"export condition: it runsindex.js, notindex.macro.js, from 0.6.7 to 1.4.4.MacroContext::call(src/js_parser_jsc/Macro.rs:146) resolved it with the importing build's resolver, which has the output target's conditions. Sobun buildran abrowserfile as the macro.Fix
Transpiler::macro_resolver): Bun's conditions plusmacro, Bun's main fields, no externals.MacroContextbuilds it at its first macro call. The docs list"macro"first.require.extensionslist (Notes: a use-after-free).test/bundler/transpiler/macro-test.test.ts(16 new tests, 14 fail on the base), two dev server tests. Self-reviewed: 77 concerns raised, 69 addressed (Notes).Background
MacroContextis one transpiler's macro state.exportsin package.json. The first key in the resolver's set wins.Downsides
bun buildbehaviour change: a macro import ignores thebrowsercondition and field and externals, and takesmainbeforemodule. A browser-only package is not found."macro"last, as in the old docs, still resolvesindex.js.bun run. Without: +0 instructions per resolve, +6,144 text bytes.Notes
For the maintainer: the scope. #8779 asks for the
"macro"key. This PR does more: a macro import resolves for Bun, where the macro runs, at every target. The smaller change is one more condition on the importing resolver (see Alternatives). It fixes the key and leaves the output target in control of everything else. #40750 (merged) already makes the macro VM from the options of the build, so the imports inside a macro module resolve for Bun with the build's--conditions. #33709 proposed to delete the docs section in place of a fix and was closed unmerged.Repro.
node_modules/my-package/package.jsonis{"name":"my-package","exports":{"macro":"./index.macro.js","default":"./index.js"}}. Each file exportswho()that returns its own name.index.tsis:bun build ./index.ts --target=bunandbun run index.tsprintindex.json 0.6.7, 1.0.0, 1.1.0, 1.2.0, 1.3.0, 1.4.3 and on the base. They printindex.macro.json this branch.Why #8779 is not closed by this PR. The first matching key decides, as in Node. The docs listed
"macro"last from 2023-05-31 until now, and the report says its map follows the docs. Such a map still resolvesindex.js. The package must move"macro"to the top. Older Bun versions ignore the key. The docs now name v1.4.5 as the first version (v1.4.4 was released on 2026-10-22 without this change; change the number if this lands later). They also show a fullexportsmap with a"./macro"subpath for packages that support older versions. Both docs examples were run as written on this branch and on the base.What a macro import resolves to now. It is the file that
bun run --conditions=macroresolves with the same--conditionsand--main-fields, plusdevelopmentwhere the importing file has it. One test asserts that for every specifier of the matrix."macro"key that is the first match gives its file:exports, nested conditions, subpaths, and the"imports"of the importing package.browserandnodeof the output target no longer apply. Thebuncondition applies at every target."browser"field and the"browser"map do not apply.maincomes beforemodule, as in Bun's runtime. If the build passes--main-fields, that list applies, as on the base and as inside the macro module.--conditionsapply, also a condition that is the name of a target: with--conditions=browsera macro import takes abrowserkey again. On the base a page of an HTML import did not get the user's conditions for its macro imports. Now it does, like the macro itself when it runs.developmentapplies when the importing resolver has it: a build that is not for production, the dev server. It does not apply in Bun's runtime or on a page of an HTML import inbun build, where plain imports do not have it either.react-serverof a framework does not apply: with it, the SSR copy and the client copy of one"use client"file inlined different results of one macro call.--external,--packages=externalandBun.build({ external, packages })do not apply. Before, the macro VM received the specifier unresolved and resolved it from the working directory. A relative macro import under--external '*'failed withCannot find module './macro.ts'.exports: { browser }) now fails withMacro "x" not found. So does a"macro"entry that gives no file ("macro": null, a missing file): as in Node, the resolver does not go on to the next key. A"macro"entry that holds conditions of which none matches does fall through. What a user can do: import the file by its path, or name the condition for the whole build (--conditions=browser,conditionsinBun.build). A[macros]remap in bunfig.toml does not help: it is not read for an import that has the attribute."imports"entry that names a builtin ("#path": "node:path") now resolves to the builtin. Before it was not found.macro:node:pathandmacro:pathnow resolve to the builtin in every mode. On the base they did in a bundle, and failed underbun runwithMacro "macro:node:path" not found. The builtin lookup now takes the specifier without themacro:prefix, as it does for the attribute spelling. So a package innode_modulesthat has the name of a builtin no longer runs for amacro:import. Do not look a bare builtin name up as a package: after a ?query cut, for a preload, for a macro: import #44957 has the same line.bun.jsloads as a file.MacroEntryPoint::generatetook every file with the base namebunfor thebunbuiltin (Macro 'who' not found in 'bun'). A package whosebunentry has that name is reachable from every target now. macros: compare the whole entry path tobun, so a macro file named bun.ts is not the builtin #46827 owns this line and has its tests: the hunk here is the same and drops out when macros: compare the whole entry path tobun, so a macro file named bun.ts is not the builtin #46827 lands.No fallback to the importing resolver. A review asked for one for item 8. A macro import that falls back runs the file of the output target whenever Bun's conditions find nothing, and it hides a
"macro"or"bun"entry that names a missing file. Anote:on the error was written and removed: it has to tell a file for the output target from a dead"macro"entry, and the first text was wrong for the second. It is a follow-up.The
require.extensionslist and transpile jobs. A module thatimportloads is transpiled on a pool thread, with a bytewise copy ofvm.transpiler(TranspilerJob::run). The copy aliases therequire.extensionslist of the VM, and the JS thread frees that list on eachrequire.extensions[x] = .... The macro resolver compares that list on each macro call, so the first commit of this PR read freed memory when a script assigned an extension during such a job. ASAN reportsheap-use-after-freeinMacroContext::resolve. A stress script (400 imports of macro modules while the list changes) crashed 7 times in 20 runs on the release build of the first commit, 0 times on the base and 0 times now. The base has the same bug on a narrower path: a macro import that resolves through a registered extension fails there withMacro "./gate" not found. NowRuntimeTranspilerStore::transpileclones the list on the JS thread and the job reads its own copy. The clone of an empty list does not allocate.TranspilerJobgets one field of 16 bytes (424 bytes now, 64 slots per VM).Not changed.
bun,nodeand withoutmacro. That needs a macro VM with a module registry of its own (macros: run every macro on one dedicated VM thread #40059). Inbun runthe macro module shares the registry of the program.bun runor the dev server listed its directory is not found.Bun.buildfinds it. Handed off as a separate fix.pathsentry maps to a file, so it skips theexportsof a package, as for a plain import.How the resolver is made.
macro_resolver_options(src/bundler/transpiler.rs) is one exhaustive literal of the resolver's options. Eight fields are set for the macro environment: target, conditions, main fields, externals, packages, builtins as externals, node polyfills, framework. Every other field is a copy of the importing resolver's value. The extension order is in that group: a macro import is animportstatement and reads only theesmlists, which no target changes. A new resolver option does not compile until it is sorted into one of the two groups. Three values can change after the first macro call of a context that lasts longer than one module.MacroContext::resolvecopies them on each call: the log, the auto-installer (PackageJSON::parserecords thenpm:aliases of a project through it), and therequire.extensionslist.A
MacroContextowns its macro resolver. A bundler worker has one context per build. The VM has one for the entry point and forrequire(). A transpile job and aBun.Transpilercall make a context of their own, so each of them that calls a macro builds a resolver. An earlier commit kept the resolver in a thread-local slot between such contexts. That saved the 12,500 instructions and added a cache key, aDrop, and a resolver that outlived its VM. It was removed.The Worker test and LeakSanitizer. A worker that called a macro does not free
vm.macrosandvm.macro_entry_pointswhen it ends:VirtualMachine::destroydoes not name them, and the VM storage is freed raw. LeakSanitizer reports 8,936 bytes in 7 allocations for one macro import (debug build, no suppression file). Nothing of the macro resolver is in that report. The leak is on main and #46228 owns it. So thebun run: a Workertest turns leak checking off for its child. When #46228 lands, that line can go.Alternatives.
--conditions=macro: it changes the same 86 cells where a"macro"key is the first match. It leaves the 31 cells where the output target decides the file, and the externals cells. It needs no second resolver and no dependency on resolver: keep every main field of a package.json in the shared cache, select per resolver #46652. It adds a branch to everyexportsresolve. If only the key is wanted, that is the smaller PR.Related PRs and the order.
main. Without resolver: keep every main field of a package.json in the shared cache, select per resolver #46652, a direct macro import can change a plain import of the same package.load_package_jsonandload_tsconfig_jsonfrom the resolver's options. The merge with this PR has no text conflict, butmacro_resolver_optionsnames both fields. The PR that lands second must drop those two lines, ormaindoes not compile.Resolver::for_target. The two copies merge to one definition. This PR adds no other overlap: a trial merge with bundler: derive the options of a server build's page from the browser target #46548 conflicts only where resolver: keep every main field of a package.json in the shared cache, select per resolver #46652 already does.bun, so a macro file named bun.ts is not the builtin #46827 (thebunentry path) and Do not look a bare builtin name up as a package: after a ?query cut, for a preload, for a macro: import #44957 (themacro:prefix) have the same line as items 11 and 10. Escape the macro path and export name in the generated macro entry module #42706 rewrites the code around thebunline and conflicts with it.Macro::initlock change is separate.Measurements. Release builds (LTO) of the base
8ccf558da7and of this branch (25a04e8a54), taken back to back.perf,strace,valgrindandbloatyare not in the container. Used: aperf_event_openwrapper forinstructions:u, aptracesyscall counter, gdb breakpoint counters on the mimalloc entry points,size,nm, gdbsizeofon the debug build.Bun.resolveSync, instructions per call (exports/ main fields / main fields withbrowser)bun -e 0, instructions (min of 20, three interleaved rounds)bun -e 0, allocationsimport()of modules with no macro, instructions per modulebun runof a file with no macro (min of 20, three rounds)bun runof a file with one macro call (min of 20, three rounds)bun runof 2,000 imported modules with one macro call each, per moduleBun.Transpiler#transformSyncof a file with one macro call, per callbun buildof 2,000 modules with one macro call eachbun buildwith one macro import:openat/read/getdents64/closesize)MacroContextTranspilerJobbun run, 98 allocations in a bundle that is not for production, 88 with--production.bun build(12 threads). 2,000 times for 2,000 imported modules underbun run.Self-review. Two passes. The first was on the first cut: 46 concerns, 40 addressed. The second was on the follow-up commit: 31 concerns, 29 addressed. The second pass found the
require.extensionsuse-after-free, thereact-serversplit, three wrong docs statements and the wider overlap with #46548. It also asked to drop the thread-local slot and the note.Not done, with the reason:
note:onMacro "x" not foundthat names the cause (a file only for the output target, a"macro"entry with no file, a key that comes before"macro"). It needs more than one text and a new output of the resolver.src/ast/target.rs,src/bundler/options.rs): two copies on main, and bundler: derive the options of a server build's page from the browser target #46548 moves that code.--compile. The dev server, aWorker, an HTML import andBun.Transpilerare tested.bun test --changedtest: resolver: keep every main field of a package.json in the shared cache, select per resolver #46652 and bundler: run no macro in a bundle made only for its import graph (test --changed,--analyze,bun create <component>) #46281 change that scan.--externalcells asitBundledcases. They share a fixture with theBun.buildcell, which needs a process of its own.Suites run with the debug build.
test/bundler/transpiler/macro-test.test.tsand the two new tests oftest/bake/dev/bundle.test.ts(both also withBUN_DESTRUCT_VM_ON_EXIT=1,detect_leaks=1andtest/leaksan.supp, as the x64-asan lane runs them), the wholetest/bake/dev/bundle.test.ts,transpiler.test.js,runtime-transpiler.test.ts,scope-mismatch-panic.test.ts,test/bundler/resolver/cache-runtime.test.ts,bundler_barrel, the macro tests ofbundler_edgecaseandbun-build-api,test/js/bun/resolve/(build-error, resolve, import-custom-condition),test/js/bun/plugin/plugins.test.ts,test/cli/test/isolation.test.ts,test/cli/run/transpiler-cache.test.ts,test/js/node/module/require-extensions.test.ts,test/js/node/disallow-code-generation-from-strings.test.ts,test/regression/issue/{03830,22656,26360,39900}.test.ts,test/internal/source-lints/. With the release build:test/cli/run/require-cache.test.ts(one test times out at 30 s on the base too).