From de682c7566f3383627af65fed846fe570b6c9286 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:22:13 +0000 Subject: [PATCH 1/6] js_parser: const_values holds a checked ConstValue with one constructor and one writer `ConstValue::new` admits a value only for a `const` binding. It admits a primitive, and an object or an array that a macro returned, as before. It does not admit an `undefined` destructuring leaf that has a default. `admit_const_value` is the only writer of the table, so the two destructuring leaf writes ask the same question as the identifier write. A `let` or a `var` that holds a macro result is no longer printed in place of each read. `let s = hello(); s += " World"` printed `"Hello" += " World"`. --- src/ast/ast_result.rs | 43 ++- src/ast/expr.rs | 22 -- src/ast/lib.rs | 4 +- src/js_parser/p.rs | 2 +- src/js_parser/parser.rs | 2 +- src/js_parser/visit/mod.rs | 70 +++-- test/bundler/transpiler/macro-test.test.ts | 306 ++++++++++++++++++++- 7 files changed, 397 insertions(+), 52 deletions(-) diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index dcc3b920b894..18ac087c9c37 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -10,8 +10,8 @@ use bun_collections::{ArrayHashMap, StringArrayHashMap, StringHashMap}; use crate::runtime; use crate::{ - CharFreq, ExportsKind, Expr, InlinedEnumValue, LocRef, NamedExport, NamedImport, Part, Range, - Ref, Scope, SlotCounts, StoreStr, Target, + CharFreq, ExportsKind, Expr, ExprData, InlinedEnumValue, LocRef, LocalKind, NamedExport, + NamedImport, Part, Range, Ref, Scope, SlotCounts, StoreStr, Target, }; use crate::part::List as PartList; @@ -256,7 +256,44 @@ pub struct DynamicImportItem { } pub type DynamicImportAliases = ArrayHashMap; pub type NamedExports = StringArrayHashMap; -pub type ConstValuesMap = ArrayHashMap; +/// The value of a binding that the parser prints in place of each read of +/// that binding. The binding is a `const`, so it cannot change. The value is +/// a primitive, so a copy of it is the same value. +/// +/// An object or an array that a macro returned is admitted too. Each read of +/// such a binding makes a new object (#7196). +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct ConstValue(Expr); + +impl ConstValue { + /// `has_default` is true for a destructuring leaf that has a default: + /// `const [x = 5] = [undefined]` binds 5, not the `undefined` leaf. + #[inline] + pub fn new(kind: LocalKind, has_default: bool, value: Expr) -> Option { + let admit = kind == LocalKind::KConst + && match &value.data { + ExprData::ENumber(_) + | ExprData::EBoolean(_) + | ExprData::EBranchBoolean(_) + | ExprData::ENull(_) + | ExprData::EInlinedEnum(_) => true, + ExprData::EUndefined(_) => !has_default, + ExprData::EString(str) => str.next.is_none(), + ExprData::EArray(array) => array.was_originally_macro, + ExprData::EObject(object) => object.was_originally_macro, + _ => false, + }; + admit.then_some(Self(value)) + } + + #[inline] + pub fn expr(self) -> Expr { + self.0 + } +} + +pub type ConstValuesMap = ArrayHashMap; pub type TsEnumsMap = ArrayHashMap, AutoContext, AstAlloc>; /// `import X ...; X.name` where `X` resolved to a module namespace while diff --git a/src/ast/expr.rs b/src/ast/expr.rs index e6ee3193eb55..48991ff0ef59 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -65,10 +65,6 @@ impl Expr { } } - pub fn can_be_const_value(&self) -> bool { - self.data.can_be_const_value() - } - pub fn can_be_moved(&self) -> bool { self.data.can_be_moved() } @@ -2255,24 +2251,6 @@ impl Data { } impl Data { - /// "const values" here refers to expressions that can participate in constant - /// inlining, as they have no side effects on instantiation, and there would be - /// no observable difference if duplicated. This is a subset of canBeMoved() - pub(crate) fn can_be_const_value(&self) -> bool { - match self { - Data::ENumber(_) - | Data::EBoolean(_) - | Data::EBranchBoolean(_) - | Data::ENull(_) - | Data::EUndefined(_) - | Data::EInlinedEnum(_) => true, - Data::EString(str) => str.next.is_none(), - Data::EArray(array) => array.was_originally_macro, - Data::EObject(object) => object.was_originally_macro, - _ => false, - } - } - /// Expressions that can be moved are those that do not have side /// effects on their own. This is used to determine what can be moved /// outside of a module wrapper (__esm/__commonJS). diff --git a/src/ast/lib.rs b/src/ast/lib.rs index f4c86093762d..3eb182cb4f06 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -2987,8 +2987,8 @@ pub mod loader; pub mod target; pub use ast_result::{ - Ast, CommonJSNamedExport, CommonJSNamedExports, ConstValuesMap, NamedExports, NamedImports, - TopLevelSymbolToParts, TsEnumsMap, + Ast, CommonJSNamedExport, CommonJSNamedExports, ConstValue, ConstValuesMap, NamedExports, + NamedImports, TopLevelSymbolToParts, TsEnumsMap, }; pub use import_record::{Flags as ImportRecordFlags, ImportRecord, Tag as ImportRecordTag}; pub use loader::{Loader, LoaderHashTable, SideEffects}; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index d981eb71862d..8038fa434624 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -2188,7 +2188,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if self.options.features.inlining { if let Some(replacement) = self.const_values.get(&ref_) { - let replacement = *replacement; + let replacement = replacement.expr(); self.ignore_usage(ref_); return replacement; } diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index b37f40c8f55a..4718b12d4939 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -711,7 +711,7 @@ pub struct VisitArgsOpts<'a> { #[derive(Clone, Copy)] pub struct VisitDeclOpts { pub(crate) was_anonymous_named_expr: bool, - pub(crate) could_be_const_value: bool, + pub(crate) kind: js_ast::LocalKind, pub(crate) could_be_macro: bool, } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index b9acf860486c..e8a7a283d87f 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -279,7 +279,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O kind: LocalKind, is_export: bool, ) -> usize { - let was_const = kind == LocalKind::KConst; // A namespace member isn't a module export. let can_replace_exports = IS_POSSIBLY_DECL_TO_REMOVE && self.enclosing_namespace_arg_ref.is_none(); @@ -592,12 +591,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - let is_after = self.vis_scope().is_after_const_local_prefix; self.visit_decl( decl, VisitDeclOpts { was_anonymous_named_expr, - could_be_const_value: was_const && !is_after, + kind, could_be_macro: if Self::ALLOW_MACROS { prev_macro_call_count != self.macro_call_count } else { @@ -622,12 +620,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if !self.replace_decl_and_possibly_remove(decl, replacer) { continue 'outer; } - let is_after = self.vis_scope().is_after_const_local_prefix; self.visit_decl( decl, VisitDeclOpts { was_anonymous_named_expr: false, - could_be_const_value: was_const && !is_after, + kind, could_be_macro: false, }, ); @@ -652,7 +649,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O j } - pub(crate) fn visit_binding_and_expr_for_macro(&mut self, binding: Binding, expr: Expr) { + pub(crate) fn visit_binding_and_expr_for_macro( + &mut self, + binding: Binding, + expr: Expr, + kind: LocalKind, + has_default: bool, + ) { match binding.data { BData::BObject(bound_object) => { let bound_object = bound_object.get(); @@ -672,15 +675,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.visit_binding_and_expr_for_macro( property.value, query.expr, + kind, + property.default_value.is_some(), ); } _ => { if self.options.features.inlining { if let BData::BIdentifier(id) = property.value.data { - self.const_values - .put(id.r#ref, query.expr) - .expect("oom"); + self.admit_const_value( + id.r#ref, + kind, + property.default_value.is_some(), + query.expr, + ); } } } @@ -713,37 +721,56 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O *child_expr = self.new_expr(E::Missing {}, expr.loc); continue; } - self.visit_binding_and_expr_for_macro(item.binding, *child_expr); + self.visit_binding_and_expr_for_macro( + item.binding, + *child_expr, + kind, + item.default_value.is_some(), + ); } } } } BData::BIdentifier(id) => { if self.options.features.inlining { - self.const_values.put(id.r#ref, expr).expect("oom"); + self.admit_const_value(id.r#ref, kind, has_default, expr); } } BData::BMissing(_) => {} } } + /// The only writer of `const_values`. `ConstValue::new` tests the kind and the + /// value. `visit_decl` tests the position. + #[inline] + fn admit_const_value(&mut self, ref_: Ref, kind: LocalKind, has_default: bool, value: Expr) { + if let Some(value) = js_ast::ConstValue::new(kind, has_default, value) { + debug_assert!(self.symbols[ref_.inner_index() as usize].kind == SymbolKind::Constant); + self.const_values.put(ref_, value).expect("oom"); + } + } + pub(crate) fn visit_decl(&mut self, decl: &mut G::Decl, opts: VisitDeclOpts) { let VisitDeclOpts { was_anonymous_named_expr, - could_be_const_value, + kind, could_be_macro, } = opts; // Optionally preserve the name match decl.binding.data { BData::BIdentifier(id) => { let id_ref = id.r#ref; - if could_be_const_value || (Self::ALLOW_MACROS && could_be_macro) { + // A declaration whose initializer held a macro call is exempt from the + // const-local prefix: `log(); const n = macro(); other(n)` gives the macro + // `other` the literal, and `let i = macro(); const n = 1;` still inlines `n`. + let is_macro = Self::ALLOW_MACROS && could_be_macro; + if kind == LocalKind::KConst + && (is_macro || !self.vis_scope().is_after_const_local_prefix) + { if let Some(val) = decl.value { - if val.can_be_const_value() { - self.const_values.put(id_ref, val).expect("oom"); - } + self.admit_const_value(id_ref, kind, false, val); } - } else { + } else if !is_macro { self.vis_scope().is_after_const_local_prefix = true; } // SAFETY: original_name is arena-owned, valid for 'a. @@ -759,7 +786,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O BData::BObject(_) | BData::BArray(_) => { if Self::ALLOW_MACROS { if could_be_macro && let Some(value) = decl.value { - self.visit_binding_and_expr_for_macro(decl.binding, value); + self.visit_binding_and_expr_for_macro(decl.binding, value, kind, false); } } } @@ -1853,10 +1880,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O | StmtData::STypeScript(_) => continue, StmtData::SLocal(mut local) => { // "using" / "await using" declarations have disposal - // side-effects on scope exit. Their refs can end up in - // `const_values` via the macro path in `visitDecl` - // (`could_be_macro`), so skip them here to avoid - // silently dropping the declaration. + // side-effects on scope exit and are never in + // `const_values`. Skip them, so that the scan reaches + // the declarations below them. if local.kind.is_using() { continue; } diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 16a41fa0d250..66605d045c58 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -1,6 +1,6 @@ import { escapeHTML } from "bun" with { type: "macro" }; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; import { existsSync } from "node:fs"; import path from "node:path"; import defaultMacro, { @@ -251,6 +251,310 @@ test("spreading a macro result into JSX doesn't change the other uses of its con expect(exitCode).toBe(0); }); +// Bun replaces the macro call with its result, and the rest of the program runs as written. So each module below +// must export what it exports when `m.ts` is imported without the macro attribute. +describe("a macro result that is bound to a name", () => { + const names = "num, hello, yes, nul, undef, undefs, obj, arr, holes, keys, twice"; + const macros = ` + export const num = () => 21; + export const hello = () => "Hello"; + export const yes = () => true; + export const nul = () => null; + export const undef = () => undefined; + export const undefs = () => [undefined, null]; + export const obj = () => ({ a: 1, b: { c: 2 }, list: [1, 2] }); + export const arr = () => [1, "two", [3]]; + export const holes = () => ({ a: undefined, b: 4, c: null }); + export const keys = () => ({ xx: 1, x: 5, 0: "zero" }); + export const twice = () => { + const o = { a: 1, b: 2 }; + return { p: o, q: o }; + }; + `; + + // Modules with the value that each must export. + const written: [body: string, exported: unknown][] = [ + [`let s = hello(); s += " World"; export default s;`, "Hello World"], + [`let n = num(); n = 5; export default n;`, 5], + [`var v = num(); v++; export default v;`, 22], + [`let { a, b } = obj(); a = 99; export default [a, b];`, [99, { c: 2 }]], + [`const [u = 5, n = 6] = undefs(); export default [u, n];`, [5, null]], + [`const { a = 10, b = 20, c = 30 } = holes(); export default [a, b, c];`, [10, 4, null]], + [`const n = num(); const s = hello(); export default [n + n, s + s.length];`, [42, "Hello5"]], + [`export default [obj().b.c, arr()[2][0], arr().length, hello().length, num() + num()];`, [2, 3, 3, 5, 42]], + ]; + + // Modules from cross products of the declaration kind, the value and the use. No exported value is stored for + // them: the plain import is the oracle. + const generated: string[] = []; + // A read of a `const` that holds a macro object or array is a new copy of the object (#7196). A module that + // can tell the copy from the object does not run for `const`. + const copied = (kind: string, seesCopy: boolean) => kind === "const" && seesCopy; + for (const kind of ["const", "let", "var"]) { + const add = (body: string, seesCopy = false) => copied(kind, seesCopy) || generated.push(body); + for (const value of ["num", "hello", "yes", "nul", "undef", "obj", "arr"]) { + const x = `${kind} x = ${value}();`; + add(`${x} export default [x, typeof x];`); + add(`${x} const read = () => x; export default [read(), read()];`); + add(`${x} export default String(x) + JSON.stringify([x]);`); + if (kind === "const") continue; + add(`${x} x = 5; export default x;`); + add(`${x} x ??= "fallback"; export default x;`); + add(`${x} x += "!"; export default x;`); + add(`${x} [x] = ["array target"]; export default x;`); + add(`${x} ({ x } = { x: "object target" }); export default x;`); + add(`${x} for (x of ["loop target"]) {} export default x;`); + } + + const o = `${kind} x = obj();`; + add(`${o} export default [x.a, x.b.c, x.list[1], x.list.length, Object.keys(x), "a" in x];`); + add(`${o} const { a, b: { c } } = x; export default [a, c];`); + add(`${o} export default JSON.stringify({ ...x, extra: 1 });`); + add(`${o} export default x === x;`, true); + add(`${o} x.a = 9; x.b.c = 8; x.list.push(3); delete x.list; export default x;`, true); + add(`${o} const y = x; y.added = true; export default [y === x, x.added];`, true); + add(`${o} export default new Map([[x, "found"]]).get(x);`, true); + + const a = `${kind} x = arr();`; + add(`${a} export default [x[0], x[1], x[2][0], x.length, x.map(v => typeof v), x.includes("two")];`); + add(`${a} const [first, , [third]] = x; export default [first, third];`); + add(`${a} export default [...x, ...x].length;`); + add(`${a} x.push(4); x[0] = 0; export default [x, x.length];`, true); + add(`${a} const y = x; y.length = 1; export default [y === x, x];`, true); + add(`${a} export default new Set([x]).has(x);`, true); + + add(`${kind} { a, b: { c }, list: [first, second] } = obj(); export default [a, c, first, second];`); + add( + `${kind} { a: renamed, missing = "d", b: { c = 0, gone = "g" } } = obj(); export default [renamed, missing, c, gone];`, + ); + add(`${kind} { list, ...rest } = obj(); export default [list, rest];`); + add(`${kind} { list: [, second = 0, third = "t"] } = obj(); export default [second, third];`); + add(`${kind} [p, q, [r]] = arr(); export default [p, q, r];`); + add(`${kind} [p = 0, , [t] = [], u = "u"] = arr(); export default [p, t, u];`); + add(`${kind} [head, ...tail] = arr(); export default [head, tail];`); + add(`${kind} { b, list } = obj(); b.c = 9; list.push(3); export default [b, list, b === b];`, true); + if (kind === "const") continue; + add(`${kind} { a, b } = obj(); a = 5; b = null; export default [a, b];`); + add(`${kind} [p, q] = arr(); p += 1; [q] = ["target"]; export default [p, q];`); + add(`${kind} { b: { c }, list: [first] } = obj(); c = "c"; first = "f"; export default [c, first];`); + } + + // A debug build is 10 to 100 times slower, so it runs a sample of the generated modules, always the same one. + const every = isDebug || isASAN ? 8 : 1; + const bodies = [...written.map(([body]) => body), ...generated.filter((_, i) => i % every === 0)]; + + const files: Record = { + "m.ts": macros, + // Imports each module by itself, so that a module that cannot load does not hide the other modules. + "observe.ts": ` + const [dir, ext, count] = process.argv.slice(2); + const seen = []; + for (let i = 0; i < Number(count); i++) { + try { + seen.push((await import("./" + dir + "/" + i + "." + ext)).default); + } catch (e) { + seen.push("threw " + e?.name); + } + } + console.log(JSON.stringify(seen, (key, value) => (value === undefined ? "undefined" : value))); + `, + }; + bodies.forEach((body, i) => { + files[`plain/${i}.ts`] = `import { ${names} } from "../m.ts";\n${body}\n`; + files[`macro/${i}.ts`] = `import { ${names} } from "../m.ts" with { type: "macro" };\n${body}\n`; + }); + + async function observe(cwd: string, dir: string, ext: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "observe.ts", dir, ext, String(bodies.length)], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Debug builds print "[macro] call " to stdout before the script's own output. + const seen: unknown[] = JSON.parse(stdout.trim().split("\n").pop()!); + return { seen: Object.fromEntries(bodies.map((body, i) => [`${i}: ${body}`, seen[i]])), stderr, exitCode }; + } + + // The plain import gives each written module its value, and no generated module throws there. + async function oracle(cwd: string) { + const plain = await observe(cwd, "plain", "ts"); + expect(Object.values(plain.seen).slice(0, written.length)).toEqual(written.map(([, exported]) => exported)); + expect(Object.values(plain.seen).filter(value => String(value).startsWith("threw "))).toEqual([]); + return plain; + } + + test.concurrent("bun run", async () => { + using dir = tempDir("macro-bound-run", files); + expect(await observe(String(dir), "macro", "ts")).toEqual(await oracle(String(dir))); + }); + + // `--no-bundle --target=bun` and `--minify` turn the inliner on. The plain build leaves it off. + test.concurrent.each([ + ["bun build", []], + ["bun build --no-bundle", ["--no-bundle"]], + ["bun build --minify", ["--minify"]], + ])("%s", async (_, flags) => { + using dir = tempDir("macro-bound-build", files); + await using build = Bun.spawn({ + cmd: [ + bunExe(), + "build", + ...flags, + ...bodies.map((_, i) => `./macro/${i}.ts`), + "--target=bun", + "--outdir=out", + // Without a name pattern, 9 or more entry points go to `out/macro/` (#38732). + "--entry-naming=[name].[ext]", + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); + expect(await observe(String(dir), "out", "js")).toEqual(await oracle(String(dir))); + }); + + // The inliner takes a `const` primitive from a macro at each position. It takes no `let` and no `var`: the + // program can assign to them. + test.concurrent("the inliner takes a const primitive and no let or var", async () => { + const from = `import { num, hello, no, pagePath } from "./m.ts" with { type: "macro" };\n`; + using dir = tempDir("macro-bound-inline", { + "m.ts": ` + export const num = () => 21; + export const hello = () => "Hello"; + export const no = () => false; + export const pagePath = () => "./page.ts"; + `, + "page.ts": `export default "the page";`, + "first.ts": from + `const n = num(); const s = hello(); export default [n, n, s, s];`, + "below.ts": from + `globalThis.effect = 1; const n = num(); const s = hello(); export default [n, n, s, s];`, + "plain.ts": from + `globalThis.effect = 1; const k = 5; export default [k, k];`, + "let.ts": from + `let n = num(); export default [n, n];`, + "after-let.ts": from + `let i = num(); const k = 5; export default [i, k, k];`, + "flag-const.ts": from + `const DEV = no(); export default DEV ? "dev" : "prod";`, + "flag-var.ts": from + `var DEV = no(); export default DEV ? "dev" : "prod";`, + "import-const.ts": from + `const page = pagePath(); export default import(page);`, + "import-let.ts": from + `let page = pagePath(); export default import(page);`, + }); + const entries = [ + "first", + "below", + "plain", + "let", + "after-let", + "flag-const", + "flag-var", + "import-const", + "import-let", + ]; + await using build = Bun.spawn({ + cmd: [ + bunExe(), + "build", + ...entries.map(name => `./${name}.ts`), + "--target=bun", + "--minify-syntax", + "--outdir=out", + "--entry-naming=[name].[ext]", + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); + const out = (name: string) => Bun.file(path.join(String(dir), "out", name + ".js")).text(); + + expect(await out("first")).toContain(`[21, 21, "Hello", "Hello"]`); + // A macro result is a literal at each position. An ordinary `const` below a statement is not inlined. + expect(await out("below")).toContain(`[21, 21, "Hello", "Hello"]`); + expect(await out("plain")).toContain(`[k, k]`); + expect(await out("let")).toContain(`[n, n]`); + // A `let` that holds a macro result does not end the run of `const` declarations that the inliner takes. + expect(await out("after-let")).toContain(`[i, 5, 5]`); + expect(await out("flag-const")).toContain(`= "prod"`); + expect(await out("flag-var")).toContain(`DEV ? "dev" : "prod"`); + // The bundler follows an `import()` of a `const` string. It cannot follow a `let`. + expect(await out("import-const")).toContain(`"the page"`); + expect(await out("import-let")).toContain(`import(page)`); + }); + + // A macro argument is evaluated at build time. The program can assign to a `let` before the call, so only a + // `const` has a build-time value. A `let` that holds a macro result does not hide the `const` below it. + test.concurrent("a macro argument can name a const primitive and cannot name a let", async () => { + const from = `import { num, hello, id, greet } from "./m.ts" with { type: "macro" };\n`; + using dir = tempDir("macro-bound-argument", { + "m.ts": ` + export const num = () => 21; + export const hello = () => "Hello"; + export const id = (x: any) => x; + export const greet = (name: string) => "hello " + name; + `, + "const.ts": + from + + `let counter = num();\nconst name = "world";\nconst h = hello();\nconsole.log(greet(name), id(h), counter);`, + "let.ts": from + `let h = hello();\nconsole.log(id(h));`, + }); + async function run(file: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), file], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { lastLine: stdout.trim().split("\n").pop(), stderr, exitCode }; + } + expect(await run("const.ts")).toEqual({ lastLine: "hello world Hello 21", stderr: "", exitCode: 0 }); + const rejected = await run("let.ts"); + expect(rejected.stderr).toContain(`"Cannot convert identifier to JS. Try a statically-known value" error in macro`); + expect(rejected.exitCode).toBe(1); + }); + + // The pattern still tells which properties the program can read, for a `let` too. + test.concurrent("a let pattern drops the properties that it does not bind", async () => { + using dir = tempDir("macro-bound-let-pattern", { + "m.ts": `export const big = () => ({ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8 });`, + "entry.ts": ` + import { big } from "./m.ts" with { type: "macro" }; + let { a } = big(); + a += 100; + console.log(a); + `, + }); + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "entry.ts", "--target=bun", "--minify", "--outfile=out.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); + const out = await Bun.file(path.join(String(dir), "out.js")).text(); + expect(out).toContain("a:1"); + expect(out).not.toContain("b:2"); + expect(out).not.toContain("h:8"); + + await using run = Bun.spawn({ + cmd: [bunExe(), "out.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "101", stderr: "", exitCode: 0 }); + }); +}); + // A value returned only by a getter can be collected once the walk moves on, and a later value can reuse its address. test("inlines every new array, object or promise returned by a macro result's getters", async () => { const count = 12; From b683c9bb92fba9b93ba95df4e3d720211b9106e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:22:15 +0000 Subject: [PATCH 2/6] js_parser: narrow a destructured macro result into a new node `visit_binding_and_expr_for_macro` removed the properties that a pattern does not bind from the macro's own object. That object can have another reader: a pattern can bind one key two times, and a macro can return one object at two places. `const { og: { title }, og } = meta()` left `og.image` undefined. The function now returns a new object or array, and keeps the whole value of a key that the pattern binds again. It also keeps each property when a pattern key is not one flat string: a computed key, a joined string key or a number key read a property that the prune had removed. --- src/js_parser/visit/mod.rs | 161 +++++++++++---------- test/bundler/transpiler/macro-test.test.ts | 6 + 2 files changed, 94 insertions(+), 73 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index e8a7a283d87f..ab9beedf2f24 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -649,94 +649,104 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O j } + /// Records the `const` leaves of a destructured macro result. Returns the + /// result without the properties and items that `binding` cannot read. + /// + /// A narrowed object or array is a new node. The node of `expr` can have + /// another reader: a macro can return one object at two places, and a + /// pattern can bind one key two times. pub(crate) fn visit_binding_and_expr_for_macro( &mut self, binding: Binding, expr: Expr, kind: LocalKind, has_default: bool, - ) { - match binding.data { - BData::BObject(bound_object) => { - let bound_object = bound_object.get(); - if let ExprData::EObject(mut object) = expr.data { - if object.was_originally_macro { - for property in bound_object.properties() { - if property.flags.contains(flags::Property::IsSpread) { - return; - } - } - let mut end: u32 = 0; - for property in bound_object.properties() { - if let Some(name) = property.key.as_string_literal(self.arena) { - if let Some(query) = object.as_property(name) { - match query.expr.data { - ExprData::EObject(_) | ExprData::EArray(_) => { - self.visit_binding_and_expr_for_macro( - property.value, - query.expr, - kind, - property.default_value.is_some(), - ); - } - _ => { - if self.options.features.inlining { - if let BData::BIdentifier(id) = property.value.data - { - self.admit_const_value( - id.r#ref, - kind, - property.default_value.is_some(), - query.expr, - ); - } - } - } - } - let i = query.i as usize; - if i >= end as usize { - object.properties.slice_mut().swap(i, end as usize); - end += 1; - } - } - } - } - object.properties.truncate(end as usize); + ) -> Expr { + match (binding.data, expr.data) { + (BData::BObject(bound), ExprData::EObject(object)) if object.was_originally_macro => { + let bound = bound.get(); + // A rest element, or a key that is not one flat string, can read any property. + for property in bound.properties() { + if property.flags.contains(flags::Property::IsSpread) + || !matches!(property.key.data, ExprData::EString(key) if key.next.is_none()) + { + return expr; } } - } - BData::BArray(bound_array) => { - let bound_array = bound_array.get(); - if let ExprData::EArray(mut array) = expr.data { - if array.was_originally_macro && !bound_array.has_spread { - let bound_items = bound_array.items(); - let trunc_n = array.items.len().min(bound_items.len()); - array.items.truncate(trunc_n); - let n = array.items.len_u32() as usize; - for (item, child_expr) in bound_items[..n] - .iter() - .zip(array.items.slice_mut().iter_mut()) - { - if matches!(item.binding.data, BData::BMissing(_)) { - *child_expr = self.new_expr(E::Missing {}, expr.loc); - continue; - } - self.visit_binding_and_expr_for_macro( - item.binding, - *child_expr, - kind, - item.default_value.is_some(), - ); + let mut kept = G::PropertyList::init_capacity(bound.properties().len()); + let mut kept_from: BumpVec<'a, u32> = BumpVec::new_in(self.arena); + for property in bound.properties() { + let Some(name) = property.key.as_string_literal(self.arena) else { + continue; + }; + let Some(query) = object.as_property(name) else { + continue; + }; + let value = self.visit_binding_and_expr_for_macro( + property.value, + query.expr, + kind, + property.default_value.is_some(), + ); + match kept_from.iter().position(|&from| from == query.i) { + // The pattern binds this key again. Keep the whole value. + Some(at) => kept[at].value = Some(query.expr), + None => { + let mut copy = + object.properties.slice()[query.i as usize].shallow_clone(); + copy.value = Some(value); + kept_from.push(query.i); + kept.push(copy); } } } + self.new_expr( + E::Object { + properties: kept, + is_single_line: object.is_single_line, + was_originally_macro: true, + close_brace_loc: object.close_brace_loc, + ..Default::default() + }, + expr.loc, + ) } - BData::BIdentifier(id) => { + (BData::BArray(bound), ExprData::EArray(array)) + if array.was_originally_macro && !bound.has_spread => + { + let bound_items = bound.get().items(); + let n = array.items.len().min(bound_items.len()); + let mut items = ExprNodeList::init_capacity(n); + for (item, child) in bound_items[..n].iter().zip(&array.items.slice()[..n]) { + items.push(if matches!(item.binding.data, BData::BMissing(_)) { + self.new_expr(E::Missing {}, expr.loc) + } else { + self.visit_binding_and_expr_for_macro( + item.binding, + *child, + kind, + item.default_value.is_some(), + ) + }); + } + self.new_expr( + E::Array { + items, + is_single_line: array.is_single_line, + was_originally_macro: true, + close_bracket_loc: array.close_bracket_loc, + ..Default::default() + }, + expr.loc, + ) + } + (BData::BIdentifier(id), _) => { if self.options.features.inlining { self.admit_const_value(id.r#ref, kind, has_default, expr); } + expr } - BData::BMissing(_) => {} + _ => expr, } } @@ -786,7 +796,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O BData::BObject(_) | BData::BArray(_) => { if Self::ALLOW_MACROS { if could_be_macro && let Some(value) = decl.value { - self.visit_binding_and_expr_for_macro(decl.binding, value, kind, false); + decl.value = Some(self.visit_binding_and_expr_for_macro( + decl.binding, + value, + kind, + false, + )); } } } diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 66605d045c58..fc9b2852bf1d 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -280,6 +280,12 @@ describe("a macro result that is bound to a name", () => { [`let { a, b } = obj(); a = 99; export default [a, b];`, [99, { c: 2 }]], [`const [u = 5, n = 6] = undefs(); export default [u, n];`, [5, null]], [`const { a = 10, b = 20, c = 30 } = holes(); export default [a, b, c];`, [10, 4, null]], + [`const key = () => "b"; const { a, [key()]: computed } = obj(); export default [a, computed];`, [1, { c: 2 }]], + [`const { ["x" + "x"]: joined, x } = keys(); export default [joined, x];`, [1, 5]], + [`const { 0: zero, x } = keys(); export default [zero, x];`, ["zero", 5]], + [`const { b: { c }, b } = obj(); export default [c, b];`, [2, { c: 2 }]], + [`const { p: { a }, q } = twice(); export default [a, q];`, [1, { a: 1, b: 2 }]], + [`const { list: [first], list } = obj(); export default [first, list];`, [1, [1, 2]]], [`const n = num(); const s = hello(); export default [n + n, s + s.length];`, [42, "Hello5"]], [`export default [obj().b.c, arr()[2][0], arr().length, hello().length, num() + num()];`, [2, 3, 3, 5, 42]], ]; From aaf264b71a2b6c6b1948ae31ad022db5c6172cb6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:45:12 +0000 Subject: [PATCH 3/6] js_parser: cut the comments on the macro const path to one line each --- src/ast/ast_result.rs | 10 ++-------- src/js_parser/visit/mod.rs | 19 ++++--------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index 18ac087c9c37..3f151352d46c 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -256,19 +256,13 @@ pub struct DynamicImportItem { } pub type DynamicImportAliases = ArrayHashMap; pub type NamedExports = StringArrayHashMap; -/// The value of a binding that the parser prints in place of each read of -/// that binding. The binding is a `const`, so it cannot change. The value is -/// a primitive, so a copy of it is the same value. -/// -/// An object or an array that a macro returned is admitted too. Each read of -/// such a binding makes a new object (#7196). +/// Printed in place of each read of a `const`. A macro object or array is copied per read (#7196). #[derive(Clone, Copy)] #[repr(transparent)] pub struct ConstValue(Expr); impl ConstValue { - /// `has_default` is true for a destructuring leaf that has a default: - /// `const [x = 5] = [undefined]` binds 5, not the `undefined` leaf. + /// `has_default`: a destructuring leaf with a default binds the default over `undefined`. #[inline] pub fn new(kind: LocalKind, has_default: bool, value: Expr) -> Option { let admit = kind == LocalKind::KConst diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index ab9beedf2f24..b50e0f70a7c6 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -649,12 +649,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O j } - /// Records the `const` leaves of a destructured macro result. Returns the - /// result without the properties and items that `binding` cannot read. - /// - /// A narrowed object or array is a new node. The node of `expr` can have - /// another reader: a macro can return one object at two places, and a - /// pattern can bind one key two times. + /// Records the `const` leaves of a destructured macro result and narrows it to the bound parts. pub(crate) fn visit_binding_and_expr_for_macro( &mut self, binding: Binding, @@ -750,8 +745,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// The only writer of `const_values`. `ConstValue::new` tests the kind and the - /// value. `visit_decl` tests the position. + /// The only writer of `const_values`. #[inline] fn admit_const_value(&mut self, ref_: Ref, kind: LocalKind, has_default: bool, value: Expr) { if let Some(value) = js_ast::ConstValue::new(kind, has_default, value) { @@ -770,9 +764,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O match decl.binding.data { BData::BIdentifier(id) => { let id_ref = id.r#ref; - // A declaration whose initializer held a macro call is exempt from the - // const-local prefix: `log(); const n = macro(); other(n)` gives the macro - // `other` the literal, and `let i = macro(); const n = 1;` still inlines `n`. + // A macro result is exempt from the const-local prefix and does not end it. let is_macro = Self::ALLOW_MACROS && could_be_macro; if kind == LocalKind::KConst && (is_macro || !self.vis_scope().is_after_const_local_prefix) @@ -1894,10 +1886,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O | StmtData::SDebugger(_) | StmtData::STypeScript(_) => continue, StmtData::SLocal(mut local) => { - // "using" / "await using" declarations have disposal - // side-effects on scope exit and are never in - // `const_values`. Skip them, so that the scan reaches - // the declarations below them. + // `using` has disposal side effects on scope exit and is never in `const_values`. if local.kind.is_using() { continue; } From 64f80c7735295e734703bf4db21273319ce0038e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:34:28 +0000 Subject: [PATCH 4/6] test: give the doors of the macro table one fixture and a time limit Each door built its own directory and ran the plain imports again. A debug build under load needed more than the default 5 seconds for one door. The doors now share one directory and one run of the plain imports, and have a limit of 60 seconds. A debug build runs each sixteenth generated module. --- test/bundler/transpiler/macro-test.test.ts | 91 +++++++++++++--------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index fc9b2852bf1d..9122c6fad8c2 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -1,5 +1,5 @@ import { escapeHTML } from "bun" with { type: "macro" }; -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; import { existsSync } from "node:fs"; import path from "node:path"; @@ -346,7 +346,7 @@ describe("a macro result that is bound to a name", () => { } // A debug build is 10 to 100 times slower, so it runs a sample of the generated modules, always the same one. - const every = isDebug || isASAN ? 8 : 1; + const every = isDebug || isASAN ? 16 : 1; const bodies = [...written.map(([body]) => body), ...generated.filter((_, i) => i % every === 0)]; const files: Record = { @@ -370,11 +370,27 @@ describe("a macro result that is bound to a name", () => { files[`macro/${i}.ts`] = `import { ${names} } from "../m.ts" with { type: "macro" };\n${body}\n`; }); - async function observe(cwd: string, dir: string, ext: string) { + // A door builds and loads each module, which takes a debug build more than the default 5 seconds under load. + const doorLimit = 60_000; + + // One directory serves each door. The doors read the same modules and write to their own folders. + let fixture: ReturnType; + // What each module exports with a plain import. + let plain: Awaited>; + beforeAll(async () => { + fixture = tempDir("macro-bound", files); + plain = await observe("plain", "ts"); + // Each written module has its value, and no generated module throws. + expect(Object.values(plain.seen).slice(0, written.length)).toEqual(written.map(([, exported]) => exported)); + expect(Object.values(plain.seen).filter(value => String(value).startsWith("threw "))).toEqual([]); + }, doorLimit); + afterAll(() => fixture[Symbol.dispose]()); + + async function observe(dir: string, ext: string) { await using proc = Bun.spawn({ cmd: [bunExe(), "observe.ts", dir, ext, String(bodies.length)], env: bunEnv, - cwd, + cwd: String(fixture), stdout: "pipe", stderr: "pipe", }); @@ -384,46 +400,45 @@ describe("a macro result that is bound to a name", () => { return { seen: Object.fromEntries(bodies.map((body, i) => [`${i}: ${body}`, seen[i]])), stderr, exitCode }; } - // The plain import gives each written module its value, and no generated module throws there. - async function oracle(cwd: string) { - const plain = await observe(cwd, "plain", "ts"); - expect(Object.values(plain.seen).slice(0, written.length)).toEqual(written.map(([, exported]) => exported)); - expect(Object.values(plain.seen).filter(value => String(value).startsWith("threw "))).toEqual([]); - return plain; - } - - test.concurrent("bun run", async () => { - using dir = tempDir("macro-bound-run", files); - expect(await observe(String(dir), "macro", "ts")).toEqual(await oracle(String(dir))); - }); + test.concurrent( + "bun run", + async () => { + expect(await observe("macro", "ts")).toEqual(plain); + }, + doorLimit, + ); // `--no-bundle --target=bun` and `--minify` turn the inliner on. The plain build leaves it off. test.concurrent.each([ ["bun build", []], ["bun build --no-bundle", ["--no-bundle"]], ["bun build --minify", ["--minify"]], - ])("%s", async (_, flags) => { - using dir = tempDir("macro-bound-build", files); - await using build = Bun.spawn({ - cmd: [ - bunExe(), - "build", - ...flags, - ...bodies.map((_, i) => `./macro/${i}.ts`), - "--target=bun", - "--outdir=out", - // Without a name pattern, 9 or more entry points go to `out/macro/` (#38732). - "--entry-naming=[name].[ext]", - ], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); - expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); - expect(await observe(String(dir), "out", "js")).toEqual(await oracle(String(dir))); - }); + ])( + "%s", + async (door, flags) => { + const outdir = door.replaceAll(" ", "-"); + await using build = Bun.spawn({ + cmd: [ + bunExe(), + "build", + ...flags, + ...bodies.map((_, i) => `./macro/${i}.ts`), + "--target=bun", + "--outdir=" + outdir, + // Without a name pattern, 9 or more entry points go to a `macro/` folder (#38732). + "--entry-naming=[name].[ext]", + ], + env: bunEnv, + cwd: String(fixture), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); + expect(await observe(outdir, "js")).toEqual(plain); + }, + doorLimit, + ); // The inliner takes a `const` primitive from a macro at each position. It takes no `let` and no `var`: the // program can assign to them. From 661da079661bb3f2162c9b9efd192901d7d890bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:34:28 +0000 Subject: [PATCH 5/6] js_parser: admit the leaves beside a computed pattern key, and drop an assertion on the symbol kind A pattern with a computed key keeps each property of the macro result. Its other leaves are still `const` primitives, so the inliner takes them, as it did before: `const { a, [k]: v } = cfg(); other(a)` gives the macro `other` the literal. `admit_const_value` asserted that the binding is a constant symbol. An export that `Bun.Transpiler` injects into an `export const` is declared with another kind, so a debug build panicked on `exports: { replace: { a: ["N", true] } }`. --- src/js_parser/visit/mod.rs | 20 +++++++++++++++----- test/bundler/transpiler/macro-test.test.ts | 7 ++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index b50e0f70a7c6..6f33d2cb4a06 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -660,17 +660,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O match (binding.data, expr.data) { (BData::BObject(bound), ExprData::EObject(object)) if object.was_originally_macro => { let bound = bound.get(); - // A rest element, or a key that is not one flat string, can read any property. + let is_flat = + |key: &Expr| matches!(key.data, ExprData::EString(key) if key.next.is_none()); + let mut narrow = true; for property in bound.properties() { - if property.flags.contains(flags::Property::IsSpread) - || !matches!(property.key.data, ExprData::EString(key) if key.next.is_none()) - { + if property.flags.contains(flags::Property::IsSpread) { return expr; } + // A key that is not one flat string can read any property. + narrow &= is_flat(&property.key); } let mut kept = G::PropertyList::init_capacity(bound.properties().len()); let mut kept_from: BumpVec<'a, u32> = BumpVec::new_in(self.arena); for property in bound.properties() { + if !is_flat(&property.key) { + continue; + } let Some(name) = property.key.as_string_literal(self.arena) else { continue; }; @@ -683,6 +688,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O kind, property.default_value.is_some(), ); + if !narrow { + continue; + } match kept_from.iter().position(|&from| from == query.i) { // The pattern binds this key again. Keep the whole value. Some(at) => kept[at].value = Some(query.expr), @@ -695,6 +703,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } + if !narrow { + return expr; + } self.new_expr( E::Object { properties: kept, @@ -749,7 +760,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O #[inline] fn admit_const_value(&mut self, ref_: Ref, kind: LocalKind, has_default: bool, value: Expr) { if let Some(value) = js_ast::ConstValue::new(kind, has_default, value) { - debug_assert!(self.symbols[ref_.inner_index() as usize].kind == SymbolKind::Constant); self.const_values.put(ref_, value).expect("oom"); } } diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 9122c6fad8c2..ea265be7c279 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -443,13 +443,14 @@ describe("a macro result that is bound to a name", () => { // The inliner takes a `const` primitive from a macro at each position. It takes no `let` and no `var`: the // program can assign to them. test.concurrent("the inliner takes a const primitive and no let or var", async () => { - const from = `import { num, hello, no, pagePath } from "./m.ts" with { type: "macro" };\n`; + const from = `import { num, hello, no, pagePath, pair } from "./m.ts" with { type: "macro" };\n`; using dir = tempDir("macro-bound-inline", { "m.ts": ` export const num = () => 21; export const hello = () => "Hello"; export const no = () => false; export const pagePath = () => "./page.ts"; + export const pair = () => ({ a: 1, b: 2 }); `, "page.ts": `export default "the page";`, "first.ts": from + `const n = num(); const s = hello(); export default [n, n, s, s];`, @@ -459,6 +460,7 @@ describe("a macro result that is bound to a name", () => { "after-let.ts": from + `let i = num(); const k = 5; export default [i, k, k];`, "flag-const.ts": from + `const DEV = no(); export default DEV ? "dev" : "prod";`, "flag-var.ts": from + `var DEV = no(); export default DEV ? "dev" : "prod";`, + "computed.ts": from + `const key = () => "b"; const { a, [key()]: v } = pair(); export default [a, a, v];`, "import-const.ts": from + `const page = pagePath(); export default import(page);`, "import-let.ts": from + `let page = pagePath(); export default import(page);`, }); @@ -470,6 +472,7 @@ describe("a macro result that is bound to a name", () => { "after-let", "flag-const", "flag-var", + "computed", "import-const", "import-let", ]; @@ -501,6 +504,8 @@ describe("a macro result that is bound to a name", () => { expect(await out("after-let")).toContain(`[i, 5, 5]`); expect(await out("flag-const")).toContain(`= "prod"`); expect(await out("flag-var")).toContain(`DEV ? "dev" : "prod"`); + // A computed key keeps each property of the object. The leaf beside it is still a `const` primitive. + expect(await out("computed")).toContain(`[1, 1, v]`); // The bundler follows an `import()` of a `const` string. It cannot follow a `let`. expect(await out("import-const")).toContain(`"the page"`); expect(await out("import-let")).toContain(`import(page)`); From 22b20d8f6b2cb22e8df93d53012955d8b5813286 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:11:33 +0000 Subject: [PATCH 6/6] test: run the doors of the macro table one at a time, with no time limit The four doors ran at the same time and had a limit of 60 seconds. Each door starts a build and a run, so they slowed each other down on a debug build. One at a time, a door takes about one second there. Two more written modules bind the whole key before the nested pattern. Only that order fails when the prune does not keep the whole value of a key that the pattern binds again. --- test/bundler/transpiler/macro-test.test.ts | 68 ++++++++++------------ 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index ea265be7c279..7083841e12b7 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -286,6 +286,8 @@ describe("a macro result that is bound to a name", () => { [`const { b: { c }, b } = obj(); export default [c, b];`, [2, { c: 2 }]], [`const { p: { a }, q } = twice(); export default [a, q];`, [1, { a: 1, b: 2 }]], [`const { list: [first], list } = obj(); export default [first, list];`, [1, [1, 2]]], + [`const { list, list: [first] } = obj(); export default [first, list];`, [1, [1, 2]]], + [`const { q, q: { a } } = twice(); export default [a, q];`, [1, { a: 1, b: 2 }]], [`const n = num(); const s = hello(); export default [n + n, s + s.length];`, [42, "Hello5"]], [`export default [obj().b.c, arr()[2][0], arr().length, hello().length, num() + num()];`, [2, 3, 3, 5, 42]], ]; @@ -370,9 +372,6 @@ describe("a macro result that is bound to a name", () => { files[`macro/${i}.ts`] = `import { ${names} } from "../m.ts" with { type: "macro" };\n${body}\n`; }); - // A door builds and loads each module, which takes a debug build more than the default 5 seconds under load. - const doorLimit = 60_000; - // One directory serves each door. The doors read the same modules and write to their own folders. let fixture: ReturnType; // What each module exports with a plain import. @@ -383,7 +382,7 @@ describe("a macro result that is bound to a name", () => { // Each written module has its value, and no generated module throws. expect(Object.values(plain.seen).slice(0, written.length)).toEqual(written.map(([, exported]) => exported)); expect(Object.values(plain.seen).filter(value => String(value).startsWith("threw "))).toEqual([]); - }, doorLimit); + }); afterAll(() => fixture[Symbol.dispose]()); async function observe(dir: string, ext: string) { @@ -400,45 +399,38 @@ describe("a macro result that is bound to a name", () => { return { seen: Object.fromEntries(bodies.map((body, i) => [`${i}: ${body}`, seen[i]])), stderr, exitCode }; } - test.concurrent( - "bun run", - async () => { - expect(await observe("macro", "ts")).toEqual(plain); - }, - doorLimit, - ); + // The doors run one at a time: each starts several processes, and a debug build is slow under load. + test("bun run", async () => { + expect(await observe("macro", "ts")).toEqual(plain); + }); // `--no-bundle --target=bun` and `--minify` turn the inliner on. The plain build leaves it off. - test.concurrent.each([ + test.each([ ["bun build", []], ["bun build --no-bundle", ["--no-bundle"]], ["bun build --minify", ["--minify"]], - ])( - "%s", - async (door, flags) => { - const outdir = door.replaceAll(" ", "-"); - await using build = Bun.spawn({ - cmd: [ - bunExe(), - "build", - ...flags, - ...bodies.map((_, i) => `./macro/${i}.ts`), - "--target=bun", - "--outdir=" + outdir, - // Without a name pattern, 9 or more entry points go to a `macro/` folder (#38732). - "--entry-naming=[name].[ext]", - ], - env: bunEnv, - cwd: String(fixture), - stdout: "pipe", - stderr: "pipe", - }); - const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); - expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); - expect(await observe(outdir, "js")).toEqual(plain); - }, - doorLimit, - ); + ])("%s", async (door, flags) => { + const outdir = door.replaceAll(" ", "-"); + await using build = Bun.spawn({ + cmd: [ + bunExe(), + "build", + ...flags, + ...bodies.map((_, i) => `./macro/${i}.ts`), + "--target=bun", + "--outdir=" + outdir, + // Without a name pattern, 9 or more entry points go to a `macro/` folder (#38732). + "--entry-naming=[name].[ext]", + ], + env: bunEnv, + cwd: String(fixture), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildErr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect({ buildErr, buildExit }).toEqual({ buildErr: "", buildExit: 0 }); + expect(await observe(outdir, "js")).toEqual(plain); + }); // The inliner takes a `const` primitive from a macro at each position. It takes no `let` and no `var`: the // program can assign to them.