From c4473c3fe43e8f60468d00b8a2ce272aa5bb1f49 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:42:05 +0000 Subject: [PATCH] parser: fix "Scope mismatch while visiting" panic from macro tagged templates When a tagged template's tag resolves to a macro import, every macro dispatch path in e_template (dead control flow, macros disabled, node_modules, macro call failure) replaced the expression and returned before visiting the template parts. Scopes recorded during the parse pass for arrows/functions inside the interpolations were then never consumed by the visit pass, panicking with "Scope mismatch while visiting" on the next scope push. Visit the parts right after the tag, before the macro dispatch, the same way e_call visits its arguments before macro handling. Also syncs Cargo.lock with bun_bin's manifest (bstr was added to Cargo.toml without the lock update). --- Cargo.lock | 1 + src/js_parser/visit/visit_expr.rs | 21 +++-- .../transpiler/scope-mismatch-panic.test.ts | 85 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cebe113107..b1b36a6808d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,7 @@ dependencies = [ name = "bun_bin" version = "0.0.0" dependencies = [ + "bstr", "bun_alloc", "bun_core", "bun_crash_handler", diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 7bdec3f8e7a..44d7197cbd3 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -707,9 +707,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let expr = *e; let _ = in_; let mut e_ = expr.data.e_template().expect("infallible: variant checked"); - if let Some(tag) = e_.tag { + if e_.tag.is_some() { p.visit_expr(e_.tag.as_mut().unwrap()); + } + + // Visit the interpolation values before the macro dispatch below: its + // early-return paths (dead code, macros disabled, node_modules, macro + // failure) replace the whole expression without visiting the parts, + // which would leave the scopes recorded during the parse pass for any + // arrows/functions inside them unconsumed and trip "Scope mismatch + // while visiting" on the next scope push. Mirrors e_call, which visits + // its arguments before macro handling. + // `Template.parts` is arena-owned (Zig: `[]E.TemplatePart`). + for part in e_.parts_mut().iter_mut() { + p.visit_expr(&mut part.value); + } + if let Some(tag) = e_.tag { if Self::ALLOW_MACROS { let ref_ = match &e_.tag.unwrap().data { Data::EImportIdentifier(ident) => Some(ident.ref_), @@ -797,11 +811,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // `Template.parts` is arena-owned (Zig: `[]E.TemplatePart`). - for part in e_.parts_mut().iter_mut() { - p.visit_expr(&mut part.value); - } - // When mangling, inline string values into the template literal. Note that // it may no longer be a template literal after this point (it may turn into // a plain string literal instead). diff --git a/test/bundler/transpiler/scope-mismatch-panic.test.ts b/test/bundler/transpiler/scope-mismatch-panic.test.ts index 0f554b408f3..a7712c98037 100644 --- a/test/bundler/transpiler/scope-mismatch-panic.test.ts +++ b/test/bundler/transpiler/scope-mismatch-panic.test.ts @@ -147,6 +147,91 @@ describe("TypeScript 'declare' statements discard scopes of dropped statements", }); }); +describe("macro tagged templates visit their interpolations", () => { + // When a tagged template's tag resolves to a macro import, the macro dispatch + // replaces the whole expression (dead code, macros disabled, or the macro call + // failing) without visiting the template parts. Scopes recorded during the parse + // pass for arrows/functions inside the interpolations were then never consumed by + // the visit pass, panicking with "Scope mismatch while visiting" on the next scope. + const macroFile = `export function mac(...args: any[]) { return "from-macro"; }`; + + test.concurrent("tagged template macro with arrow interpolation reports the macro error", async () => { + using dir = tempDir("macro-template-scope", { + "macro.ts": macroFile, + "index.ts": ` +import { mac } from './macro.ts' with { type: 'macro' }; +const r = mac\`a\${() => { let q = 1; }}b\`; +function g() { { let y = 1; } } +g();`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Must fail with the intended transpiler error, not a scope mismatch panic. + expect(stderr).not.toContain("Scope mismatch"); + expect(stderr).toContain("template literal macro invocations are not supported"); + expect(exitCode).not.toBe(0); + }); + + test.concurrent("tagged template macro with arrow interpolation in dead code is erased", async () => { + using dir = tempDir("macro-template-scope-dead", { + "macro.ts": macroFile, + "index.ts": ` +import { mac } from './macro.ts' with { type: 'macro' }; +false && mac\`a\${() => { let q = 1; }}b\`; +function g() { { let y = 2; console.log("ran", y); } } +g();`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Scope mismatch"); + expect(stdout).toBe("ran 2\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("member-expression macro tag with function interpolation reports the macro error", async () => { + using dir = tempDir("macro-template-scope-ns", { + "macro.ts": macroFile, + "index.ts": ` +import * as macros from './macro.ts' with { type: 'macro' }; +macros.mac\`x\${function inner() { let z = 3; }}y\`; +class C { m() { { let w = 4; } } } +new C().m();`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Scope mismatch"); + expect(stderr).toContain("template literal macro invocations are not supported"); + expect(exitCode).not.toBe(0); + }); +}); + describe("dropped TypeScript class members discard scopes", () => { // Decorators and computed keys are parsed before the parser knows whether the class // member they belong to will be kept. When the member is then dropped (an overload