diff --git a/src/js_printer.zig b/src/js_printer.zig index 6d7c2b086b8..5d516b18d38 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -1926,6 +1926,10 @@ fn NewPrinter( return printClauseItemAs(p, item, .@"export"); } + fn printExportFromClauseItem(p: *Printer, item: js_ast.ClauseItem) void { + return printClauseItemAs(p, item, .export_from); + } + fn printClauseItemAs(p: *Printer, item: js_ast.ClauseItem, comptime as: @Type(.enum_literal)) void { const name = p.renamer.nameForSymbol(item.name.ref.?); @@ -1955,6 +1959,23 @@ fn NewPrinter( p.addSourceMapping(item.alias_loc); p.printClauseAlias(item.alias); } + } else if (comptime as == .export_from) { + // In `export { x } from 'mod'`, the "name" on the left of `as` + // refers to an export of the other module, not a local binding. + // It's stored as the raw source text on `item.original_name` + // (ECMAScript allows this to be a string literal like `"a b c"`) + // and the item's ref points to a synthesized intermediate symbol + // whose display name may be mangled by a minifier. We must print + // `original_name` via `printClauseAlias` so string literals stay + // quoted and mangling can't corrupt the foreign-module name. + const from_name = if (item.original_name.len > 0) item.original_name else name; + p.printClauseAlias(from_name); + + if (!strings.eql(from_name, item.alias)) { + p.print(" as "); + p.addSourceMapping(item.alias_loc); + p.printClauseAlias(item.alias); + } } else { @compileError("Unknown as"); } @@ -4216,7 +4237,7 @@ fn NewPrinter( p.printNewline(); p.printIndent(); } - p.printExportClauseItem(item); + p.printExportFromClauseItem(item); } if (!s.is_single_line) { diff --git a/test/regression/issue/29242.test.ts b/test/regression/issue/29242.test.ts new file mode 100644 index 00000000000..415aeb47e20 --- /dev/null +++ b/test/regression/issue/29242.test.ts @@ -0,0 +1,125 @@ +// https://github.com/oven-sh/bun/issues/29242 +// +// The parser handles string-literal names in `export { ... } from 'mod'` +// clauses, but when transpiling without bundling the printer dropped the +// quotes around the local name, producing invalid syntax that JSC then +// rejected: +// +// export { "a b c" } from './b.mjs'; // input +// export { a b c } from './b.mjs'; // old output — SyntaxError +// export { "a b c" } from './b.mjs'; // fixed output +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test.concurrent("re-export with string literal local name (export { 'a b c' } from 'mod')", async () => { + using dir = tempDir("issue-29242-bare", { + "a.mjs": `export { "a b c" } from './b.mjs';`, + "b.mjs": `const a = 1;\nexport { a as "a b c" };`, + "main.mjs": `import { "a b c" as a } from './a.mjs';\nconsole.log(a);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("SyntaxError"); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent("re-export aliasing from string literal to identifier", async () => { + using dir = tempDir("issue-29242-alias", { + "a.mjs": `export { "a b c" as a } from './b.mjs';`, + "b.mjs": `const a = 1;\nexport { a as "a b c" };`, + "main.mjs": `import { a } from './a.mjs';\nconsole.log(a);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("SyntaxError"); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent("re-export aliasing string literal to string literal", async () => { + using dir = tempDir("issue-29242-both", { + "a.mjs": `export { "a b c" as "x y z" } from './b.mjs';`, + "b.mjs": `const a = 1;\nexport { a as "a b c" };`, + "main.mjs": `import { "x y z" as a } from './a.mjs';\nconsole.log(a);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("SyntaxError"); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent.each([ + [`export { "a b c" } from './mod';`, [`"a b c"`]], + [`export { "a b c" as a } from './mod';`, [`"a b c"`]], + [`export { "a b c" as "x y z" } from './mod';`, [`"a b c"`, `"x y z"`]], + [`export { plain, "a b c" as aliased } from './mod';`, [`"a b c"`, `plain`]], +])("transpiler preserves string literal names in export-from clauses: %s", async (source, mustContain) => { + // Direct test of the printer: transpile without bundling and confirm the + // quotes around the local names are preserved. + using dir = tempDir("issue-29242-printer", { + "input.ts": source, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "input.ts", "--target=bun", "--no-bundle"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("SyntaxError"); + for (const frag of mustContain) { + expect(stdout).toContain(frag); + } + // No unquoted `a b c` or `x y z` anywhere (guard quote-style agnostic). + expect(stdout).not.toMatch(/(^|[^"'])a b c(?!["'])/); + expect(stdout).not.toMatch(/(^|[^"'])x y z(?!["'])/); + expect(exitCode).toBe(0); +}); + +test.concurrent("transpiler preserves string literal names under --minify-identifiers", async () => { + // Regression for a subtlety: the export-from clause's left-side symbol + // is a synthesized intermediate that a minifier may rename. Printing + // `original_name` (the raw source text) keeps re-exports correct. + using dir = tempDir("issue-29242-minify", { + "input.ts": [`export { "a b c" as aliased } from './mod';`, `export { foo as bar } from './mod';`].join("\n"), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "input.ts", "--target=bun", "--no-bundle", "--minify-identifiers"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("SyntaxError"); + expect(stdout).toContain(`"a b c" as aliased`); + expect(stdout).toContain(`foo as bar`); + expect(stdout).not.toMatch(/(^|[^"'])a b c(?!["'])/); + expect(exitCode).toBe(0); +});