Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fast-bears-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/eslint-plugin-start': patch
---

Avoid repeated whole-file and whole-graph scans when checking server and async client components.
231 changes: 231 additions & 0 deletions packages/eslint-plugin-start/src/__tests__/rule-performance.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof violationDetector>()
return {
...original,
createViolationDetector(
tsLib: typeof ts,
options: Parameters<typeof original.createViolationDetector>[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<typeof renderGraphBuilder>()
return {
...original,
createRenderGraphBuilder(
...args: Parameters<typeof original.createRenderGraphBuilder>
) {
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<string> = []
afterEach(() => {
for (const directory of directories) {
rmSync(directory, { recursive: true, force: true })
}
directories.length = 0
})

function createLint(files: Record<string, string>) {
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 <button onClick={() => {}} /> }`
const roots = Array.from({ length: count }, (_, i) => {
const jsx = `<button onClick={() => {}}>${i}</button>`
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 <Panel /> }
function Panel() { return <Leaf /> }
async function Leaf() { return <span /> }
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 <><Left /><Right /><Left /></> }
function Left() { return <Leaf /> }
function Right() { return <Leaf /> }
async function Leaf() { return <Page /> }
export const Route = createFileRoute('/')({ component: Page });
`,
...(unreachableEdges
? {
'unrelated.tsx': `
'use client';
function Unrelated() { return <Other /> }
async function Other() { return <span /> }
`,
}
: {}),
})
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'],
])
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ const onDemandCache = new WeakMap<
>()

// Cache adjacency per program to avoid O(allEdges) scans while slicing
const adjacencyCache = new WeakMap<ts.Program, Map<string, Set<string>>>()
const adjacencyCache = new WeakMap<
ts.Program,
Map<string, { children: Set<string>; edgeIndexes: Array<number> }>
>()

export const rule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)({
name,
Expand Down Expand Up @@ -308,15 +311,21 @@ export const rule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)({
for (const r of entryServerRoots) queue.push(r)

const adjacency = getAdjacency(full)
const subEdgeIndexes: Array<number> = []

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)
}
Expand All @@ -329,10 +338,12 @@ export const rule = ESLintUtils.RuleCreator<ExtraRuleDocs>(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)),
Expand Down Expand Up @@ -373,14 +384,16 @@ export const rule = ESLintUtils.RuleCreator<ExtraRuleDocs>(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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,16 @@ export const rule = ESLintUtils.RuleCreator<ExtraRuleDocs>(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)
}
}

Expand Down Expand Up @@ -247,20 +245,16 @@ export const rule = ESLintUtils.RuleCreator<ExtraRuleDocs>(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)
}
}
}
Expand Down
Loading