Skip to content

Commit 7f14405

Browse files
performance: compiler (#6190)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent f7b94b7 commit 7f14405

4 files changed

Lines changed: 222 additions & 37 deletions

File tree

packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts

Lines changed: 200 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ function needsDirectCallDetection(kinds: Set<LookupKind>): boolean {
176176
return false
177177
}
178178

179+
/**
180+
* Checks if all kinds in the set are guaranteed to be top-level only.
181+
* Only ServerFn is always declared at module level (must be assigned to a variable).
182+
* Middleware, IsomorphicFn, ServerOnlyFn, ClientOnlyFn can be nested inside functions.
183+
* When all kinds are top-level-only, we can use a fast scan instead of full traversal.
184+
*/
185+
function areAllKindsTopLevelOnly(kinds: Set<LookupKind>): boolean {
186+
return kinds.size === 1 && kinds.has('ServerFn')
187+
}
188+
179189
/**
180190
* Checks if a CallExpression is a direct-call candidate for NESTED detection.
181191
* Returns true if the callee is a known factory function name.
@@ -234,6 +244,11 @@ export class ServerFnCompiler {
234244
private moduleCache = new Map<string, ModuleInfo>()
235245
private initialized = false
236246
private validLookupKinds: Set<LookupKind>
247+
private resolveIdCache = new Map<string, string | null>()
248+
private exportResolutionCache = new Map<
249+
string,
250+
Map<string, { moduleInfo: ModuleInfo; binding: Binding } | null>
251+
>()
237252
// Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start')
238253
// Maps: libName → (exportName → Kind)
239254
// This allows O(1) resolution for the common case without async resolveId calls
@@ -246,11 +261,44 @@ export class ServerFnCompiler {
246261
lookupKinds: Set<LookupKind>
247262
loadModule: (id: string) => Promise<void>
248263
resolveId: (id: string, importer?: string) => Promise<string | null>
264+
/**
265+
* In 'build' mode, resolution results are cached for performance.
266+
* In 'dev' mode (default), caching is disabled to avoid invalidation complexity with HMR.
267+
*/
268+
mode?: 'dev' | 'build'
249269
},
250270
) {
251271
this.validLookupKinds = options.lookupKinds
252272
}
253273

274+
private get mode(): 'dev' | 'build' {
275+
return this.options.mode ?? 'dev'
276+
}
277+
278+
private async resolveIdCached(id: string, importer?: string) {
279+
if (this.mode === 'dev') {
280+
return this.options.resolveId(id, importer)
281+
}
282+
283+
const cacheKey = importer ? `${importer}::${id}` : id
284+
const cached = this.resolveIdCache.get(cacheKey)
285+
if (cached !== undefined) {
286+
return cached
287+
}
288+
const resolved = await this.options.resolveId(id, importer)
289+
this.resolveIdCache.set(cacheKey, resolved)
290+
return resolved
291+
}
292+
293+
private getExportResolutionCache(moduleId: string) {
294+
let cache = this.exportResolutionCache.get(moduleId)
295+
if (!cache) {
296+
cache = new Map()
297+
this.exportResolutionCache.set(moduleId, cache)
298+
}
299+
return cache
300+
}
301+
254302
private async init() {
255303
// Register internal stub package exports for recognition.
256304
// These don't need module resolution - only the knownRootImports fast path.
@@ -274,7 +322,7 @@ export class ServerFnCompiler {
274322
}
275323
libExports.set(config.rootExport, config.kind)
276324

277-
const libId = await this.options.resolveId(config.libName)
325+
const libId = await this.resolveIdCached(config.libName)
278326
if (!libId) {
279327
throw new Error(`could not resolve "${config.libName}"`)
280328
}
@@ -311,9 +359,14 @@ export class ServerFnCompiler {
311359
this.initialized = true
312360
}
313361

314-
public ingestModule({ code, id }: { code: string; id: string }) {
315-
const ast = parseAst({ code })
316-
362+
/**
363+
* Extracts bindings and exports from an already-parsed AST.
364+
* This is the core logic shared by ingestModule and ingestModuleFromAst.
365+
*/
366+
private extractModuleInfo(
367+
ast: ReturnType<typeof parseAst>,
368+
id: string,
369+
): ModuleInfo {
317370
const bindings = new Map<string, Binding>()
318371
const exports = new Map<string, ExportEntry>()
319372
const reExportAllSources: Array<string> = []
@@ -414,10 +467,19 @@ export class ServerFnCompiler {
414467
reExportAllSources,
415468
}
416469
this.moduleCache.set(id, info)
470+
return info
471+
}
472+
473+
public ingestModule({ code, id }: { code: string; id: string }) {
474+
const ast = parseAst({ code })
475+
const info = this.extractModuleInfo(ast, id)
417476
return { info, ast }
418477
}
419478

420479
public invalidateModule(id: string) {
480+
// Note: Resolution caches (resolveIdCache, exportResolutionCache) are only
481+
// used in build mode where there's no HMR. In dev mode, caching is disabled,
482+
// so we only need to invalidate the moduleCache here.
421483
return this.moduleCache.delete(id)
422484
}
423485

@@ -448,7 +510,13 @@ export class ServerFnCompiler {
448510
}
449511

450512
const checkDirectCalls = needsDirectCallDetection(fileKinds)
513+
// Optimization: ServerFn is always a top-level declaration (must be assigned to a variable).
514+
// If the file only has ServerFn, we can skip full AST traversal and only visit
515+
// the specific top-level declarations that have candidates.
516+
const canUseFastPath = areAllKindsTopLevelOnly(fileKinds)
451517

518+
// Always parse and extract module info upfront.
519+
// This ensures the module is cached for import resolution even if no candidates are found.
452520
const { ast } = this.ingestModule({ code, id })
453521

454522
// Single-pass traversal to:
@@ -462,38 +530,110 @@ export class ServerFnCompiler {
462530
babel.NodePath<t.CallExpression>
463531
>()
464532

465-
babel.traverse(ast, {
466-
CallExpression: (path) => {
467-
const node = path.node
468-
const parent = path.parent
533+
if (canUseFastPath) {
534+
// Fast path: only visit top-level statements that have potential candidates
469535

470-
// Check if this call is part of a larger chain (inner call)
471-
// If so, store it for method chain lookup but don't treat as candidate
472-
if (
473-
t.isMemberExpression(parent) &&
474-
t.isCallExpression(path.parentPath.parent)
475-
) {
476-
// This is an inner call in a chain - store for later lookup
477-
chainCallPaths.set(node, path)
478-
return
536+
// Collect indices of top-level statements that contain candidates
537+
const candidateIndices: Array<number> = []
538+
for (let i = 0; i < ast.program.body.length; i++) {
539+
const node = ast.program.body[i]!
540+
let declarations: Array<t.VariableDeclarator> | undefined
541+
542+
if (t.isVariableDeclaration(node)) {
543+
declarations = node.declarations
544+
} else if (t.isExportNamedDeclaration(node) && node.declaration) {
545+
if (t.isVariableDeclaration(node.declaration)) {
546+
declarations = node.declaration.declarations
547+
}
479548
}
480549

481-
// Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.)
482-
if (isMethodChainCandidate(node, fileKinds)) {
483-
candidatePaths.push(path)
484-
return
550+
if (declarations) {
551+
for (const decl of declarations) {
552+
if (decl.init && t.isCallExpression(decl.init)) {
553+
if (isMethodChainCandidate(decl.init, fileKinds)) {
554+
candidateIndices.push(i)
555+
break // Only need to mark this statement once
556+
}
557+
}
558+
}
485559
}
560+
}
486561

487-
// Pattern 2: Direct call pattern
488-
if (checkDirectCalls) {
489-
if (isTopLevelDirectCallCandidate(path)) {
490-
candidatePaths.push(path)
491-
} else if (isNestedDirectCallCandidate(node)) {
562+
// Early exit: no potential candidates found at top level
563+
if (candidateIndices.length === 0) {
564+
return null
565+
}
566+
567+
// Targeted traversal: only visit the specific statements that have candidates
568+
// This is much faster than traversing the entire AST
569+
babel.traverse(ast, {
570+
Program(programPath) {
571+
const bodyPaths = programPath.get('body')
572+
for (const idx of candidateIndices) {
573+
const stmtPath = bodyPaths[idx]
574+
if (!stmtPath) continue
575+
576+
// Traverse only this statement's subtree
577+
stmtPath.traverse({
578+
CallExpression(path) {
579+
const node = path.node
580+
const parent = path.parent
581+
582+
// Check if this call is part of a larger chain (inner call)
583+
if (
584+
t.isMemberExpression(parent) &&
585+
t.isCallExpression(path.parentPath.parent)
586+
) {
587+
chainCallPaths.set(node, path)
588+
return
589+
}
590+
591+
// Method chain pattern
592+
if (isMethodChainCandidate(node, fileKinds)) {
593+
candidatePaths.push(path)
594+
}
595+
},
596+
})
597+
}
598+
// Stop traversal after processing Program
599+
programPath.stop()
600+
},
601+
})
602+
} else {
603+
// Normal path: full traversal for non-fast-path kinds
604+
babel.traverse(ast, {
605+
CallExpression: (path) => {
606+
const node = path.node
607+
const parent = path.parent
608+
609+
// Check if this call is part of a larger chain (inner call)
610+
// If so, store it for method chain lookup but don't treat as candidate
611+
if (
612+
t.isMemberExpression(parent) &&
613+
t.isCallExpression(path.parentPath.parent)
614+
) {
615+
// This is an inner call in a chain - store for later lookup
616+
chainCallPaths.set(node, path)
617+
return
618+
}
619+
620+
// Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.)
621+
if (isMethodChainCandidate(node, fileKinds)) {
492622
candidatePaths.push(path)
623+
return
493624
}
494-
}
495-
},
496-
})
625+
626+
// Pattern 2: Direct call pattern
627+
if (checkDirectCalls) {
628+
if (isTopLevelDirectCallCandidate(path)) {
629+
candidatePaths.push(path)
630+
} else if (isNestedDirectCallCandidate(node)) {
631+
candidatePaths.push(path)
632+
}
633+
}
634+
},
635+
})
636+
}
497637

498638
if (candidatePaths.length === 0) {
499639
return null
@@ -651,6 +791,19 @@ export class ServerFnCompiler {
651791
exportName: string,
652792
visitedModules = new Set<string>(),
653793
): Promise<{ moduleInfo: ModuleInfo; binding: Binding } | undefined> {
794+
const isBuildMode = this.mode === 'build'
795+
796+
// Check cache first (only for top-level calls in build mode)
797+
if (isBuildMode && visitedModules.size === 0) {
798+
const moduleCache = this.exportResolutionCache.get(moduleInfo.id)
799+
if (moduleCache) {
800+
const cached = moduleCache.get(exportName)
801+
if (cached !== undefined) {
802+
return cached ?? undefined
803+
}
804+
}
805+
}
806+
654807
// Prevent infinite loops in circular re-exports
655808
if (visitedModules.has(moduleInfo.id)) {
656809
return undefined
@@ -662,7 +815,12 @@ export class ServerFnCompiler {
662815
if (directExport) {
663816
const binding = moduleInfo.bindings.get(directExport.name)
664817
if (binding) {
665-
return { moduleInfo, binding }
818+
const result = { moduleInfo, binding }
819+
// Cache the result (build mode only)
820+
if (isBuildMode) {
821+
this.getExportResolutionCache(moduleInfo.id).set(exportName, result)
822+
}
823+
return result
666824
}
667825
}
668826

@@ -671,10 +829,11 @@ export class ServerFnCompiler {
671829
if (moduleInfo.reExportAllSources.length > 0) {
672830
const results = await Promise.all(
673831
moduleInfo.reExportAllSources.map(async (reExportSource) => {
674-
const reExportTarget = await this.options.resolveId(
832+
const reExportTarget = await this.resolveIdCached(
675833
reExportSource,
676834
moduleInfo.id,
677835
)
836+
678837
if (reExportTarget) {
679838
const reExportModule = await this.getModuleInfo(reExportTarget)
680839
return this.findExportInModule(
@@ -689,11 +848,19 @@ export class ServerFnCompiler {
689848
// Return the first valid result
690849
for (const result of results) {
691850
if (result) {
851+
// Cache the result (build mode only)
852+
if (isBuildMode) {
853+
this.getExportResolutionCache(moduleInfo.id).set(exportName, result)
854+
}
692855
return result
693856
}
694857
}
695858
}
696859

860+
// Cache negative result (build mode only)
861+
if (isBuildMode) {
862+
this.getExportResolutionCache(moduleInfo.id).set(exportName, null)
863+
}
697864
return undefined
698865
}
699866

@@ -719,7 +886,7 @@ export class ServerFnCompiler {
719886
}
720887

721888
// Slow path: resolve through the module graph
722-
const target = await this.options.resolveId(binding.source, fileId)
889+
const target = await this.resolveIdCached(binding.source, fileId)
723890
if (!target) {
724891
return 'None'
725892
}
@@ -863,7 +1030,7 @@ export class ServerFnCompiler {
8631030
binding.importedName === '*'
8641031
) {
8651032
// resolve the property from the target module
866-
const targetModuleId = await this.options.resolveId(
1033+
const targetModuleId = await this.resolveIdCached(
8671034
binding.source,
8681035
fileId,
8691036
)

packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export function createServerFnPlugin(opts: {
118118
async handler(code, id) {
119119
let compiler = compilers[this.environment.name]
120120
if (!compiler) {
121+
// Default to 'dev' mode for unknown environments (conservative: no caching)
122+
const mode =
123+
this.environment.mode === 'build' ? 'build' : ('dev' as const)
121124
compiler = new ServerFnCompiler({
122125
env: environment.type,
123126
directive: opts.directive,
@@ -126,6 +129,7 @@ export function createServerFnPlugin(opts: {
126129
environment.type,
127130
opts.framework,
128131
),
132+
mode,
129133
loadModule: async (id: string) => {
130134
if (this.environment.mode === 'build') {
131135
const loaded = await this.load({ id })

packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ describe('createMiddleware compiles correctly', async () => {
9797
// the fast path uses knownRootImports map for O(1) lookup
9898
// Note: init() now resolves from project root, not from a specific file
9999
expect(resolveIdMock).toHaveBeenCalledTimes(1)
100-
expect(resolveIdMock).toHaveBeenCalledWith('@tanstack/react-start')
100+
expect(resolveIdMock).toHaveBeenCalledWith(
101+
'@tanstack/react-start',
102+
undefined,
103+
)
101104
})
102105

103106
test('should use slow path for factory pattern (resolveId called for import resolution)', async () => {
@@ -149,7 +152,11 @@ describe('createMiddleware compiles correctly', async () => {
149152
// Note: The factory module's import from '@tanstack/react-start' ALSO uses
150153
// the fast path (knownRootImports), so no additional resolveId call is needed there.
151154
expect(resolveIdMock).toHaveBeenCalledTimes(2)
152-
expect(resolveIdMock).toHaveBeenNthCalledWith(1, '@tanstack/react-start')
155+
expect(resolveIdMock).toHaveBeenNthCalledWith(
156+
1,
157+
'@tanstack/react-start',
158+
undefined,
159+
)
153160
expect(resolveIdMock).toHaveBeenNthCalledWith(2, './factory', 'test.ts')
154161
})
155162
})

0 commit comments

Comments
 (0)