Skip to content

lexer: end a JSX pragma value at a line break in block comments too - #45140

Open
robobun wants to merge 2 commits into
mainfrom
robobun/7a23504b/jsx-pragma-line-break
Open

robobun wants to merge 2 commits into
mainfrom
robobun/7a23504b/jsx-pragma-line-break

Conversation

@robobun

@robobun robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A JSX pragma on its own line of a /* */ comment keeps the line break in its value. bun format --insert-pragma writes that form. Then @jsxImportSource ./my fails with Cannot find module './my\n/jsx-dev-runtime'.
  • The value loop of PragmaArg::scan (src/js_parser/lexer.rs:3552) stops at a line break only for // comments. Fuzzing found this. No issue is filed.

Fix

  • The loop stops at white space or a line break in both comment kinds. A name with no value is not a pragma, as in esbuild. Deleted: allow_newline, CommentKind, PragmaArg::is_newline.
  • The transpiler cache version goes from 35 to 36, so old cached output is not reused.
  • Verified: test/bundler/bundler_jsx.test.ts and test/bundler/transpiler/transpiler.test.js (22 of 27 new cases fail on main). Also ran react-compiler.test.ts, module-graph.test.ts, esbuild/default.test.ts.
  • Self-reviewed: 8 concerns raised, 8 addressed.

Background

  • A JSX pragma is a comment that sets a JSX option for one file, for example /** @jsx h */.
  • This deviates from esbuild, which keeps the line break. TypeScript and swc stop there.
  • Considered a trim in the parser: the lexer still swallows a pragma on the next line. Considered a line cut in the comment scanners: a search per @ or # marker.

Downsides

  • Each cached module is transpiled again once after the upgrade (20 of 20 in a probe).
  • // @jsx with no value no longer resets an earlier pragma. A value on the line after @jsx is still not read, unlike TypeScript.
Notes

Timing. bun format is not released yet (#44805, LATEST is 1.4.3). On main it turns a file that runs into a file that does not run. So this is worth landing before the release that ships bun format.

mkdir my
echo 'export const jsxDEV = (tag) => "made by my runtime: " + tag;' > my/jsx-dev-runtime.js
printf '/** @jsxImportSource ./my */\nconsole.log(<a />);\n' > app.jsx
bun app.jsx                          # made by my runtime: a
bun format --insert-pragma app.jsx   # writes "/**", " * @format", " * @jsxImportSource ./my", " */"
bun app.jsx                          # main: error: Cannot find module './my\n/jsx-dev-runtime'
                                     # this PR: made by my runtime: a

Prettier 3.6.2 --insert-pragma writes the same four lines.

Symptoms on main, per pragma and entry point (\n is a real line break in the value):

Pragma on its own line bun run bun build Bun.Transpiler
@jsxImportSource ./my exit 1, Cannot find module './my\n/jsx-dev-runtime' exit 1, Could not resolve imports "./my\n/jsx-dev-runtime"
@jsxRuntime classic no message, the automatic runtime is used warning Unsupported JSX runtime: "classic\n", the automatic runtime is used throws that warning
@jsx h (classic runtime) ReferenceError: h is not defined when h is an import in a .tsx file exit 0, the bundle throws ReferenceError: h is not defined emits h\n("a", null)
@jsxFrag F (classic runtime) the same with F the same with F emits F\n

A pragma at the start of the next line was also swallowed: /*\n@jsxRuntime classic\n@jsx h\n*/ gave Unsupported JSX runtime: "classic\n@jsx". The same happened to a @__PURE__ at the start of the next line. LF, CRLF, a lone CR, U+2028 and U+2029 all behave the same, with or without a leading *.

Published code. 11 files in dist/esm of @atlaskit/primitives 22.5.4 have a docblock with @jsxRuntime classic and @jsx jsx on their own lines. bun build node_modules/@atlaskit/primitives/dist/esm/index.js --packages external prints 11 Unsupported JSX runtime: "classic\n" warnings on main and 0 with this PR. The two bundles are byte-identical, because those files contain no JSX.

One earlier sighting. The output pasted in #10026 (closed, about another crash) shows Unsupported JSX runtime: "theRuntime.` followed by a line break. That text is prose in a doc comment of estree-util-build-jsx. With this PR the warning still prints, without the line break.

What changes for // comments. Only the value-less case. The stop set of the loop is the same as before: strings::is_js_whitespace is is_whitespace plus LF, CR, U+2028 and U+2029. A name with no value (// @jsx at a line end, /* @jsx */) returned an empty value before, and that reset the value of an earlier pragma of the same name. It now returns nothing, as scanForPragmaArg in esbuild does. The tests cover this for all four names.

Other tools, run on this machine. esbuild 0.21.5 keeps the line break (Invalid JSX factory: h\n, Invalid JSX runtime: "classic\n"). TypeScript 6.0.2 and swc 1.3.38 follow the pragma on its own line. For /* @jsx<LF> h */, TypeScript uses h, esbuild and swc do not.

Measurements. Release builds of main d1cb7db and of this PR on that base, linux x64. The branch was rebased after these runs and the lexer change is the same. valgrind, perf, strace and bloaty are not installed on this machine.

  • release binary: text 105,077,925 B -> 105,077,157 B bun-profile, 105,051,807 B -> 105,051,039 B bun (size, main d1cb7db vs PR); PragmaArg::scan 3,025 -> 2,305 B, scan_pragma 662 -> 660 B, scan_comment_text 1,088 -> 1,069 B, scan_single_line_comment 508 -> 508 B, Lexer::next 15,420 -> 15,420 B (nm -S)
  • PragmaArg::scan calls while transpiling typescript.js / lodash.js / bun.d.ts: 0 / 0 / 0 (base) and 0 / 0 / 0 (PR), against 1,022 / 3,695 / 1,080 scan_pragma calls (gdb breakpoint hit counts). The output of bun build --no-bundle is byte-identical for the three files.
  • scan_pragma on a non-JSX marker: 264 -> 262 instructions per call for /** @param ... */, 264 -> 262 for // @param ... (gdb single-step count, base vs PR)
  • scan_pragma on a matched pragma: /** @jsx h */ 285 -> 277, // @jsx h 329 -> 302, /** @jsxImportSource @emotion/react */ 776 -> 742, multi-line @jsx h 316 -> 279 instructions (gdb single-step count, base vs PR)
  • pragma path allocations: 0 allocator call sites in PragmaArg::scan and scan_pragma (base 0, PR 0; objdump call targets)
  • transpiler cache: 20 of 20 cached modules rewritten once on the first run after the upgrade, 0 on the second (inode and mtime of the entries under BUN_RUNTIME_TRANSPILER_CACHE_PATH)
  • behaviour: 22 new cases fail on main and on bun 1.4.2 and pass on the PR build (11 spawn a process, 11 in-process); 5 more new cases pass before and after (the // forms and two pragmas on one line); 0 expectations edited in existing tests

Left alone.

  • /* @jsx<LF> h */: a value on the line after the name is not read. A test holds this.
  • The # marker. #jsx h is read like @jsx h, which esbuild and TypeScript do not do. A comment such as // see https://www.typescriptlang.org/tsconfig#jsxImportSource for details sets the import source to for, before and after this PR. That is a separate change. It is reported separately and is not part of this PR.
  • bun format with prettier-plugin-organize-imports removes the import of the factory when @jsxRuntime comes before @jsx in one comment (src/format/js/sort_imports/organize.rs:1514). The one-line form fails too, so it is not a line-break fault. It is reported separately and is not part of this PR.
  • The other pragma readers are separate code and already end the value at a line break: bun check (src/sema/parser/pragmas.rs:138) and the React rules of bun lint (src/lint/react/util_pragma.rs:27).
  • Pragma warnings are reported for a file with no JSX, and Bun.Transpiler throws on a warning (Bun.Transpiler: do not throw on a parser warning #42433). Not changed here.
  • Bun.Transpiler#scanImports applies no JSX pragma at all, the one-line forms included.
  • has_bun_pragma (src/js_parser/parse/parse_entry.rs:2542) is a separate reader for the first-line // @bun comment, which Bun writes itself.
  • The position of a pragma warning in a block comment is wrong before and after. lexer: report JSX pragma warnings at the pragma argument #39636 fixes it.

Self-review. It asked for: the symptoms per pragma and entry point, the formatter route, fixtures where the factory is an import and one process per pragma, the value-less rule tested for all four names, a no-JSX case that does not depend on Bun.Transpiler throwing on a warning, and the full list above. All are in this PR. It also offered to fold the # marker fix into this PR. That stays separate, because it is a different behaviour change with its own repro.

Related PRs. #39636 changes the signatures of scan_pragma and PragmaArg::scan, so the second of the two to land needs a small rebase. #42676 says its member-expression rule cannot cover the @jsx and @jsxFrag pragmas until the lexer ends the value at the line end. The // stop that this PR makes general came with #19134.

Suites run. Debug build: bundler_jsx.test.ts (77 pass), transpiler.test.js (252 pass), react-compiler.test.ts (116 pass). Release build of this PR: module-graph.test.ts (292 pass), transpiler-cache.test.ts (20), esbuild/default.test.ts (155), esbuild/dce.test.ts (87), esbuild/tsconfig.test.ts (21), bundler_comments.test.ts (50), and bun-build-api.test.ts (71 pass, 1 fail: bytecode: repeated builds don't retain the generated code, which fails the same way on the release build of main). On the debug build, module-graph.test.ts and bun-build-api.test.ts have tests that time out at 5 s. They pass on the release builds.

@robobun

robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:36 AM PT - Oct 16th, 2026

⏳ @robobun, your commit 1b1fff6 is still building in Build #126704, but has 1 failures so far (All Failures):

@robobun

robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.2 and on main:

  • A file with * @jsxImportSource ./my on its own line of a /** */ comment exits 1 with Cannot find module './my\n/jsx-dev-runtime' (a real line break in the specifier). The one-line comment /** @jsxImportSource ./my */ works.
  • On main, bun format --insert-pragma rewrites the one-line comment into the form that fails.
  • With * @jsx h on its own line and the classic runtime, bun build exits 0 and the bundle throws ReferenceError: h is not defined.

The fix and the tests are in this PR (#45140). 22 of the 27 new test cases fail on main and all 27 pass with the fix. The branch is rebased onto main 5866b5f. The transpiler cache version is now 36, because main took 35 in #45290.

CI. The diff is green. Build 126704 finished with one failing test: test/cli/install/bun-install-windows-rename-retry.test.ts on Windows 11 aarch64 (files: 299 where 300 are expected). This PR does not touch bun install, and the same test was red on main when the build ran. All shards passed on the seven Linux lanes, on Windows 2019 x64 and on macOS aarch64, and so did the other seven shards on Windows 11 aarch64. test/bundler/bundler_jsx.test.ts and test/bundler/transpiler/transpiler.test.js ran in passing shards on both Windows lanes. The two macOS x64 jobs expired before an agent picked them up, so that lane did not run in this build. It passed in build 125085, which had the same lexer change.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8b37ddec-77a7-46b9-a4d6-0514164a8470
📥 Commits

Reviewing files that changed from the base of the PR and between d76fd2e and 1b1fff6.

📒 Files selected for processing: 1
  • test/bundler/bundler_jsx.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: a3a34e07-9d9e-4588-a280-96b9332fa1b8
📥 Commits

Reviewing files that changed from the base of the PR and between a84567a and d76fd2e.

📒 Files selected for processing: 2
  • src/js_parser/lexer.rs
  • src/jsc/RuntimeTranspilerCache.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The lexer now stops JSX pragma values at JavaScript whitespace, including line breaks, and returns no span for empty values. The runtime transpiler cache format version changes from 35 to 36. New transpiler and bundler tests cover pragma parsing and JSX output.

Changes

JSX pragma parsing

Layer / File(s) Summary
Pragma scanning and cache version
src/js_parser/lexer.rs, src/jsc/RuntimeTranspilerCache.rs
Pragma scanning no longer varies newline handling by comment type. Values stop at JavaScript whitespace, and empty values return no span. The runtime transpiler cache format version changes from 35 to 36.
Transpiler pragma parsing tests
test/bundler/transpiler/transpiler.test.js
Tests cover pragma values in line and block comments, multiple line-break forms, multiple pragmas, member expressions, and missing values.
Bundler and runtime pragma tests
test/bundler/bundler_jsx.test.ts
Tests check JSX output through bundling and direct bun run, warning spans for unsupported runtime values, and the absence of pragma warnings when a file has no JSX.

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to d76fd

The change bounds JSX pragma values at whitespace and preserves earlier values when a later pragma has no value. No actionable merge-blocking risk is evident; it appears ready to merge after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely describes the main lexer change: JSX pragma values now end at line breaks in block comments.
Description check Passed The description explains the problem, implementation, behavior changes, scope, and extensive verification results. It uses different headings than the template, but it provides the required informatio…
Full details: Description check

Explanation

The description explains the problem, implementation, behavior changes, scope, and extensive verification results. It uses different headings than the template, but it provides the required information.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked that strings::is_js_whitespace is a strict superset of the old is_whitespace stop set (0xFEFF and Unicode Zs route through lexer::is_whitespace), so the // path only changes in the value-less case; and that the new None return (offset 0) cannot loop in either scanner, since both the block-comment index_of_any loop and scan_single_line_comment advance past the @/# before calling scan_pragma. The empty-text path (/* @ jsx */) stops at i == 0 without reading past the slice.

Extended reasoning...

The PR changes PragmaArg::scan in src/js_parser/lexer.rs so a JSX pragma value ends at any JS whitespace including line terminators in both comment kinds, deletes CommentKind/is_newline/allow_newline, returns None for an empty value, and bumps the transpiler cache EXPECTED_VERSION to 35; tests cover LF/CRLF/CR/U+2028/U+2029 through Bun.Transpiler, itBundled and spawned bun run. No security-sensitive surface. Not approved because it is a deliberate semantic deviation from esbuild in core lexer code with a behavior change for bare-name pragmas, and one confirmed finding about the pre-existing # marker handling is posted inline.

Comment thread src/js_parser/lexer.rs
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.
@robobun
robobun force-pushed the robobun/7a23504b/jsx-pragma-line-break branch from a84567a to d76fd2e Compare October 16, 2026 14:02

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

LGTM — a narrow lexer change: the pragma value loop now stops at strings::is_js_whitespace, which for // comments is exactly the old is_whitespace || is_newline stop set and for block comments adds the four line terminators; the new None on an empty value keeps an earlier pragma instead of blanking it, and both callers tolerate a 0 return without re-scanning the same marker. Cache version bump is in place (the description's "34 to 35" is stale; the code goes 35 to 36).

Extended reasoning...

The change touches src/js_parser/lexer.rs (PragmaArg::scan, scan_pragma and its two call sites, deletion of CommentKind/is_newline/allow_newline) and bumps EXPECTED_VERSION in src/jsc/RuntimeTranspilerCache.rs, with new exact-value tests in test/bundler/transpiler/transpiler.test.js and test/bundler/bundler_jsx.test.ts covering LF/CRLF/CR/U+2028/U+2029, name-only pragmas, and end-to-end bun run/bun build. It touches no security-sensitive surface. Approve was decided by the small, self-contained diff whose stop-set change I traced against is_whitespace/is_js_whitespace, by JSXPragma accessors already gating on text.len() > 0 so the zero-length-span removal is behavior-neutral for consumers, and by no CODEOWNERS entry covering the changed paths; the only inline finding is a test-assertion-ordering nit. The earlier inline thread about the # marker was a pre-existing, non-blocking note the author replied to.

Comment thread test/bundler/bundler_jsx.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants