From 290dc7a3d5bb85815a434b771a6bf375ec7d97d7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 04:34:59 +0000 Subject: [PATCH 1/2] parser: don't panic when export default interface starts an expression or label "export default interface=2" parsed the inner statement as an expression statement and wrapped it in S.ExportDefault, an AST shape the printer rejects with "Internal error: unexpected export default stmt data". Handle an SExpr result by keeping the statement (matching esbuild) and report a syntax error for a labeled statement instead of panicking. --- src/js_parser/parse/parse_stmt.rs | 26 ++++++++++++++++++--- test/bundler/transpiler/transpiler.test.js | 27 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 0462868c339..e18f608918b 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1031,8 +1031,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let default_name: LocRef = 'default_name_getter: { match &stmt.data { - // This was just a type annotation - js_ast::StmtData::STypeScript(_) => { + // This was just a type annotation, or "interface" turned + // out to start an expression statement instead of an + // interface declaration ("export default interface + 1"). + // Keep the statement as-is like esbuild does; using + // "interface" as an identifier is reported separately + // because modules are always in strict mode. + js_ast::StmtData::STypeScript(_) | js_ast::StmtData::SExpr(_) => { return Ok(stmt); } @@ -1052,7 +1057,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; } } - _ => {} + // Anything else cannot be a default export value, e.g. a + // labeled statement: "export default interface: 0". Report + // a syntax error instead of building an S.ExportDefault + // that the visit and print passes don't support. + _ => { + let r = js_lexer::range_of_identifier(p.source, stmt.loc); + p.log().add_range_error_fmt( + Some(p.source), + r, + format_args!( + "Unexpected \"{}\"", + bstr::BStr::new(p.source.text_for_range(r)) + ), + ); + return Err(err!("SyntaxError")); + } } p.create_default_name(default_loc).expect("unreachable") diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 6b270d6dd44..8959cb0f9f5 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -194,6 +194,33 @@ describe("Bun.Transpiler", () => { err("module = (t) => 0 Foo { () => () => 0 }", 'Expected ";" but found "Foo"'); }); + it("export default interface that is not an interface declaration does not crash", () => { + const exp = ts.expectPrinted_; + const err = ts.expectParseError; + const reservedWord = '"interface" is a reserved word and cannot be used in strict mode'; + + // "interface" turns out to start an expression, not an interface declaration. + // Modules are always strict mode, so using it as an identifier is an error. + err("export default interface=2", reservedWord); + err("export default interface + 1", reservedWord); + err("export default interface.foo()", reservedWord); + + // A labeled statement cannot be a default export. + err("export default interface: 2", 'Unexpected "interface"'); + + // The exact fuzz repro: tsx loader, no trailing newline. + expect(() => transpiler.transformSync("export default interface=2")).toThrow(reservedWord); + + // Same shapes through the plain JavaScript loader must not crash either. + const js = new Bun.Transpiler({ loader: "js" }); + expect(() => js.transformSync("export default interface=2")).toThrow(reservedWord); + expect(() => js.transformSync("export default interface: 2")).toThrow('Unexpected "interface"'); + + // Real interface declarations still parse and get erased. + exp("export default interface Foo {}", ""); + exp("export default interface Foo { bar(): void }\nexport const x = 1;", "export const x = 1;\n"); + }); + it("should parse empty type parameters", () => { const exp = ts.expectPrinted_; const err = ts.expectParseError; From f680f9904d3e59b4ec87d2fc1cf30808a1ee98d9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 05:16:41 +0000 Subject: [PATCH 2/2] parser: report a syntax error for every non-declaration statement after export default interface An expression statement result relied on the visit pass flagging "interface" as a strict-mode reserved word, but that check only covers identifier expressions, not arrow parameter bindings, so "export default interface => 1" would have silently dropped the export. Route every non-function/class/interface result through the same Unexpected "interface" error instead. --- src/js_parser/parse/parse_stmt.rs | 21 +++++++++---------- test/bundler/transpiler/transpiler.test.js | 24 +++++++++++----------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index e18f608918b..f1d6f560b2e 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1031,13 +1031,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let default_name: LocRef = 'default_name_getter: { match &stmt.data { - // This was just a type annotation, or "interface" turned - // out to start an expression statement instead of an - // interface declaration ("export default interface + 1"). - // Keep the statement as-is like esbuild does; using - // "interface" as an identifier is reported separately - // because modules are always in strict mode. - js_ast::StmtData::STypeScript(_) | js_ast::StmtData::SExpr(_) => { + // This was just a type annotation + js_ast::StmtData::STypeScript(_) => { return Ok(stmt); } @@ -1057,10 +1052,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; } } - // Anything else cannot be a default export value, e.g. a - // labeled statement: "export default interface: 0". Report - // a syntax error instead of building an S.ExportDefault - // that the visit and print passes don't support. + // "interface" turned out not to start an interface + // declaration: the nested statement came back as an + // expression statement ("export default interface = 2", + // "export default interface => 1") or a labeled statement + // ("export default interface: 0"). None of these can be a + // default export value, so report a syntax error instead of + // building an S.ExportDefault that the visit and print + // passes don't support. _ => { let r = js_lexer::range_of_identifier(p.source, stmt.loc); p.log().add_range_error_fmt( diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 8959cb0f9f5..c4f58b97306 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -197,24 +197,24 @@ describe("Bun.Transpiler", () => { it("export default interface that is not an interface declaration does not crash", () => { const exp = ts.expectPrinted_; const err = ts.expectParseError; - const reservedWord = '"interface" is a reserved word and cannot be used in strict mode'; + const unexpected = 'Unexpected "interface"'; - // "interface" turns out to start an expression, not an interface declaration. - // Modules are always strict mode, so using it as an identifier is an error. - err("export default interface=2", reservedWord); - err("export default interface + 1", reservedWord); - err("export default interface.foo()", reservedWord); - - // A labeled statement cannot be a default export. - err("export default interface: 2", 'Unexpected "interface"'); + // "interface" turns out to start an expression or a labeled statement, not an + // interface declaration. None of these can be a default export value. + err("export default interface=2", unexpected); + err("export default interface + 1", unexpected); + err("export default interface.foo()", unexpected); + err("export default interface => 1", unexpected); + err("export default interface: 2", unexpected); // The exact fuzz repro: tsx loader, no trailing newline. - expect(() => transpiler.transformSync("export default interface=2")).toThrow(reservedWord); + expect(() => transpiler.transformSync("export default interface=2")).toThrow(unexpected); // Same shapes through the plain JavaScript loader must not crash either. const js = new Bun.Transpiler({ loader: "js" }); - expect(() => js.transformSync("export default interface=2")).toThrow(reservedWord); - expect(() => js.transformSync("export default interface: 2")).toThrow('Unexpected "interface"'); + expect(() => js.transformSync("export default interface=2")).toThrow(unexpected); + expect(() => js.transformSync("export default interface => 1")).toThrow(unexpected); + expect(() => js.transformSync("export default interface: 2")).toThrow(unexpected); // Real interface declarations still parse and get erased. exp("export default interface Foo {}", "");