From 2637d2924de0e94f8fabc8d9edeecc4c9914b203 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 6 Sep 2026 11:00:07 -0700 Subject: [PATCH 1/4] perf(eslint-plugin-start): avoid repeated file and graph scans --- .changeset/fast-bears-mix.md | 5 + .../src/__tests__/rule-performance.test.ts | 228 ++++++++++++++++++ .../no-async-client-component.rule.ts | 41 ++-- ...no-client-code-in-server-component.rule.ts | 38 ++- 4 files changed, 276 insertions(+), 36 deletions(-) create mode 100644 .changeset/fast-bears-mix.md create mode 100644 packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts 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..dfc2677e39 --- /dev/null +++ b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts @@ -0,0 +1,228 @@ +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: { + 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).toHaveLength(count * 2) + 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 traversal and 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.components.values()].map((component) => component.name), + ).toEqual(['Page', 'Right', 'Leaf', 'Left']) + 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) } } } From 0ce0881eef49c3100e3c40ddf6ec10f002c86449 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 6 Sep 2026 12:30:57 -0700 Subject: [PATCH 2/4] test(eslint-plugin-start): preserve program reuse in CI --- .../eslint-plugin-start/src/__tests__/rule-performance.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts index dfc2677e39..3f51b5ffcc 100644 --- a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts +++ b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts @@ -107,6 +107,7 @@ function createLint(files: Record) { languageOptions: { parser: RuleTester.getDefaultConfig().languageOptions?.parser, parserOptions: { + disallowAutomaticSingleRunInference: true, projectService: true, tsconfigRootDir: directory, }, From 3ea0c3c2436a6891b450f8123411e72c208985cf Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 7 Sep 2026 15:16:22 -0700 Subject: [PATCH 3/4] test: assert lint diagnostics instead of traversal work --- .../src/__tests__/rule-performance.test.ts | 175 ++++-------------- 1 file changed, 40 insertions(+), 135 deletions(-) diff --git a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts index 3f51b5ffcc..4240f7d0e6 100644 --- a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts +++ b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts @@ -3,72 +3,9 @@ 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 { afterEach, assert, expect, test } 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(() => { @@ -93,9 +30,6 @@ function createLint(files: Record) { ) 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) @@ -120,71 +54,58 @@ function createLint(files: Record) { ) }) expect(messages.filter((message) => message.fatal)).toEqual([]) - return { ...counts, messages } + return { messages } } } test.each(['callback', 'jsx'])( - 'direct %s checks scale with server roots without rescanning unrelated code', + 'reports violations in each server %s without reporting unrelated client 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'), + const outside = + 'function Client() { useEffect(); return ` + return kind === 'callback' + ? `createCompositeComponent(({ value = window.location }) => ${jsx});` + : `renderServerComponent(${jsx});` + }) + const result = createLint({ 'roots.tsx': [outside, ...roots].join('\n') })( + serverRule, ) - expect(large.detectorNodes).toBeGreaterThan(0) - expect(large.detectorNodes).toBeLessThanOrEqual(small.detectorNodes * 5) + expect(result.messages.map((message) => message.messageId)).toEqual([ + 'eventHandlerInServerComponent', + 'eventHandlerInServerComponent', + 'eventHandlerInServerComponent', + ]) }, ) -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).toHaveLength(count * 2) - return cold - } - - const small = lintRoutes(8) - const large = lintRoutes(32) - expect(large.edgeReads).toBeGreaterThan(0) - expect(large.edgeReads).toBeLessThanOrEqual(small.edgeReads * 5) +test('reports the same diagnostics when linting separate routes again', () => { + const files = Object.fromEntries( + ['first', 'second'].map((name) => [ + `${name}.tsx`, + ` + function Page() { return } + function Panel() { return } + async function Leaf() { return } + export const Route = createFileRoute('/${name}')({ component: Page }); + `, + ]), + ) + const lint = createLint(files) + const first = lint(asyncRule) + expect(first.messages.map((message) => message.messageId)).toEqual([ + 'asyncClientComponentUsage', + 'asyncClientComponentDefinition', + 'asyncClientComponentUsage', + 'asyncClientComponentDefinition', + ]) + expect(lint(asyncRule).messages).toEqual(first.messages) }) test.each([false, true])( - 'slicing preserves traversal and JSX edge order through duplicate edges, diamonds, and cycles (unreachable edges: %s)', + 'reports diagnostics through duplicate edges, diamonds, and cycles (unreachable edges: %s)', (unreachableEdges) => { - const analyze = vi.spyOn(contextAnalyzer, 'analyzeContext') const lint = createLint({ 'route.tsx': ` function Page() { return <> } @@ -209,21 +130,5 @@ test.each([false, true])( 'asyncClientComponentUsage', 'asyncClientComponentDefinition', ]) - const [call] = analyze.mock.calls - assert.isDefined(call) - const [graph] = call - expect( - [...graph.components.values()].map((component) => component.name), - ).toEqual(['Page', 'Right', 'Leaf', 'Left']) - expect( - graph.edges.map((edge) => [edge.fromComponent, edge.toComponent]), - ).toEqual([ - ['Page', 'Left'], - ['Page', 'Right'], - ['Page', 'Left'], - ['Left', 'Leaf'], - ['Right', 'Leaf'], - ['Leaf', 'Page'], - ]) }, ) From 7c2e1282083b30356933f49aba8620b77bd23eb7 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 7 Sep 2026 20:10:18 -0700 Subject: [PATCH 4/4] test(eslint-plugin-start): preserve traversal work bounds --- .../src/__tests__/rule-performance.test.ts | 177 ++++++++++++++---- 1 file changed, 137 insertions(+), 40 deletions(-) diff --git a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts index 4240f7d0e6..3bfafc18d3 100644 --- a/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts +++ b/packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts @@ -3,9 +3,72 @@ 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 } from 'vitest' +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(() => { @@ -30,6 +93,9 @@ function createLint(files: Record) { ) 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) @@ -54,58 +120,76 @@ function createLint(files: Record) { ) }) expect(messages.filter((message) => message.fatal)).toEqual([]) - return { messages } + return { ...counts, messages } } } test.each(['callback', 'jsx'])( - 'reports violations in each server %s without reporting unrelated client code', + 'direct %s checks scale with server roots without rescanning unrelated code', (kind) => { - const outside = - 'function Client() { useEffect(); return ` - return kind === 'callback' - ? `createCompositeComponent(({ value = window.location }) => ${jsx});` - : `renderServerComponent(${jsx});` - }) - const result = createLint({ 'roots.tsx': [outside, ...roots].join('\n') })( - serverRule, + 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(result.messages.map((message) => message.messageId)).toEqual([ - 'eventHandlerInServerComponent', - 'eventHandlerInServerComponent', - 'eventHandlerInServerComponent', - ]) + expect(large.detectorNodes).toBeGreaterThan(0) + expect(large.detectorNodes).toBeLessThanOrEqual(small.detectorNodes * 5) }, ) -test('reports the same diagnostics when linting separate routes again', () => { - const files = Object.fromEntries( - ['first', 'second'].map((name) => [ - `${name}.tsx`, - ` - function Page() { return } - function Panel() { return } - async function Leaf() { return } - export const Route = createFileRoute('/${name}')({ component: Page }); - `, - ]), - ) - const lint = createLint(files) - const first = lint(asyncRule) - expect(first.messages.map((message) => message.messageId)).toEqual([ - 'asyncClientComponentUsage', - 'asyncClientComponentDefinition', - 'asyncClientComponentUsage', - 'asyncClientComponentDefinition', - ]) - expect(lint(asyncRule).messages).toEqual(first.messages) +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])( - 'reports diagnostics through duplicate edges, diamonds, and cycles (unreachable edges: %s)', + '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 <> } @@ -130,5 +214,18 @@ test.each([false, true])( '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'], + ]) }, )