From d76fd2e258fc22b0195aa221120c2ddb74d0a283 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:47:33 +0000 Subject: [PATCH 1/2] lexer: end a JSX pragma value at a line break in block comments too A JSX pragma (`@jsx`, `@jsxFrag`, `@jsxRuntime`, `@jsxImportSource`) on a line of its own in a `/* */` comment kept the line break in its value. `PragmaArg::scan` ended the value at a line break only for `//` comments. The value now ends at white space or at a line break in both comment kinds. A pragma name with no value is not a pragma, so it does not reset the value of an earlier pragma. The runtime transpiler cache version goes from 35 to 36, so that output cached by an older build is not used again. --- src/js_parser/lexer.rs | 91 +++--------- src/jsc/RuntimeTranspilerCache.rs | 3 +- test/bundler/bundler_jsx.test.ts | 164 +++++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 94 ++++++++++++ 4 files changed, 280 insertions(+), 72 deletions(-) diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 26f624f19c00..de5fcaeecab0 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -1978,7 +1978,6 @@ impl<'a> Lexer<'a> { let offset = self.scan_pragma( self.start + i + (text.len() - rest.len()), chunk, - CommentKind::MultiLine, PureAnnotation::Allow, ); @@ -2117,12 +2116,7 @@ impl<'a> Lexer<'a> { }; first_marker = false; let chunk = js_ast::StoreStr::new(self.remaining()); - self.current += self.scan_pragma( - pragma_trigger_pos, - chunk.slice(), - CommentKind::SingleLine, - pure, - ); + self.current += self.scan_pragma(pragma_trigger_pos, chunk.slice(), pure); continue; } _ => { @@ -2150,11 +2144,8 @@ impl<'a> Lexer<'a> { &mut self, offset_for_errors: usize, chunk: &[u8], - comment: CommentKind, pure: PureAnnotation, ) -> usize { - // A `//` comment ends at the line break, so a pragma argument stops there. - let allow_newline = comment == CommentKind::SingleLine; if pure == PureAnnotation::Allow && !self.has_pure_comment_before { if strings::has_prefix_with_word_boundary(chunk, b"__PURE__") { self.has_pure_comment_before = true; @@ -2163,61 +2154,29 @@ impl<'a> Lexer<'a> { } if strings::has_prefix_with_word_boundary(chunk, b"jsx") { - if let Some(span) = - PragmaArg::scan(self.start + offset_for_errors, b"jsx", chunk, allow_newline) - { + if let Some(span) = PragmaArg::scan(self.start + offset_for_errors, b"jsx", chunk) { self.jsx_pragma._jsx = span; - return "jsx".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + return "jsx".len() + usize::try_from(span.range.len).expect("int cast"); } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxFrag") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxFrag", - chunk, - allow_newline, - ) { + if let Some(span) = PragmaArg::scan(self.start + offset_for_errors, b"jsxFrag", chunk) { self.jsx_pragma._jsx_frag = span; - return "jsxFrag".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + return "jsxFrag".len() + usize::try_from(span.range.len).expect("int cast"); } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxRuntime") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxRuntime", - chunk, - allow_newline, - ) { + if let Some(span) = + PragmaArg::scan(self.start + offset_for_errors, b"jsxRuntime", chunk) + { self.jsx_pragma._jsx_runtime = span; - return "jsxRuntime".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + return "jsxRuntime".len() + usize::try_from(span.range.len).expect("int cast"); } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxImportSource") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxImportSource", - chunk, - allow_newline, - ) { + if let Some(span) = + PragmaArg::scan(self.start + offset_for_errors, b"jsxImportSource", chunk) + { self.jsx_pragma._jsx_import_source = span; return "jsxImportSource".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + + usize::try_from(span.range.len).expect("int cast"); } } else if chunk.len() > " sourceMappingURL=".len() && chunk.starts_with(b" sourceMappingURL=") @@ -3452,12 +3411,6 @@ pub(crate) fn latin1_identifier_continue_length_scalar(name: &[u8]) -> usize { name.len() } -#[derive(Clone, Copy, PartialEq, Eq)] -enum CommentKind { - SingleLine, - MultiLine, -} - /// Whether a `__PURE__` match at this position marks the next call. #[derive(Clone, Copy, PartialEq, Eq)] enum PureAnnotation { @@ -3468,10 +3421,6 @@ enum PureAnnotation { pub struct PragmaArg; impl PragmaArg { - pub(crate) fn is_newline(c: CodePoint) -> bool { - c == 0x0D || c == 0x0A || c == 0x2028 || c == 0x2029 - } - // These can be extremely long, so we use SIMD. /// "//# sourceMappingURL=data:/adspaoksdpkz" /// ^^^^^^^^^^^^^^^^^^ @@ -3518,12 +3467,7 @@ impl PragmaArg { PREFIX as usize + url_len // Correct total length } - pub(crate) fn scan( - offset_: usize, - pragma: &[u8], - text_: &[u8], - allow_newline: bool, - ) -> Option { + pub(crate) fn scan(offset_: usize, pragma: &[u8], text_: &[u8]) -> Option { let mut text = &text_[pragma.len()..]; let mut iter = CodepointIterator::init(text); @@ -3548,8 +3492,9 @@ impl PragmaArg { iter = CodepointIterator::init(text); let _ = iter.next(&mut cursor); + // A line break ends the value too. esbuild's `scanForPragmaArg` stops at white space only. let mut i: usize = 0; - while !is_whitespace(cursor.c) && (!allow_newline || !Self::is_newline(cursor.c)) { + while !strings::is_js_whitespace(cursor.c as u32) { i += cursor.width as usize; if i >= text.len() { break; @@ -3560,6 +3505,10 @@ impl PragmaArg { } } + if i == 0 { + return None; + } + Some(js_ast::Span { range: Range { len: i32::try_from(i).expect("int cast"), diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ac8f5b5f8124..d9aa45ed5848 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -65,7 +65,8 @@ bun_core::declare_scope!(cache, visible); /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. /// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double. -const EXPECTED_VERSION: u32 = 35; +/// Version 36: A JSX pragma value in a block comment ends at a line break. +const EXPECTED_VERSION: u32 = 36; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/bundler_jsx.test.ts b/test/bundler/bundler_jsx.test.ts index d1ed6f089a0b..9659de553bf7 100644 --- a/test/bundler/bundler_jsx.test.ts +++ b/test/bundler/bundler_jsx.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { join } from "path"; import { BundlerTestInput, itBundled } from "./expectBundled"; const helpers = { @@ -527,6 +528,169 @@ describe("bundler", () => { }, }); + // The value of a pragma ends at the line break. So a pragma can have a line + // of its own in a block comment, and the next line can hold another pragma. + describe("pragma on its own line of a block comment", () => { + const lines = (...lines: string[]) => lines.join("\n") + "\n"; + const h = { "h.ts": lines(`export const h = (tag: string) => "h: " + tag;`, `export const F = "F";`) }; + const myRuntime = { + "my/jsx-dev-runtime.js": `export const jsxDEV = tag => "my runtime: " + tag;`, + "my/jsx-runtime.js": `export const jsx = tag => "my runtime: " + tag;`, + }; + // The first file of each case is its entry point. + const cases: Record; stdout: string }> = { + ImportSource: { + files: { + "index.jsx": lines("/**", " * @jsxImportSource ./my", " */", "console.log();"), + ...myRuntime, + }, + stdout: "my runtime: a", + }, + Factory: { + files: { + "index.tsx": lines( + "/** @jsxRuntime classic */", + "/**", + " * @jsx h", + " */", + `import { h } from "./h";`, + "console.log();", + ), + ...h, + }, + stdout: "h: a", + }, + Fragment: { + files: { + "index.tsx": lines( + "/** @jsxRuntime classic */", + "/** @jsx h */", + "/**", + " * @jsxFrag F", + " */", + `import { F, h } from "./h";`, + "console.log(<>);", + ), + ...h, + }, + stdout: "h: F", + }, + // With the first comment, a build that does not follow `@jsxRuntime classic` imports ./my and not react. + Runtime: { + files: { + "index.tsx": lines( + "/** @jsxImportSource ./my */", + "/**", + " * @jsxRuntime classic", + " * @jsx h", + " */", + `import { h } from "./h";`, + "console.log();", + ), + ...h, + ...myRuntime, + }, + stdout: "h: a", + }, + FactoryWithRuntimeFromTsconfig: { + files: { + "index.tsx": lines("/**", " * @jsx h", " */", `import { h } from "./h";`, "console.log();"), + "tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react" } }), + ...h, + }, + stdout: "h: a", + }, + }; + const inRoot = (files: Record) => + Object.fromEntries(Object.entries(files).map(([file, source]) => ["/" + file, source])); + + for (const [name, { files, stdout }] of Object.entries(cases)) { + itBundled(`jsx/PragmaOwnLine${name}`, { + files: inRoot(files), + target: "bun", + run: { stdout }, + }); + + test.concurrent(`bun run: ${name}`, async () => { + using dir = tempDir(`jsx-pragma-own-line-${name}`, files); + + await using proc = Bun.spawn({ + cmd: [bunExe(), Object.keys(files)[0]], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).toBe(""); + expect(out).toBe(stdout + "\n"); + expect(exitCode).toBe(0); + }); + } + + const localFactory = `const h = tag => "h: " + tag;`; + itBundled("jsx/PragmaOnEachLine", { + files: inRoot({ + "index.js": lines( + `import "./all-four.jsx";`, + `import "./column-0-runtime.jsx";`, + `import "./column-0-fragment.jsx";`, + ), + "all-four.jsx": lines( + "/**", + " * @jsxImportSource ./my", + " * @jsxRuntime classic", + " * @jsx h", + " * @jsxFrag F", + " */", + localFactory, + `const F = "F";`, + `console.log("all-four:", , <>);`, + ), + "column-0-runtime.jsx": lines( + "/*", + "@jsxRuntime classic", + "@jsx h", + "*/", + localFactory, + `console.log("column-0-runtime:", );`, + ), + "column-0-fragment.jsx": lines( + "/** @jsxRuntime classic */", + "/*", + "@jsx h", + "@jsxFrag F", + "*/", + localFactory, + `const F = "F";`, + `console.log("column-0-fragment:", <>);`, + ), + }), + target: "bun", + run: { stdout: lines("all-four: h: a h: F", "column-0-runtime: h: a", "column-0-fragment: h: F") }, + }); + + test("a warning about the value has the length of the value", async () => { + using dir = tempDir("jsx-pragma-own-line-warning", { + "index.jsx": lines("/**", " * @jsxRuntime bogus", " */", "export default ;"), + }); + + const { logs } = await Bun.build({ entrypoints: [join(String(dir), "index.jsx")], external: ["*"] }); + expect(logs.map(log => ({ level: log.level, message: log.message, length: log.position?.length }))).toEqual([ + { level: "warn", message: 'Unsupported JSX runtime: "bogus"', length: 5 }, + ]); + }); + + test("the pragmas in a file without JSX give no warning", async () => { + using dir = tempDir("jsx-pragma-own-line-no-jsx", { + "index.js": lines("/**", " * @jsxRuntime classic", " * @jsx jsx", " */", "export default 1;"), + }); + + const { logs } = await Bun.build({ entrypoints: [join(String(dir), "index.js")] }); + expect(logs.map(log => log.message)).toEqual([]); + }); + }); + // The classic transform reads the first member of the factory and fragment // member lists. A factory or fragment option whose text has no identifier // in it ("." or "..") must keep the list that was configured before it, diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 37cf46afb05c..8d9fdef29616 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2633,6 +2633,100 @@ console.log(
);`), ); }); + // The value of a JSX pragma ends at white space or at a line break, in a `//` comment and in a + // `/* */` comment. So a pragma can have a line of its own in a block comment. + describe("JSX pragma value ends at a line break", () => { + const jsx = new Bun.Transpiler({ + loader: "jsx", + autoImportJSX: true, + define: { "process.env.NODE_ENV": JSON.stringify("development") }, + }); + const lineBreaks = { LF: "\n", CRLF: "\r\n", CR: "\r", "U+2028": "\u2028", "U+2029": "\u2029" }; + const classic = "/** @jsxRuntime classic */\n"; + const elements = "console.log(, <>);\n"; + const factoryIsH = 'console.log(h("a", null), h(React.Fragment, null));\n'; + // Each pragma, the text before it that makes its effect visible, and the output when it is followed. + const pragmas = [ + ["@jsx h", classic, factoryIsH], + ["@jsxFrag F", classic, 'console.log(React.createElement("a", null), React.createElement(F, null));\n'], + [ + "@jsxRuntime classic", + "", + 'console.log(React.createElement("a", null), React.createElement(React.Fragment, null));\n', + ], + [ + "@jsxImportSource ./my", + "", + 'import { jsxDEV as jsxDEV_7x81h0kn, Fragment as Fragment_8vg9x3sq } from "./my/jsx-dev-runtime";\n' + + 'console.log(jsxDEV_7x81h0kn("a", {}, undefined, false, undefined, this), jsxDEV_7x81h0kn(Fragment_8vg9x3sq, {}, undefined, false, undefined, this));\n', + ], + ]; + + for (const [pragma, before, output] of pragmas) { + it(`${pragma} on its own line of a block comment`, () => { + for (const [name, lineBreak] of Object.entries(lineBreaks)) { + for (const star of [" * ", ""]) { + const comment = `/*${star && "*"}${lineBreak}${star}${pragma}${lineBreak}${star && " "}*/\n`; + expect(jsx.transformSync(before + comment + elements), `${name}, "${star}" before the pragma`).toBe(output); + } + } + }); + + it(`${pragma} in a // comment`, () => { + for (const [name, lineBreak] of Object.entries(lineBreaks)) { + expect(jsx.transformSync(`${before}// ${pragma}${lineBreak}${elements}`), name).toBe(output); + } + }); + } + + it("one pragma on each line of a block comment", () => { + for (const [name, lineBreak] of Object.entries(lineBreaks)) { + for (const star of [" * ", ""]) { + const comment = + `/*${star && "*"}${lineBreak}` + + ["@jsxRuntime classic", "@jsx h", "@jsxFrag F"].map(pragma => star + pragma + lineBreak).join("") + + `${star && " "}*/\n`; + expect(jsx.transformSync(comment + elements), `${name}, "${star}" before each pragma`).toBe( + 'console.log(h("a", null), h(F, null));\n', + ); + } + } + }); + + it("two pragmas on one line", () => { + expect(jsx.transformSync("/** @jsxRuntime classic @jsx h */\n" + elements)).toBe(factoryIsH); + }); + + it("a member expression on its own line", () => { + expect(jsx.transformSync(`${classic}/**\n * @jsx a.b\n * @jsxFrag c.d\n */\n${elements}`)).toBe( + 'console.log(a.b("a", null), a.b(c.d, null));\n', + ); + }); + + it("text on the line after the pragma name is not the value", () => { + for (const comment of ["/* @jsx\n h */", "/* @jsx \n h */", "/**\n * @jsx\n * h\n */"]) { + expect(jsx.transformSync(`${classic}/* @jsx first */\n${comment}\n${elements}`), comment).toBe( + 'console.log(first("a", null), first(React.Fragment, null));\n', + ); + } + }); + + it("a pragma without a value keeps the value of an earlier pragma", () => { + for (const [pragma, before, output] of pragmas) { + const name = pragma.slice(0, pragma.indexOf(" ")); + for (const comment of [`/* ${name} */`, `/*\n * ${name} \n */`, `// ${name} `, `// ${name}`]) { + expect(jsx.transformSync(`${before}/** ${pragma} */\n${comment}\n${elements}`), comment).toBe(output); + } + } + }); + + it("the value does not take an annotation at the start of the next line", () => { + expect(jsx.transformSync(`${classic}/* @jsx h\n@__PURE__ */ foo();\nconsole.log();\n`)).toBe( + 'console.log(h("a", null));\n', + ); + }); + }); + // Non-bundle transpile without `minify.identifiers` uses NoOpRenamer // (prints symbol.original_name verbatim), so the `generatedSymbolName` // hash suffix on the automatic JSX runtime import is the sole collision From 1b1fff652c8a890ab44ab879303e67dac8a6941f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:15:25 +0000 Subject: [PATCH 2/2] test: assert stdout, stderr and the exit code together in the bun run pragma cases --- test/bundler/bundler_jsx.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/bundler/bundler_jsx.test.ts b/test/bundler/bundler_jsx.test.ts index 9659de553bf7..97a57538c18c 100644 --- a/test/bundler/bundler_jsx.test.ts +++ b/test/bundler/bundler_jsx.test.ts @@ -622,9 +622,7 @@ describe("bundler", () => { stderr: "pipe", }); const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(err).toBe(""); - expect(out).toBe(stdout + "\n"); - expect(exitCode).toBe(0); + expect({ stdout: out, stderr: err, exitCode }).toEqual({ stdout: stdout + "\n", stderr: "", exitCode: 0 }); }); }