Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 15 additions & 6 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_),
Expand Down Expand Up @@ -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).
Expand Down
85 changes: 85 additions & 0 deletions test/bundler/transpiler/scope-mismatch-panic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading