Skip to content
189 changes: 188 additions & 1 deletion packages/oxlint-plugin-react-doctor/src/plugin/constants/js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,191 @@ export const CHAINABLE_ITERATION_METHODS = new Set(["map", "filter", "forEach",
// detection in `js-combine-iterations` filters that receiver out.
export const ITERATOR_PRODUCING_METHOD_NAMES = new Set(["values", "keys", "entries"]);

export const TEST_FILE_PATTERN = /\.(?:test|spec|stories)\.[tj]sx?$/;
// Vitest browser mode / Storybook test-runner / Playwright Component
// Testing conventionally put the React under-test in a `*.browser.tsx`
// (or `*.browser.jsx`) module. These files *render* the component and
// drive ordered interactions exactly like a `.test.tsx`, so
// `async-parallel` would false-positive on the canonical
// render/expect/click/expect rhythm.
export const BROWSER_TEST_FILE_PATTERN = /\.browser\.[cm]?[jt]sx?$/;

// Module identifiers whose presence in a file's imports proves the
// file is a test, story, or interaction-driving harness. Used by
// `async-parallel` to suppress the rule on files that aren't covered
// by the shared `isTestFilePath` path heuristic (e.g. a helper at
// `src/test-utils.ts` that imports `@testing-library/react`, or a
// Vitest browser fixture co-located with production code).
export const TEST_LIBRARY_IMPORT_SOURCES: ReadonlySet<string> = new Set([
"vitest",
"jest",
"mocha",
"chai",
"sinon",
"expect",
"ava",
"uvu",
"node:test",
"bun:test",
"@testing-library/react",
"@testing-library/react-native",
"@testing-library/react-hooks",
"@testing-library/dom",
"@testing-library/user-event",
"@testing-library/jest-dom",
"@testing-library/vue",
"@testing-library/svelte",
"@testing-library/preact",
"@testing-library/cypress",
"playwright",
"playwright-core",
"@playwright/test",
"@playwright/experimental-ct-react",
"@playwright/experimental-ct-react17",
"cypress",
"@cypress/react",
"@cypress/react18",
"@storybook/test",
"@storybook/test-runner",
"@storybook/testing-library",
"@storybook/jest",
"puppeteer",
"puppeteer-core",
"webdriverio",
"@wdio/globals",
"@nuxt/test-utils",
]);

// Source-prefix matches catch sub-paths and scoped extensions that the
// `TEST_LIBRARY_IMPORT_SOURCES` set can't enumerate exhaustively
// (`vitest/browser`, `@vitest/spy`, `@playwright/test/reporter`, etc.).
// Every entry MUST end in `/` so the prefix can only match a subpath
// — a bare prefix like `@storybook/test` would also subsume
// `@storybook/test-runner` and `@storybook/testing-library`, both of
// which are already enumerated in the exact set above and may diverge
// independently in the future.
export const TEST_LIBRARY_IMPORT_SOURCE_PREFIXES: ReadonlyArray<string> = [
"vitest/",
"@vitest/",
"@jest/",
"@testing-library/",
"@playwright/",
"@storybook/test/",
"@storybook/test-runner/",
"@storybook/testing-library/",
"@cypress/",
"@nuxt/test-utils/",
];

// Callees that strongly signal an ordered UI-driving sequence —
// render/assert/click/assert flows that intentionally serialize
// each `await` to preserve cause-and-effect, NOT independent async
// I/O that should be parallelized with `Promise.all`. Membership is
// checked against the rightmost identifier in the callee chain so
// both `await render(...)` and `await screen.findByRole(...)` match.
export const ORDERED_UI_FLOW_CALLEE_NAMES: ReadonlySet<string> = new Set([
"render",
"rerender",
"renderHook",
"renderToString",
"renderToStaticMarkup",
"act",
"click",
"dblClick",
"dblclick",
"tripleClick",
"tap",
"press",
"longPress",
"type",
"clear",
"fill",
"focus",
"blur",
"hover",
"unhover",
"check",
"uncheck",
"selectOption",
"selectOptions",
"setChecked",
"setInputFiles",
"scrollIntoViewIfNeeded",
"dragTo",
"dragAndDrop",
"drop",
"evaluate",
"evaluateHandle",
"waitFor",
"waitForLoadState",
"waitForSelector",
"waitForURL",
"waitForResponse",
"waitForRequest",
"waitForEvent",
"waitForFunction",
"waitForElementToBeRemoved",
"goto",
"goBack",
"goForward",
"reload",
"screenshot",
"snapshot",
"toMatchSnapshot",
"toMatchInlineSnapshot",
"expect",
"expectTypeOf",
"step",
"describe",
"test",
"it",
"beforeAll",
"beforeEach",
"afterAll",
"afterEach",
"play",
"userEvent",
"screen",
"within",
]);

// `findBy*` / `findAllBy*` are the Testing Library async query family
// — `findByRole`, `findByText`, etc. Treat any callee whose rightmost
// identifier starts with `findBy` or `findAllBy` as a UI flow call,
// without having to enumerate every suffix.
export const ORDERED_UI_FLOW_CALLEE_PREFIXES: ReadonlyArray<string> = ["findBy", "findAllBy"];

// Callees that signal intentional pacing — animation tweens, demo
// sequencing, polling waits, throttles. Awaits on these are
// inherently serial: parallelizing a `sleep(200)` and a `sleep(400)`
// would defeat the point. Matches the rightmost identifier in the
// callee chain (so `await timer.tick(16)` matches "tick" and
// `await animations.spring(...)` matches "spring"). Kept in sync with
// the sister set in `async-await-in-loop`.
export const INTENTIONAL_SEQUENCING_CALLEE_NAMES: ReadonlySet<string> = new Set([
"sleep",
"delay",
"wait",
"pause",
"throttle",
"debounce",
"tick",
"nextTick",
"advanceTimersByTime",
"advanceTimersByTimeAsync",
"runAllTimers",
"runAllTimersAsync",
"runOnlyPendingTimers",
"runOnlyPendingTimersAsync",
"setTimeout",
"setInterval",
"requestAnimationFrame",
"requestIdleCallback",
"animate",
"transition",
"spring",
"tween",
"stagger",
"sequence",
"timeline",
"scrub",
]);
Original file line number Diff line number Diff line change
@@ -1,13 +1,72 @@
import { TEST_FILE_PATTERN } from "../../constants/js.js";
import {
BROWSER_TEST_FILE_PATTERN,
INTENTIONAL_SEQUENCING_CALLEE_NAMES,
ORDERED_UI_FLOW_CALLEE_NAMES,
ORDERED_UI_FLOW_CALLEE_PREFIXES,
} from "../../constants/js.js";
import { SEQUENTIAL_AWAIT_THRESHOLD } from "../../constants/thresholds.js";
import { defineRule } from "../../utils/define-rule.js";
import { getCalleeIdentifierTrail } from "../../utils/get-callee-identifier-trail.js";
import { isTestLibraryImportSource } from "../../utils/is-test-library-import-source.js";
import { walkAst } from "../../utils/walk-ast.js";
import type { EsTreeNode } from "../../utils/es-tree-node.js";
import type { Rule } from "../../utils/rule.js";
import type { RuleContext } from "../../utils/rule-context.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

const getAwaitedCall = (statement: EsTreeNode): EsTreeNode | null => {
if (isNodeOfType(statement, "VariableDeclaration")) {
const declarator = statement.declarations?.[0];
if (declarator && isNodeOfType(declarator.init, "AwaitExpression")) {
return declarator.init.argument ?? null;
}
}
if (
isNodeOfType(statement, "ExpressionStatement") &&
isNodeOfType(statement.expression, "AwaitExpression")
) {
return statement.expression.argument ?? null;
}
return null;
};

const isOrderedUiFlowName = (name: string): boolean => {
if (ORDERED_UI_FLOW_CALLEE_NAMES.has(name)) return true;
return ORDERED_UI_FLOW_CALLEE_PREFIXES.some((prefix) => name.startsWith(prefix));
};

// True when ANY identifier in the callee chain — leaf method, owning
// object, or bare callee — names an ordered UI-flow operation. So
// `await screen.findByRole(...)`, `await page.locator(...).click()`,
// and `await render(...)` all qualify.
const isOrderedUiFlowAwait = (awaitedCall: EsTreeNode | null): boolean => {
if (!awaitedCall) return false;
const trail = getCalleeIdentifierTrail(awaitedCall);
return trail.some(isOrderedUiFlowName);
};

const isIntentionalSequencingAwait = (awaitedCall: EsTreeNode | null): boolean => {
if (!awaitedCall) return false;
const trail = getCalleeIdentifierTrail(awaitedCall);
return trail.some((name) => INTENTIONAL_SEQUENCING_CALLEE_NAMES.has(name));
};

// Skip a consecutive-await block whenever any one of its awaits is an
// ordered-UI-flow call or an intentional sequencing call. A single
// `await page.click(...)` in the middle of three otherwise-independent
// awaits is enough to mark the whole sequence as deliberately
// serialized — collapsing it into `Promise.all([...])` would change
// observable behavior.
const sequenceContainsSerializationSignal = (statements: EsTreeNode[]): boolean => {
for (const statement of statements) {
const awaitedCall = getAwaitedCall(statement);
if (isOrderedUiFlowAwait(awaitedCall)) return true;
if (isIntentionalSequencingAwait(awaitedCall)) return true;
}
return false;
};

const reportIfIndependent = (statements: EsTreeNode[], context: RuleContext): void => {
const declaredNames = new Set<string>();

Expand Down Expand Up @@ -39,21 +98,42 @@ const reportIfIndependent = (statements: EsTreeNode[], context: RuleContext): vo

export const asyncParallel = defineRule<Rule>({
id: "async-parallel",
// `test-noise` opts every file `isTestFilePath(...)` recognises
// (`*.test.*`, `*.spec.*`, `__tests__/`, `e2e/`, `playwright/`,
// `cypress/`, fixtures, mocks, Windows-slashed equivalents, …) out
// of this rule via `mergeAndFilterDiagnostics`. The in-rule guards
// below handle the cases that path matching can't see: Vitest
// browser fixtures (`*.browser.tsx`), production-co-located helpers
// that import a test library, and ordered render→assert→click
// flows. Allow intentional animation/demo pacing or a documented
// inline `// react-doctor-disable-next-line` opt-out.
tags: ["test-noise"],
severity: "warn",
recommendation:
"Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
create: (context: RuleContext) => {
const filename = context.getFilename?.() ?? "";
const isTestFile = TEST_FILE_PATTERN.test(filename);
const isBrowserTestFile = BROWSER_TEST_FILE_PATTERN.test(filename);
let hasTestLibraryImport = false;

const shouldSkipFile = (): boolean => isBrowserTestFile || hasTestLibraryImport;

return {
ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) {
if (hasTestLibraryImport) return;
if (isTestLibraryImportSource(node.source?.value)) {
hasTestLibraryImport = true;
}
},
BlockStatement(node: EsTreeNodeOfType<"BlockStatement">) {
if (isTestFile) return;
if (shouldSkipFile()) return;
const consecutiveAwaitStatements: EsTreeNode[] = [];

const flushConsecutiveAwaits = (): void => {
if (consecutiveAwaitStatements.length >= SEQUENTIAL_AWAIT_THRESHOLD) {
reportIfIndependent(consecutiveAwaitStatements, context);
if (!sequenceContainsSerializationSignal(consecutiveAwaitStatements)) {
reportIfIndependent(consecutiveAwaitStatements, context);
}
}
consecutiveAwaitStatements.length = 0;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { EsTreeNode } from "./es-tree-node.js";
import { isNodeOfType } from "./is-node-of-type.js";

// Walks a `CallExpression`'s callee — including any intermediate
// member accesses, optional-chains, and parenthesised sub-calls — and
// yields every identifier name encountered (leaf-first). For
// `await screen.findByRole("button").focus()` the trail is
// `["focus", "findByRole", "screen"]`; for `await render(...)` it's
// just `["render"]`. Used by `async-parallel` to pattern-match the
// rightmost identifier against UI-flow / sequencing tables without
// having to enumerate every possible chain shape.
export const getCalleeIdentifierTrail = (call: EsTreeNode | null | undefined): string[] => {
// Optional-chained awaits (`await page?.click()`) parse as a
// `ChainExpression` wrapping the underlying `CallExpression`, so the
// entry guard must peel any wrapping chain layers before checking
// for the call/new — otherwise the trail would silently come back
// empty and the UI-flow / sequencing signal would be missed.
let entry: EsTreeNode | null | undefined = call;
while (isNodeOfType(entry, "ChainExpression")) entry = entry.expression;
if (!isNodeOfType(entry, "CallExpression") && !isNodeOfType(entry, "NewExpression")) return [];
const trail: string[] = [];
let cursor: EsTreeNode | null | undefined = entry.callee;
while (cursor) {
if (isNodeOfType(cursor, "ChainExpression")) {
cursor = cursor.expression;
continue;
}
if (isNodeOfType(cursor, "MemberExpression")) {
if (isNodeOfType(cursor.property, "Identifier")) trail.push(cursor.property.name);
cursor = cursor.object;
continue;
}
if (isNodeOfType(cursor, "CallExpression")) {
cursor = cursor.callee;
continue;
}
if (isNodeOfType(cursor, "Identifier")) {
trail.push(cursor.name);
}
break;
}
return trail;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
TEST_LIBRARY_IMPORT_SOURCES,
TEST_LIBRARY_IMPORT_SOURCE_PREFIXES,
} from "../constants/js.js";

// Returns true when an `import ... from "<source>"` (or
// `require("<source>")`) module identifier belongs to a known test
// runner, browser-test harness, assertion library, or interaction
// driver. Used to suppress noisy "this should be parallelized" advice
// in fixtures that import these libraries but don't match the shared
// `isTestFilePath` path heuristic (e.g. `src/test-utils.ts`,
// component fixtures co-located with production code).
export const isTestLibraryImportSource = (source: unknown): boolean => {
if (typeof source !== "string" || source.length === 0) return false;
if (TEST_LIBRARY_IMPORT_SOURCES.has(source)) return true;
return TEST_LIBRARY_IMPORT_SOURCE_PREFIXES.some((prefix) => source.startsWith(prefix));
};
Loading
Loading