Skip to content

perf(cli): parse TypeScript dependencies with oxc-parser [RED-1160] [ship] - #1533

Merged
sorccu merged 5 commits into
mainfrom
perf/oxc-dependency-parser
Oct 13, 2026
Merged

sorccu merged 5 commits into
mainfrom
perf/oxc-dependency-parser

Conversation

@danielpaulus

@danielpaulus danielpaulus commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Linear: RED-1160

Affected Components

  • CLI
  • Create CLI
  • Test
  • Docs
  • Examples
  • Other

What

To bundle a check, the CLI walks its import graph. For TypeScript files it found imports with @typescript-eslint/typescript-estree, which loads the whole TypeScript compiler from the user's project. That has two problems:

  1. It's slow. Loading the compiler alone costs ~140ms on every deploy/test/pw-test, and parsing is slow on top of that.
  2. It breaks with TypeScript 7. typescript-estree supports typescript >=4.8.4 <6.1.0, and npm i -D typescript now installs 7.x. In a project with typescript@7, bundling TypeScript browser, multi-step or Playwright checks fails with DependencyParseError … Cannot read properties of undefined (reading 'Cjs') (or 'Intrinsic', depending on the npm layout).

This PR parses TypeScript dependencies with oxc-parser, a native Rust parser (the parser inside Rolldown/Vite 8). Bundling gets ~140–320ms faster depending on project size, and TypeScript 7 projects bundle again. Benchmarks and the TypeScript 7 repro are in this comment.

Changes

  • check-parser/parser.ts: TypeScript files are parsed with oxc-parser.
    • Its AST is ESTree-compatible, so the existing acorn-walk visitors run unchanged and find the same dependencies, including import type. Dynamic import(), import x = require() and anything inside TS/JSX nodes are still skipped.
    • .d.ts/.d.mts/.d.cts/.d.*.ts files are parsed in declaration mode.
    • Any syntax error fails the file (oxc would otherwise return a partial AST), reported acorn-style, e.g. Unexpected token (3:13).
    • Fallback: if the native binding can't load (--omit=optional, npm's optional-dependency lockfile bug, node_modules copied from another OS), the CLI falls back to typescript-estree, exactly as before. If the fallback fails too (TypeScript not installed, or TypeScript 7), the error also names the oxc-parser load failure, whose message explains how to reinstall.
  • tsconfig.json parsing: JSON with comments is parsed with json5, already a dependency, instead of TypeScript's parseJsonText. It now works regardless of the TypeScript version, or without TypeScript installed. The lazy parser state machine and its JSON.parse fallback are gone.
  • package.json: adds oxc-parser, pinned to an exact version (0.153.0) because oxc is pre-1.0 and releases weekly.

Not in this PR: write-back/deploy-diff still use typescript-estree, because they read its token stream. Both are best effort, so on TypeScript 7 the deploy preview loses source details but doesn't fail.

Behaviour changes

  • Grammar errors fail the parse. oxc rejects some code that typescript-estree let through and only tsc reported, e.g. const x without an initializer, await as an identifier in a script-style .ts file, a getter with parameters, or import.meta in a .cts file. These now fail with a DependencyParseError. Almost all of them would also fail when the check runs.
  • Malformed tsconfig.json files are ignored. TypeScript's JSON parser recovered from errors such as a missing comma, and treated an empty file as {}. JSON5 rejects them, so the file is skipped as if it didn't exist.

Notes for the Reviewer

  • ts-parser-parity.spec.ts asserts that oxc and typescript-estree find identical dependencies for every TypeScript file in the check-parser fixtures, examples/ and benchmarks/, plus edge cases that must parse cleanly in both. It also records the grammar errors oxc rejects but typescript-estree accepts, and covers the fallback's error reporting.
  • At 170 files, the synthesized project payload and the Playwright bundle were byte-identical between main and this branch.

🤖 Generated with Claude Code

@danielpaulus

Copy link
Copy Markdown
Contributor Author

Findings

1. Where the time went

CPU profile of checkly debug parse-project (parse → bundle → synthesize, the same pipeline as deploy) on benchmarks/large-playwright-project: 170 TypeScript files, 600 checks, checkly 9.5.0, ~1s total.

Hotspot Time
Compiling the typescript compiler module (pulled in by typescript-estree) ~140ms
typescript-estree + typescript parsing and AST conversion ~200ms
Path handling in the bundler (path.resolve/relative, pathToPosix, registerFiles) ~150ms
Loading playwright-core ~50ms
GC ~57ms

2. Parser comparison (same 170 files, warm runs)

Parser Time Imports found
typescript-estree + TS 6 (before) ~305ms (105ms load + 200ms parse) 934
TypeScript 7 / Go (typescript/unstable/sync) ~75ms (spawns the Go binary, project-based, unstable API) 934
oxc-parser (Rust, in-process) ~17ms (3ms load + 14ms parse) 934

3. End-to-end perf test

Setup: main (b6d3542) vs this branch, both built with pnpm pack and installed into separate copies of benchmarks/large-playwright-project. The command was CHECK_COUNT=200 checkly debug parse-project --stats (601 constructs), with 15 interleaved runs per build after a warmup, reporting medians. M4 Max, Node 24.14. Shared code size was scaled with the benchmark's generate script.

Project size main wall This PR wall Saved main bundleMs This PR bundleMs
13 TS files (no generated code) 583ms 442ms −141ms (−24%) 209ms 72ms
170 TS files (generate 150 2500) 990ms 737ms −253ms (−26%) 608ms 362ms
620 TS files (generate 600 2500) 1652ms 1335ms −317ms (−19%) 1253ms 944ms

deploy, test and pw-test run the same bundling, so they save the same amount; the network calls come on top.

Same output: at 170 files, the synthesized project payload is byte-identical between main and this branch (651,707 bytes, ignoring temp-dir names). The Playwright bundle tarball has the same 174 files with byte-identical contents.

4. TypeScript 7 repro

A minimal project with one browser check (home.spec.ts importing ./helper) plus typescript@7.0.2, run through checkly debug parse-project:

Build Plain npm install (npm nests TS 6 under checkly) Hoisted (--legacy-peer-deps)
9.5.0 / main ❌ Cannot read properties of undefined (reading 'Intrinsic') ❌ … (reading 'Cjs')
This PR ✅ parses, finds helper.ts ✅ parses, finds helper.ts

5. Safety net and other runtimes

  • Native binding missing (I deleted node_modules/@oxc-parser/binding-*): logs Failed to load oxc-parser, falling back to typescript-estree: Error: Cannot find native binding… under DEBUG, and the project still parses.
  • Bun 1.3.13 and Deno 2.7.14: oxc loads natively in both (no fallback) and the project parses.

6. Is oxc maintained well enough?

  • oxc-project/oxc (MIT): ~23k stars, ~470 contributors, 100+ commits in the last 30 days. 187 oxc-parser releases since Nov 2023, roughly weekly. Maintained by VoidZero (the Vite team). ~50M weekly npm downloads. Its Rust core is the parser in Rolldown, which Vite 8 uses by default.
  • Conformance (oxc's published test results): test262 47,455/47,455 (100%), Babel 2,239/2,239 (100%), TypeScript test suite 9,661/9,669 (99.92%). ESTree output: test262 100%, TypeScript 99.99%.
  • 19 prebuilt platform binaries (macOS, Linux glibc/musl, Windows x64/arm64/ia32, FreeBSD, arm, riscv, s390x, …). Requires Node ^20.19 || >=22.12, which fits our >=22.13.

Risks and mitigations:

Risk Mitigation
Pre-1.0, with breaking AST changes every few weeks (mostly in the Rust AST) Our surface is tiny (parseSync + ESTree node types). Pinned to an exact version, and ts-parser-parity.spec.ts gates upgrades.
Native binary missing or broken Falls back to typescript-estree; worst case is today's behavior.
Parsing differences Parity test over 109 real files + 21 edge cases; identical payload and bundle on the benchmark.
Install size About +4.5MB per platform.

7. What's left (follow-ups)

  • Bundler path handling (~150ms at 170 files) is now the biggest bundling cost, and it grows with project size (see the 620-file row). It looks like repeated per-file or per-construct work, which can be fixed in JS.
  • Loading playwright-core (~50ms) could likely be made lazy.
  • write-back/deploy-diff (parseSource) still use typescript-estree, which needs a token stream, so deploy's preview diff and write-back still have the TypeScript 7 problem. That needs a separate PR.

@danielpaulus

Copy link
Copy Markdown
Contributor Author

@sorccu what do you think about making this switch? You know the check-parser and bundler better than anyone, so I'd value your take before we go further.

The short version (details in the comment above):

  • Speed: bundling gets ~140–320ms faster depending on project size, mostly because we no longer load the TypeScript compiler to find imports.
  • TypeScript 7: projects with typescript@7 (what npm i -D typescript installs now) currently can't bundle TS checks at all, because typescript-estree only supports <6.1. This fixes that.
  • Same output: the existing acorn-walk visitors run unchanged on oxc's ESTree output. A new parity test checks identical dependency lists across fixtures, examples/ and benchmarks/, and the benchmark payload and bundle are byte-identical.
  • Safety net: if the native binary can't load, we fall back to typescript-estree.

Things I'd especially like your opinion on:

  1. Are you comfortable with a native (Rust/N-API) dependency in the CLI, given the fallback? Any environments you know of where users run the CLI and that could be a problem?
  2. Any parsing edge cases or past bugs in the dependency parser you'd want in the parity test before trusting it?
  3. Should we keep typescript-estree around as the fallback long-term, or eventually drop it once write-back/deploy-diff move off it too?
  4. Pinning oxc-parser to an exact version (it's pre-1.0, weekly releases): OK, or would you prefer a different update policy?

@sorccu

sorccu commented Oct 13, 2026

Copy link
Copy Markdown
Member

Review: backwards compatibility

Valid existing projects keep working. Beyond the PR's own parity test, I compared oxc and typescript-estree on about 90 more TypeScript constructs (overloads, abstract members, declare, decorators, JSX namespaces, satisfies/as/! wrappers, import type/typeof import(), static blocks, parameter defaults, .mts/.cts, BOM, hashbang, …). For valid code they found the same dependencies every time. The PR's tests, tsc and lint pass locally on Node 24. The breakage I found is limited to two narrower cases: TypeScript files with grammar errors, and malformed or empty tsconfig.json files.

When existing projects break

1. oxc rejects some TypeScript that typescript-estree accepted (hard failure). typescript-estree lets through errors that tsc only reports later, at type-check time. oxc rejects them at parse time. Each of these parsed fine on main and now fails deploy/test with a DependencyParseError:

Construct oxc error
const await = 1 in a script-style .ts file (no import/export) Cannot use await as an identifier…
const x with no initializer Missing initializer in const declaration
get x(a) {} A 'get' accessor must not have any formal parameters
function f(...a,) {} rest parameter with a trailing comma
new.target outside a function; top-level return —
import.meta, or top-level await, in a .cts file —

All of these except the await identifier would also throw when the code runs. So the realistic victim is a file that never runs: one reached only through import type, in a project that never runs tsc. That's rare, but the failure is a hard deploy error rather than degraded behaviour.

The reverse also happens: oxc accepts octal literals and import source, which typescript-estree rejected.

Suggested fix: when oxc reports a syntax error and typescript-estree can be loaded, re-parse that one file with typescript-estree before failing. That keeps main's behaviour exactly for anyone on TypeScript 6 or older. The TypeScript compiler is only loaded on the error path, so the speedup is untouched.

2. tsconfig.json is now parsed strictly (silent failure). TypeScript's parseJsonText recovers from errors; JSON5 throws. Measured differences:

tsconfig contents main (TypeScript) This PR (JSON5)
Empty or whitespace only {} throws
Missing comma between properties full object (paths kept) throws
Unclosed brace, or ,, full object throws

When parsing throws, JsonTextSourceFile.loadFromSourceFile returns undefined and PackageFiles.satisfyFromDirPath in resolver.ts keeps searching parent directories. The tsconfig is then silently ignored and not bundled, or a parent tsconfig takes its place. Path-alias imports become "missing dependency" errors.

  • An empty tsconfig.json next to a parent tsconfig is the plausible real-world case.
  • In fairness, I believe Playwright also reads tsconfig with JSON5 (not verified), so for Playwright checks this may match what happens at runtime.
  • Suggested fix: treat a whitespace-only file as {}, and consider a warning instead of skipping silently.

3. Environments where the native binary can't load. Examples: --omit=optional, node_modules copied from another OS, and npm's known bug where a lockfile created on macOS leaves out the Linux binary.

  • These fall back to typescript-estree, which is exactly today's behaviour, including today's TypeScript 7 failure.
  • There are 19 prebuilt binaries, and no WASM fallback unless one is installed explicitly.
  • oxc-parser is ESM and is loaded with require(). It used the native parser on Node 20.19 and 22.23. I couldn't test 22.13.0, the CLI's minimum.

Improvements: projects on TypeScript 7 now bundle, and TypeScript check files now parse without typescript installed. On TypeScript 7, deploy's preview diff still uses typescript-estree, but it is best effort (deploy-diff/source-index.ts), so it degrades rather than fails, as the PR says.

Other findings

  • The syntax.ts case in the new parity test checks nothing. Both parsers fail on the accessor field (No walker function defined for node type AccessorProperty), so "both errored" counts as a match. Decorators, using, satisfies and enum are never compared. Edge cases should assert that neither side errors.
  • This also exposes an existing bug on main: any TypeScript file with an accessor class field fails dependency parsing. It's a one-line fix in the walker (map AccessorProperty to PropertyDefinition), but it belongs in a separate PR.
  • The fallback is logged only under DEBUG. A TypeScript 7 user whose binary is missing gets the old reading 'Cjs' error with no hint about why. When the fallback parser also fails, the error should mention that oxc couldn't load.
  • Nits:
    • Error columns are 1-based, while acorn's are 0-based, so "acorn's style" is off by one.
    • The JSON.parse fallback and the "make sure it works" self-test in json-text-source-file-parser.ts are now dead code, since json5 is a hard dependency. A plain static import would do.
    • The "TypeScript not available" test block in json-text-source-file.spec.ts now tests nothing related to TypeScript.

On the four open questions

  1. Native dependency: acceptable, because the fallback covers every failure to load. The case I'd guard against is the npm lockfile bug above combined with TypeScript 7, which brings back the original error.
  2. Edge cases worth adding: the grammar-error table above, await used as an identifier, and accessor.
  3. Keeping typescript-estree: keep it as the fallback for as long as write-back and deploy-diff depend on it anyway. The per-file retry suggested above would use it too.
  4. Exact pin: fine. @oxc-project/types@^0.153.0 is effectively pinned as well, since a caret on 0.x only allows patch releases.

🤖 Generated with Claude Code

@sorccu

sorccu commented Oct 13, 2026

Copy link
Copy Markdown
Member

Correction to my review: the missing-binary case and the fallback's future

Question 1 (native dependency). I called a missing binary combined with TypeScript 7 "the case I'd guard against". That overstated it. When the oxc binary is missing (npm's lockfile bug npm/cli#4828, --omit=optional, node_modules copied from another OS), the CLI falls back to typescript-estree, which is exactly what main does:

Project main PR, binary present PR, binary missing
TypeScript ≤ 6 works works, faster works (same as main)
TypeScript 7 fails works fails (same as main)

So a missing binary never turns a working project into a failing one. TypeScript 7 users just don't get the fix, and they see the old reading 'Cjs' error with no hint that oxc failed to load. Some context:

  • typescript@7 ships its own platform binary packages, so a damaged lockfile very likely breaks their tsc too.
  • typescript@6 is pure JavaScript.
  • The npm bug was fixed in npm 11.3.0 (npm/cli#8184). I found no mention of it in the npm 10.9.x release notes (Node 22 ships npm 10), and lockfiles damaged before the fix stay damaged until regenerated.

The suggestion still stands, with lower priority: when oxc failed to load and the fallback also fails, the error should name the oxc failure and give the reinstall fix.

Question 3 (keeping typescript-estree). "Keep it while write-back and deploy-diff depend on it" is right, but it should also say what replaces it afterwards:

  • Now: the fallback costs almost nothing, because typescript-estree stays a dependency for write-back and deploy-diff anyway. Its only benefit is keeping TypeScript ≤ 6 projects without the binary working as they do on main.
  • Once write-back and deploy-diff move off typescript-estree: don't keep it only for the fallback. oxc's loader (src-js/bindings.js) already falls back to @oxc-parser/binding-wasm32-wasi when no native binary loads. That package isn't one of oxc-parser's optional dependencies, so the CLI would have to depend on it directly.
    • It would work on every platform and with TypeScript 7.
    • It adds about 1.6 MB unpacked, plus @napi-rs/wasm-runtime and two @emnapi packages, currently at 2.0.0-alpha.3.
    • It has no cpu/os install restriction, and its supported Node versions cover the CLI's >=22.13.
    • I haven't tested installing it or compared its speed with the native binary.
    • If those extra dependencies aren't wanted, just let oxc's own "Cannot find native binding" error through. It already explains the reinstall fix.

🤖 Generated with Claude Code

@sorccu
sorccu force-pushed the perf/oxc-dependency-parser branch from b665919 to ff7c446 Compare October 13, 2026 18:15
@sorccu sorccu changed the title perf(cli): parse TypeScript dependencies with oxc-parser perf(cli): parse TypeScript dependencies with oxc-parser [RED-1160] Oct 13, 2026
danielpaulus and others added 5 commits October 14, 2026 03:21
Finding the imports of TypeScript check files used typescript-estree,
which loads the whole TypeScript compiler from the user's project. That
costs ~140ms on every deploy, test and pw-test even for tiny projects,
and breaks with TypeScript 7, which typescript-estree doesn't support:
any project with typescript@7 failed to bundle TypeScript checks with
"Cannot read properties of undefined (reading 'Cjs'/'Intrinsic')".

oxc-parser is a native (Rust) parser with an ESTree-compatible AST, so
the existing visitors keep working unchanged and the same dependencies
are found. It doesn't need TypeScript at all. If its native binding
can't be loaded, the parser falls back to typescript-estree.

tsconfig.json and other JSON-with-comments files are now parsed with
json5 instead of TypeScript's JSON parser, for the same reasons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ED-1160]

When oxc-parser can't load, TypeScript files are parsed with
typescript-estree instead. If that fails too (TypeScript isn't installed,
or its version isn't supported, e.g. TypeScript 7), the error now also
includes why oxc-parser failed to load, which explains how to fix the
install. Syntax errors in the parsed file are reported unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
json5 is a hard dependency and no longer needs a lazily loaded parser
with a JSON.parse fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An edge case that failed in both parsers used to count as a match, which
hid that `accessor` class fields couldn't be walked. Edge cases now must
parse without errors, the syntax error is its own case, and the grammar
errors oxc rejects but typescript-estree accepts are recorded as tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sorccu
sorccu force-pushed the perf/oxc-dependency-parser branch from ff7c446 to cbac804 Compare October 13, 2026 18:24
@sorccu sorccu changed the title perf(cli): parse TypeScript dependencies with oxc-parser [RED-1160] perf(cli): parse TypeScript dependencies with oxc-parser [RED-1160] [ship] Oct 13, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: ship/show PR from a same-repo branch.

@sorccu
sorccu merged commit f4dd3c6 into main Oct 13, 2026
17 checks passed
@sorccu
sorccu deleted the perf/oxc-dependency-parser branch October 13, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants