@@ -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 )
0 commit comments