diff --git a/.changeset/fast-bears-mix.md b/.changeset/fast-bears-mix.md new file mode 100644 index 0000000000..ec93b13f5e --- /dev/null +++ b/.changeset/fast-bears-mix.md @@ -0,0 +1,5 @@ +--- +'@tanstack/eslint-plugin-start': patch +--- + +Avoid repeated whole-file and whole-graph scans when checking server and async client components. diff --git a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts new file mode 100644 index 0000000000..3bfafc18d3 --- /dev/null +++ b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts @@ -0,0 +1,231 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { RuleTester } from '@typescript-eslint/rule-tester' +import { TSESLint } from '@typescript-eslint/utils' +import { afterEach, assert, expect, test, vi } from 'vitest' +import { rule as serverRule } from '../rules/no-client-code-in-server-component/no-client-code-in-server-component.rule' +import { rule as asyncRule } from '../rules/no-async-client-component/no-async-client-component.rule' +import * as contextAnalyzer from '../rules/no-async-client-component/context-analyzer' +import type * as violationDetector from '../rules/no-client-code-in-server-component/violation-detector' +import type * as renderGraphBuilder from '../rules/no-async-client-component/render-graph-builder' +import type * as ts from 'typescript' + +const counts = vi.hoisted(() => ({ detectorNodes: 0, edgeReads: 0, builds: 0 })) + +// Count work in the real detector and graph builder, without timing assertions. +vi.mock( + '../rules/no-client-code-in-server-component/violation-detector', + async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + createViolationDetector( + tsLib: typeof ts, + options: Parameters[1], + ) { + return original.createViolationDetector( + { + ...tsLib, + forEachChild(node, cbNode, cbNodes) { + counts.detectorNodes++ + return tsLib.forEachChild(node, cbNode, cbNodes) + }, + }, + options, + ) + }, + } + }, +) + +vi.mock( + '../rules/no-async-client-component/render-graph-builder', + async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + createRenderGraphBuilder( + ...args: Parameters + ) { + const builder = original.createRenderGraphBuilder(...args) + const graph = builder.getGraph() + graph.edges = new Proxy(graph.edges, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + counts.edgeReads++ + } + return Reflect.get(target, key, receiver) + }, + }) + return { + ...builder, + build() { + counts.builds++ + return builder.build() + }, + } + }, + } + }, +) + +const directories: Array = [] +afterEach(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } + directories.length = 0 +}) + +function createLint(files: Record) { + const directory = mkdtempSync(path.join(tmpdir(), 'start-eslint-')) + directories.push(directory) + for (const [name, code] of Object.entries(files)) { + writeFileSync(path.join(directory, name), code) + } + writeFileSync( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { jsx: 'preserve', noLib: true, types: [] }, + files: Object.keys(files), + }), + ) + const linter = new TSESLint.Linter({ cwd: directory }) + return (rule: TSESLint.AnyRuleModule, names = Object.keys(files)) => { + counts.detectorNodes = 0 + counts.edgeReads = 0 + counts.builds = 0 + const messages = names.flatMap((name) => { + const code = files[name] + assert.isDefined(code) + return linter.verify( + code, + [ + { + files: ['**/*.tsx'], + languageOptions: { + parser: RuleTester.getDefaultConfig().languageOptions?.parser, + parserOptions: { + disallowAutomaticSingleRunInference: true, + projectService: true, + tsconfigRootDir: directory, + }, + }, + plugins: { test: { rules: { check: rule } } }, + rules: { 'test/check': 'error' }, + }, + ], + { filename: path.join(directory, name) }, + ) + }) + expect(messages.filter((message) => message.fatal)).toEqual([]) + return { ...counts, messages } + } +} + +test.each(['callback', 'jsx'])( + 'direct %s checks scale with server roots without rescanning unrelated code', + (kind) => { + function lintRoots(count: number) { + const outside = `function Client() { useEffect(); return ` + return kind === 'callback' + ? `createCompositeComponent(({ value = window.location }) => ${jsx});` + : `renderServerComponent(${jsx});` + }) + return createLint({ 'roots.tsx': [outside, ...roots].join('\n') })( + serverRule, + ) + } + + const small = lintRoots(8) + const large = lintRoots(32) + expect(small.messages).toHaveLength(8) + expect(large.messages.map((message) => message.messageId)).toEqual( + Array(32).fill('eventHandlerInServerComponent'), + ) + expect(large.detectorNodes).toBeGreaterThan(0) + expect(large.detectorNodes).toBeLessThanOrEqual(small.detectorNodes * 5) + }, +) + +test('slicing separate route graphs scales with reachable edges and reuses cached analysis', () => { + function lintRoutes(count: number) { + const files = Object.fromEntries( + Array.from({ length: count }, (_, i) => [ + `route-${i}.tsx`, + ` + function Page() { return } + function Panel() { return } + async function Leaf() { return } + export const Route = createFileRoute('/${i}')({ component: Page }); + `, + ]), + ) + const lint = createLint(files) + const cold = lint(asyncRule) + const warm = lint(asyncRule) + expect(warm.messages).toEqual(cold.messages) + expect(cold.builds).toBe(1) + expect(warm.builds).toBe(0) + expect(warm.edgeReads).toBe(0) + expect(cold.messages.map((message) => message.messageId)).toEqual( + Array.from({ length: count }, () => [ + 'asyncClientComponentUsage', + 'asyncClientComponentDefinition', + ]).flat(), + ) + return cold + } + + const small = lintRoutes(8) + const large = lintRoutes(32) + expect(large.edgeReads).toBeGreaterThan(0) + expect(large.edgeReads).toBeLessThanOrEqual(small.edgeReads * 5) +}) + +test.each([false, true])( + 'slicing preserves JSX edge order through duplicate edges, diamonds, and cycles (unreachable edges: %s)', + (unreachableEdges) => { + const analyze = vi.spyOn(contextAnalyzer, 'analyzeContext') + const lint = createLint({ + 'route.tsx': ` + function Page() { return <> } + function Left() { return } + function Right() { return } + async function Leaf() { return } + export const Route = createFileRoute('/')({ component: Page }); + `, + ...(unreachableEdges + ? { + 'unrelated.tsx': ` + 'use client'; + function Unrelated() { return } + async function Other() { return } + `, + } + : {}), + }) + const result = lint(asyncRule, ['route.tsx']) + expect(result.messages.map((message) => message.messageId)).toEqual([ + 'asyncClientComponentUsage', + 'asyncClientComponentUsage', + 'asyncClientComponentDefinition', + ]) + const [call] = analyze.mock.calls + assert.isDefined(call) + const [graph] = call + expect( + graph.edges.map((edge) => [edge.fromComponent, edge.toComponent]), + ).toEqual([ + ['Page', 'Left'], + ['Page', 'Right'], + ['Page', 'Left'], + ['Left', 'Leaf'], + ['Right', 'Leaf'], + ['Leaf', 'Page'], + ]) + }, +) diff --git a/packages/eslint-plugin-start/src/rules/no-async-client-component/no-async-client-component.rule.ts b/packages/eslint-plugin-start/src/rules/no-async-client-component/no-async-client-component.rule.ts index 54a69a6fb8..c43af40d1e 100644 --- a/packages/eslint-plugin-start/src/rules/no-async-client-component/no-async-client-component.rule.ts +++ b/packages/eslint-plugin-start/src/rules/no-async-client-component/no-async-client-component.rule.ts @@ -31,7 +31,10 @@ const onDemandCache = new WeakMap< >() // Cache adjacency per program to avoid O(allEdges) scans while slicing -const adjacencyCache = new WeakMap>>() +const adjacencyCache = new WeakMap< + ts.Program, + Map; edgeIndexes: Array }> +>() export const rule = ESLintUtils.RuleCreator(getDocsUrl)({ name, @@ -308,15 +311,21 @@ export const rule = ESLintUtils.RuleCreator(getDocsUrl)({ for (const r of entryServerRoots) queue.push(r) const adjacency = getAdjacency(full) + const subEdgeIndexes: Array = [] while (queue.length) { const key = queue.pop()! if (reachable.has(key)) continue reachable.add(key) - const children = adjacency.get(key) - if (!children) continue - for (const toKey of children) { + const outgoing = adjacency.get(key) + if (!outgoing) { + continue + } + for (const index of outgoing.edgeIndexes) { + subEdgeIndexes.push(index) + } + for (const toKey of outgoing.children) { if (!reachable.has(toKey)) { queue.push(toKey) } @@ -329,10 +338,12 @@ export const rule = ESLintUtils.RuleCreator(getDocsUrl)({ if (comp) subComponents.set(key, comp) } - const subEdges = edges.filter((edge) => { - const fromKey = `${edge.fromFile}:${edge.fromComponent}` - return reachable.has(fromKey) && reachable.has(edge.toComponentKey) - }) + // Every outgoing target is reachable. Keep duplicate JSX edges and restore + // their original order for context propagation and diagnostics. + const subEdges = + subEdgeIndexes.length === edges.length + ? edges.slice() + : subEdgeIndexes.sort((a, b) => a - b).map((index) => edges[index]!) const subServerRoots = new Set( [...entryServerRoots].filter((k) => reachable.has(k)), @@ -373,14 +384,16 @@ export const rule = ESLintUtils.RuleCreator(getDocsUrl)({ if (cachedAdjacency) return cachedAdjacency cachedAdjacency = new Map() - for (const edge of full.edges) { + for (let index = 0; index < full.edges.length; index++) { + const edge = full.edges[index]! const fromKey = `${edge.fromFile}:${edge.fromComponent}` - let set = cachedAdjacency.get(fromKey) - if (!set) { - set = new Set() - cachedAdjacency.set(fromKey, set) + let outgoing = cachedAdjacency.get(fromKey) + if (!outgoing) { + outgoing = { children: new Set(), edgeIndexes: [] } + cachedAdjacency.set(fromKey, outgoing) } - set.add(edge.toComponentKey) + outgoing.children.add(edge.toComponentKey) + outgoing.edgeIndexes.push(index) } adjacencyCache.set(program, cachedAdjacency) diff --git a/packages/eslint-plugin-start/src/rules/no-client-code-in-server-component/no-client-code-in-server-component.rule.ts b/packages/eslint-plugin-start/src/rules/no-client-code-in-server-component/no-client-code-in-server-component.rule.ts index 57d314ee9c..e0d126783c 100644 --- a/packages/eslint-plugin-start/src/rules/no-client-code-in-server-component/no-client-code-in-server-component.rule.ts +++ b/packages/eslint-plugin-start/src/rules/no-client-code-in-server-component/no-client-code-in-server-component.rule.ts @@ -186,18 +186,16 @@ export const rule = ESLintUtils.RuleCreator(getDocsUrl)({ const sourceFile = node.getSourceFile() // Detect violations in the whole arg node - const directViolations = violationDetector.detectViolations(sourceFile) - const start = node.getStart() - const end = node.getEnd() + const directViolations = violationDetector.detectViolationsInNode( + node, + sourceFile, + ) for (const violation of directViolations) { - const violationPos = violation.node.getStart() - if (violationPos >= start && violationPos <= end) { - const key = `${violation.fileName}:${violation.line}:${violation.name}` - if (!reportedViolations.has(key)) { - reportedViolations.add(key) - reportViolation(violation, [], rootKind, eslintNode) - } + const key = `${violation.fileName}:${violation.line}:${violation.name}` + if (!reportedViolations.has(key)) { + reportedViolations.add(key) + reportViolation(violation, [], rootKind, eslintNode) } } @@ -247,20 +245,16 @@ export const rule = ESLintUtils.RuleCreator(getDocsUrl)({ const sourceFile = callback.getSourceFile() // Check for violations in the callback itself - const directViolations = violationDetector.detectViolations(sourceFile) - - // Filter to only violations within the callback body - const callbackStart = body.getStart() - const callbackEnd = body.getEnd() + const directViolations = violationDetector.detectViolationsInNode( + body, + sourceFile, + ) for (const violation of directViolations) { - const violationPos = violation.node.getStart() - if (violationPos >= callbackStart && violationPos <= callbackEnd) { - const key = `${violation.fileName}:${violation.line}:${violation.name}` - if (!reportedViolations.has(key)) { - reportedViolations.add(key) - reportViolation(violation, [], rootKind, eslintNode) - } + const key = `${violation.fileName}:${violation.line}:${violation.name}` + if (!reportedViolations.has(key)) { + reportedViolations.add(key) + reportViolation(violation, [], rootKind, eslintNode) } } }