Add the jsx option, with the classic runtime in downlevel mode #1

Open natemoo.re
targeting main from jsx
Open opened by and assigned to natemoo.re
targeting main from jsx

Adds a jsx option for .tsx input, and pragma and pragmaFrag options for downlevel mode.

transformSync(src, { jsx: true }); // erase mode: JSX kept, every offset preserved
transformSync(src, { jsx: true, mode: "downlevel", pragma: "h", pragmaFrag: "Fragment" });

Behavior #

  • jsx defaults to false, so existing callers see no change. With it on, < at an operand start follows TypeScript's .tsx rule. It is JSX unless it starts a generic arrow (<T,>, <T extends U>, <T = U>).
  • Erase mode keeps the JSX and erases types inside it. It also blanks type arguments on tags (<Select<Option> />). This is a deliberate difference from ts-blank-space, which keeps them and so produces output that no JSX tool accepts.
  • Downlevel mode writes the classic runtime, factory(tag, props | null, ...children), as ts.transpileModule does with jsx: "react". Every line is preserved. A leading /** @jsx h */ or /** @jsxFrag F */ comment overrides the options, following TypeScript's pragma rules.

docs/modes.md has the full rules.

Engine #

  • lexChild() lexes JSX children with no trivia skipping. When a child token runs out of input, push() re-lexes it the same way on the next chunk.
  • Each element is one S_JSX frame in strip.zena. The parser is in both builds.
  • The classic-runtime emitter is engine/jsx.zena. It is linked into downlevel.wasm only, through the hook seam.
  • engine/jsx-entities.wat is TypeScript's HTML entity table, generated from its own map.
  • ABI: bit 1 of init's argument turns on JSX. A new pragma(len, fragLen) export passes the factory names.

Cost #

main this PR
erase.wasm gzip 12,622 B 13,648 B
downlevel.wasm gzip 17,838 B 22,205 B
wrapper gzip 2,595 B 2,852 B

Throughput is unchanged with jsx off: about 172 MB/s in erase mode and 189 MB/s in downlevel mode on both builds. The erase engine pays for the parser even with jsx off. A separate JSX-capable erase engine would avoid that, but it is not in this PR.

Verification #

  • New judge, test/jsx.mjs (pnpm test:jsx, added to pnpm test):
    • Erase mode is compared with ts-blank-space on TSX, with the type-argument difference applied.
    • Downlevel mode must keep the line count and match the canonical AST of transpileModule on ts-blank-space's output.
    • Chunked input must give the whole-input output in both modes.
    • A --self-test proves the judge rejects broken output.
  • corpus/jsx.jsonl: 51/51.
  • pnpm test:jsx:typescript, every .tsx in TypeScript 6's compiler and conformance suites: 333/333 (62 skipped because they don't parse cleanly as TSX).
  • Existing suites unchanged: pnpm test, and pnpm test:erase:typescript at 11,736/11,736.

Open questions #

  • Should @jsxImportSource and @jsxRuntime be reported? They are currently ignored and written as the classic runtime, but TypeScript switches those files to the automatic runtime.
  • JSX in output that downlevel mode copies verbatim, such as a parameter decorator's argument, stays untransformed. No corpus has hit this.
Commits 6

Document the jsx, pragma and pragmaFrag options
Write an empty JSX attribute container as true, and CRLF in an attribute string as one line
Add JSX correctness judge, corpus, and test:jsx script
End a written JSX element's statement before `(`, `[` or a template
Add the jsx option: TSX parsing in both modes, the classic runtime in downlevel
Generate the JSX HTML entity table