perf(linter): avoid node-chain allocation for non-Jest calls#23907
Merged
Conversation
Merging this PR will improve performance by 4.97%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
`parse_jest_fn_call` runs on ~every CallExpression for the 34 Jest/Vitest
rules and always built a heap-allocated member-call chain via `get_node_chain`
before deciding the call was not a Jest call. Three cost-only changes, returning
byte-identical values (proven by the committed oracle + an all-rules digest diff
vs main over benchmark + adversarial fixtures, including vitest-import and
@jest/globals-import test files):
1. Bare-identifier fast path, gated to NON-test files (`!is_jest && !is_vitest`)
— the only arm where an unknown root's result does not depend on the
`is_valid_jest_call`/`is_valid_vitest_call` filtering the slow path applies on
test files. There it reproduces the slow path's exact result (None if not
top-of-chain, else Some(GeneralJest{Unknown, members:[]})) without the
allocation. Test files fall through to the slow path unchanged.
2. Skip building the `call_chains` Vec on the non-test arm, where it is unused.
3. `remove(0)` instead of `split_off(1)` to reuse the chain buffer for members.
anonrig
force-pushed
the
perf/linter-jest-fn-fast-path
branch
from
June 28, 2026 20:54
63aae74 to
a83dc4b
Compare
camc314
added a commit
that referenced
this pull request
Jul 3, 2026
## What
`parse_jest_fn_call` is the shared helper the 34 Jest/Vitest rules use
to decide whether a call expression is a Jest/Vitest call, so it runs on
(effectively) **every** `CallExpression`. It always built a
heap-allocated member-call chain via `get_node_chain` (a `Vec` +
recursion) before deciding the call was *not* a Jest call.
This PR makes three **cost-only** changes — the returned values are
byte-identical to before:
1. **Bare-identifier fast path, gated to NON-test files (`!is_jest() &&
!is_vitest()`).** That is the only framework arm where an unknown root's
result does not depend on the slow path's `is_valid_jest_call` /
`is_valid_vitest_call` filtering: on a test file an unknown root (e.g.
`setTimeout`) is rejected there and the slow path returns `None`, so
**test files deliberately fall through to the slow path**. On a non-test
file the slow path builds a single-element chain (empty `members`),
skips those test-only checks, and returns either `None` (call not at the
top of its chain) or `Some(GeneralJest { kind: Unknown, members: [], ..
})`; the fast path reproduces exactly that without the allocation. It
still returns `Some` there, so consumers that read the result on
non-test files — `vitest/prefer-called-exactly-once-with` and
`jest/prefer-importing-jest-globals` — are unaffected.
2. **Skip building the `call_chains` Vec on the non-test arm**, where it
is never read.
3. **`remove(0)` instead of `split_off(1)`** to reuse the chain's
allocation for `members`.
## Why it is behavior-preserving
Verified by:
- A committed oracle
(`crates/oxc_linter/tests/jest_fn_fast_path_oracle.rs`, 5 cases) driving
the real `Linter::run` with every rule enabled. It pins both the
non-test behavior (a global `pending()` is reported by
`prefer-importing-jest-globals`; a `setTimeout(() => {
expect(x).toHaveBeenCalledOnce();
expect(x).toHaveBeenCalledWith('hoge'); })` wrapper is reported by
`prefer-called-exactly-once-with`) **and the test-file behavior** (on a
`vitest`-importing file and on a `*.test.ts` file the same `setTimeout`
wrapper is NOT reported — matching `main`). The two test-file cases fail
on a naive un-gated fast path and pass on `main` and on this change.
- An all-rules (`ConfigStoreBuilder::all()`) `Linter::run` diagnostic
digest: **byte-for-byte identical to `main`** across react, excalidraw
`App.tsx`, the TypeScript compiler's `binder.ts`, RadixUI, kitchen-sink,
**and** test-file fixtures that import from `vitest` and `@jest/globals`
(so `is_vitest()` / `is_jest()` are true) plus fixtures exercising
`pending`/`each`/member chains/`foo().bar()`/`describe.each`/the
`setTimeout` wrapper.
- `cargo test -p oxc_linter`: 1162 passed (+ 5 oracle cases). `cargo
lintgen` reports no generated-file change. `cargo clippy -p oxc_linter`
clean.
## Benchmark
`cargo bench -p oxc_benchmark --bench linter --no-default-features
--features linter`, comparing a `--save-baseline main` build of `main`
against this branch (criterion `change`, all `p < 0.05`), three runs:
| File | run 1 | run 2 | run 3 |
|---|---|---|---|
| RadixUIAdoptionSection.jsx | −4.9% | −5.2% | −6.7% |
| react.development.js | **−12.7%** | **−12.6%** | **−11.7%** |
| App.tsx | −6.5% | −6.9% | −6.7% |
| binder.ts | **−22.7%** | **−22.9%** | **−22.9%** |
| kitchen-sink.tsx | **−21.8%** | **−21.7%** | **−22.4%** |
No file regresses. Aggregated over the whole linter benchmark workload:
**total time ≈ −16%**, **geometric mean ≈ −14%**, stable across all
three runs. The win is not isolated to the synthetic `kitchen-sink`
fixture — two real-world files (`react.development.js` and `binder.ts`)
independently exceed 10% in every run; `binder.ts` benefits most as it
is extremely call-expression-dense.
### How the target was found
Profiling the linter benchmark (`sample`) showed `execute_rules` is ~96%
of `Linter::run`, with cost spread across ~470 rules and no single rule
above ~4%; the Jest/Vitest `parse_jest_fn_call` path (dominated by the
per-call `get_node_chain` allocation) was the single largest
*cross-cutting* cost.
---
**AI usage disclosure** (per repo policy): produced with AI assistance
(Grok). The profiling, the before/after diagnostic-equality digest
(incl. test files), the oracle test, the full test suite, and the
benchmark runs were all executed and reviewed.
---------
Co-authored-by: Cameron <[email protected]>
Boshen
added a commit
that referenced
this pull request
Jul 6, 2026
# Oxlint ### 🚀 Features - 7db7a29 allocator: Add `ReplaceWith` trait (#24012) (overlookmotel) - a2c97f3 linter/unicorn: Implement `explicit-timer-delay` rule (#23612) (Mikhail Baev) - 85735cb linter/unicorn: Implement `no-confusing-array-with` rule (#23638) (Shekhu☺️ ) - cb4fbb9 linter/eslint: Implement no-unreachable-loop rule (#23975) (Todor Andonov) - dc32112 linter/eslint/no-constant-binary-expression: Check relational comparisons (#24088) (camc314) - 439c344 linter/jsdoc: Added missing options to `jsdoc/require-param` rule (#23364) (kapobajza) - 62af717 linter/unicorn/filename-case: Add `lowercase` and `screamingSnakeCase` (#24045) (Boshen) - d963967 linter/unicorn/no-array-sort: Add `allowAfterSpread` option (#24043) (Boshen) - 0a75682 linter: Add per-rule timings for type-aware linting (#22488) (camchenry) - 743e222 linter/react: Add `disallowedValues` option for `forbid-dom-props` rule (#23970) (Mikhail Baev) ### 🐛 Bug Fixes - 7b80010 linter: Use direct binding symbol ids (#24216) (camc314) - 8f94b49 linter/import/no-duplicates: Don't flag a type-only import beside a side-effect import (#24030) (Boshen) - d8c3fee linter/react/rules-of-hooks: Flag `useEffectEvent` escapes (#23764) (Rayan Salhab) - 0a7312b linter/no-deprecated-functions: Map `require.requireActual` to `jest.requireActual` (#23627) (Jerry Zhao) - d9e3ab3 linter/eslint/no-useless-return: Handle switch case continuation (#23984) (camc314) - 0b25582 ast: Type binding node `typeAnnotation` as `TSTypeAnnotation | null` (#23113) (Boshen) - 122d112 linter/eslint/no-restricted-imports: Flag dynamic import() expressions (#24029) (Boshen) - 59b6b83 linter: Avoid `OnceLock` re-entry on cyclic `export *` re-exports (#23632) (Jerry Zhao) - dd09af0 linter/import/namespace: Avoid panic on destructuring of an unresolvable namespace re-export (#23626) (Jerry Zhao) - bdb51c7 linter/jest/prefer-ending-with-an-expect: Validate config patterns (#24122) (camc314) - e383843 linter/unicorn/prefer-modern-dom-apis: Skip fixer for non identifier arguments (#23630) (Jerry Zhao) - 0ac4c83 linter: Detect circular config extends (#24115) (camc314) - bae1edf linter/import/namespace: Check namespace imports after named imports (#24094) (camc314) - cd8fdfe linter/eslint/no-eval: Recognize Array.from family thisArg (#24091) (camc314) - 851ee43 linter/eslint/no-eval: Resolve this binding for functions returned from an IIFE (#23643) (Jerry Zhao) - 002ab35 linter/unicorn: Avoid prefer-array-find rest destructuring false positive (#23654) (ColemanDunn) - 01c8775 linter/unicorn/filename-case: Keep digits attached in screamingSnakeCase (#24056) (Boshen) - f256941 linter: Recognize `@effect/vitest` as a vitest import source (#24025) (Boshen) - 73eeb1d linter/import/extensions: Honor per-extension `never` for explicit extensions (#24031) (Boshen) - d4ebe1f linter: Reject non-object oxlint config files (#24026) (Boshen) - 45d607d linter/react/forbid-component-props: Make allow/disallow lists optional in schema (#24024) (Boshen) - 54076ad linter/unicorn/no-array-for-each: Suggest entries loop for index callbacks (#24004) (camc314) - d057736 linter/jsdoc: Avoid param root underflow (#23945) (camc314) - 29c76bf linter/unicorn/prefer-at: Skip object numeric-key access (#23909) (Gaurav Dubey) ### ⚡ Performance - 657a8fc linter/oxc/bad-array-method-on-arguments: Only run on member expressions instead of all identifiers (#24164) (camchenry) - 073d9e7 linter/eslint/prefer-rest-params: Run on functions instead of all identifiers (#24163) (camchenry) - e5a4162 linter/jest/no-confusing-set-timeout: Early exit fast path (#24092) (camc314) - bca7ce5 linter: Only run react-perf rules on JSX attribute nodes (#24083) (camchenry) - 6881bf6 linter: Compute `apply_overrides` rule set lazily (#23648) (Jerry Zhao) - 911c106 linter/eslint/no-obj-calls: Use resolved reference instead of scope walk (#23895) (Marius Schulz) - dc8fd9a linter/unicorn/prefer-dom-node-text-content: Change dispatch to run only on less common node types (#23897) (Connor Shea) - fdbd34d linter/eslint/no-useless-call: Fast-path static callees (#24077) (camc314) - b1be114 linter/import/extensions: Skip empty config and borrow extensions (#24075) (camc314) - 4781b2d linter/eslint/no-obj-calls: Use direct global matches (#24076) (camc314) - e6cee89 linter: Avoid node-chain allocation for non-Jest calls (#23907) (Yagiz Nizipli) - 30dc517 linter/typescript/no-restricted-types: O(1) banned-type lookups (#23827) (Yagiz Nizipli) ### 📚 Documentation - 6ca9125 linter/typescript: Clarify consistent-type-imports behavior (#23972) (camc314) # Oxfmt ### 🚀 Features - 4f4313e formatter_css: Update oxc-css-parser 0.0.5 (#24120) (leaysgur) - 0ccd8a1 formatter_graphql: Update oxc-graphql-parser 0.0.5 (#24106) (leaysgur) - 89ec3d9 formatter_core: Add literal line and root indention primitives (#24051) (leaysgur) - 213a96b formatter_core: Add no-expand-parent for multiline text (#24050) (leaysgur) - 0e5bcc9 formatter_graphql: Update oxc-graphql-parser 0.0.4 (#24039) (leaysgur) - e0b35a1 formatter_css: Update `[email protected]` (#23974) (leaysgur) ### 🐛 Bug Fixes - 1fe6546 formatter: Omit unneeded `;` for type members with `no-semi` (#24212) (leaysgur) - 0ad7316 formatter: Print space for `ForStatement`.`update` only if exists (#24211) (leaysgur) - 3abbed5 formatter: Print `;` before jsdoc type-cast parens with no-semi (#24208) (leaysgur) - 9af3833 formatter_css: Make scss formatter consistent (#24207) (leaysgur) - 46d7194 formatter_css: Use fill IR for `@forward` members (#24206) (leaysgur) - e31038f formatter_css: Keep comment inside sass config list (#24205) (leaysgur) - d3b9591 formatter: Add parens around `await/yield` with `<T>` (#24202) (leaysgur) - 2121a55 oxfmt: Reuse tinypool process during the same LSP process (#24197) (leaysgur) - 9bf4b4a formatter_css: Align CSS output to Prettier 3.9.1 (#24100) (leaysgur) - cd2452e formatter_css: Align SCSS output to Prettier 3.9.1 (#24097) (leaysgur) - 4ee8745 formatter_css: Keep selector value contain line-break without breaking line (#24055) (leaysgur) - e1ece97 formatter_graphql: Break `implements` list by print-width (#23997) (leaysgur) - 0a6b16c formatter_json: Preserve key and literal value for json-stringify (#23996) (leaysgur) - 903ab6e formatter_css: Preserve newlines in css-in-js selector list (#23992) (leaysgur) - ea5d095 oxfmt: Update `--migrate prettier` (#23963) (leaysgur) ### ⚡ Performance - 468e1e3 formatter_core: Make printer queues cursor-based (#24098) (Boshen) - c59f2fe rust: Return impl ExactSizeIterator from slice-backed accessors (#24144) (Boshen) - c292fb2 formatter: Inline fits element dispatcher (#23982) (camc314) Co-authored-by: Boshen <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
parse_jest_fn_callis the shared helper the 34 Jest/Vitest rules use to decide whether a call expression is a Jest/Vitest call, so it runs on (effectively) everyCallExpression. It always built a heap-allocated member-call chain viaget_node_chain(aVec+ recursion) before deciding the call was not a Jest call.This PR makes three cost-only changes — the returned values are byte-identical to before:
!is_jest() && !is_vitest()). That is the only framework arm where an unknown root's result does not depend on the slow path'sis_valid_jest_call/is_valid_vitest_callfiltering: on a test file an unknown root (e.g.setTimeout) is rejected there and the slow path returnsNone, so test files deliberately fall through to the slow path. On a non-test file the slow path builds a single-element chain (emptymembers), skips those test-only checks, and returns eitherNone(call not at the top of its chain) orSome(GeneralJest { kind: Unknown, members: [], .. }); the fast path reproduces exactly that without the allocation. It still returnsSomethere, so consumers that read the result on non-test files —vitest/prefer-called-exactly-once-withandjest/prefer-importing-jest-globals— are unaffected.call_chainsVec on the non-test arm, where it is never read.remove(0)instead ofsplit_off(1)to reuse the chain's allocation formembers.Why it is behavior-preserving
Verified by:
crates/oxc_linter/tests/jest_fn_fast_path_oracle.rs, 5 cases) driving the realLinter::runwith every rule enabled. It pins both the non-test behavior (a globalpending()is reported byprefer-importing-jest-globals; asetTimeout(() => { expect(x).toHaveBeenCalledOnce(); expect(x).toHaveBeenCalledWith('hoge'); })wrapper is reported byprefer-called-exactly-once-with) and the test-file behavior (on avitest-importing file and on a*.test.tsfile the samesetTimeoutwrapper is NOT reported — matchingmain). The two test-file cases fail on a naive un-gated fast path and pass onmainand on this change.ConfigStoreBuilder::all())Linter::rundiagnostic digest: byte-for-byte identical tomainacross react, excalidrawApp.tsx, the TypeScript compiler'sbinder.ts, RadixUI, kitchen-sink, and test-file fixtures that import fromvitestand@jest/globals(sois_vitest()/is_jest()are true) plus fixtures exercisingpending/each/member chains/foo().bar()/describe.each/thesetTimeoutwrapper.cargo test -p oxc_linter: 1162 passed (+ 5 oracle cases).cargo lintgenreports no generated-file change.cargo clippy -p oxc_linterclean.Benchmark
cargo bench -p oxc_benchmark --bench linter --no-default-features --features linter, comparing a--save-baseline mainbuild ofmainagainst this branch (criterionchange, allp < 0.05), three runs:No file regresses. Aggregated over the whole linter benchmark workload: total time ≈ −16%, geometric mean ≈ −14%, stable across all three runs. The win is not isolated to the synthetic
kitchen-sinkfixture — two real-world files (react.development.jsandbinder.ts) independently exceed 10% in every run;binder.tsbenefits most as it is extremely call-expression-dense.How the target was found
Profiling the linter benchmark (
sample) showedexecute_rulesis ~96% ofLinter::run, with cost spread across ~470 rules and no single rule above ~4%; the Jest/Vitestparse_jest_fn_callpath (dominated by the per-callget_node_chainallocation) was the single largest cross-cutting cost.AI usage disclosure (per repo policy): produced with AI assistance (Grok). The profiling, the before/after diagnostic-equality digest (incl. test files), the oracle test, the full test suite, and the benchmark runs were all executed and reviewed.