diff --git a/.changeset/start-compiler-module-info-ast-retention.md b/.changeset/start-compiler-module-info-ast-retention.md new file mode 100644 index 0000000000..6cbe0c69ac --- /dev/null +++ b/.changeset/start-compiler-module-info-ast-retention.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-utils': patch +'@tanstack/start-plugin-core': patch +--- + +Stop the Start compiler's module cache from retaining parsed ASTs. `extractModuleInfoFromAst` now stores a small summary of each binding's initializer instead of the `t.Expression` node itself, so a cached module no longer keeps the `@babel/traverse` `NodePath` and `Scope` graph of the file it came from reachable. diff --git a/packages/router-utils/src/compiler-helpers.ts b/packages/router-utils/src/compiler-helpers.ts index 5460c34914..addf05306d 100644 --- a/packages/router-utils/src/compiler-helpers.ts +++ b/packages/router-utils/src/compiler-helpers.ts @@ -16,6 +16,15 @@ type IdentifierScopeFrame = { } type IdentifierScopeStack = Array +/** + * A detached description of an expression: identifier, member access or call, + * and the names involved. Holding one does not keep the parsed file alive. + */ +export type ExpressionSummary = + | { type: 'identifier'; name: string } + | { type: 'member'; object: ExpressionSummary; property: string } + | { type: 'call'; callee: ExpressionSummary } + export type ModuleInfoBinding = | { type: 'import' @@ -24,7 +33,7 @@ export type ModuleInfoBinding = } | { type: 'var' - init: t.Expression | null + init: ExpressionSummary | null } export interface ExtractedModuleInfo { @@ -86,6 +95,46 @@ function getModuleExportName(node: t.Identifier | t.StringLiteral) { return t.isIdentifier(node) ? node.name : node.value } +/** + * Projects an expression onto the parts module info consumers read. Anything + * they cannot resolve summarizes to `null`, like an absent initializer. + */ +export function summarizeExpression( + expression: t.Expression | null | undefined, +): ExpressionSummary | null { + if (!expression) { + return null + } + + const expr = unwrapExpression(expression) + + if (t.isIdentifier(expr)) { + return { type: 'identifier', name: expr.name } + } + + if (t.isMemberExpression(expr)) { + // `computed` is deliberately not distinguished: consumers have always + // treated `obj[prop]` and `obj.prop` alike. + if (!t.isIdentifier(expr.property) || !t.isExpression(expr.object)) { + return null + } + const object = summarizeExpression(expr.object) + if (!object) { + return null + } + return { type: 'member', object, property: expr.property.name } + } + + if (t.isCallExpression(expr)) { + const callee = t.isExpression(expr.callee) + ? summarizeExpression(expr.callee) + : null + return callee ? { type: 'call', callee } : null + } + + return null +} + function addVariableDeclarationModuleInfo( declaration: t.VariableDeclaration, bindings: Map, @@ -95,7 +144,7 @@ function addVariableDeclarationModuleInfo( for (const name of collectIdentifiersFromPattern(declarator.id)) { bindings.set(name, { type: 'var', - init: declarator.init ?? null, + init: summarizeExpression(declarator.init), }) exportMap?.set(name, name) } @@ -552,7 +601,9 @@ export function extractModuleInfoFromAst(ast: t.File): ExtractedModuleInfo { const synth = '__default_export__' bindings.set(synth, { type: 'var', - init: t.isExpression(declaration) ? declaration : null, + init: t.isExpression(declaration) + ? summarizeExpression(declaration) + : null, }) exportMap.set('default', synth) } diff --git a/packages/router-utils/src/index.ts b/packages/router-utils/src/index.ts index 12c3c8e20c..ebc2e83d1c 100644 --- a/packages/router-utils/src/index.ts +++ b/packages/router-utils/src/index.ts @@ -28,7 +28,12 @@ export { removeModuleLevelBindings, retainModuleLevelDeclarations, stripUnreferencedTopLevelExpressionStatements, + summarizeExpression, unwrapExpression, unwrapExportedDeclarations, } from './compiler-helpers' -export type { ExtractedModuleInfo, ModuleInfoBinding } from './compiler-helpers' +export type { + ExpressionSummary, + ExtractedModuleInfo, + ModuleInfoBinding, +} from './compiler-helpers' diff --git a/packages/router-utils/tests/compiler-helpers.test.ts b/packages/router-utils/tests/compiler-helpers.test.ts index 00b75d67e5..b18fbfe0eb 100644 --- a/packages/router-utils/tests/compiler-helpers.test.ts +++ b/packages/router-utils/tests/compiler-helpers.test.ts @@ -5,6 +5,7 @@ import { collectIdentifiersFromNode, collectLocalBindingsFromStatement, extractModuleInfoFromAst, + summarizeExpression, } from '../src/compiler-helpers' import { parseAst } from '../src/ast' @@ -199,7 +200,7 @@ describe('extractModuleInfoFromAst', () => { ], [ "exported", - "Identifier", + "identifier", ], [ "loader", @@ -207,7 +208,7 @@ describe('extractModuleInfoFromAst', () => { ], [ "local", - "Identifier", + "identifier", ], [ "localNamed", @@ -247,3 +248,71 @@ describe('extractModuleInfoFromAst', () => { `) }) }) + +describe('summarizeExpression', () => { + function summarize(code: string) { + return summarizeExpression(getVariableInit(code)) + } + + test('summarizes identifiers, member access, and calls', () => { + expect(summarize('const value = other')).toEqual({ + type: 'identifier', + name: 'other', + }) + expect(summarize('const value = ns.member')).toEqual({ + type: 'member', + object: { type: 'identifier', name: 'ns' }, + property: 'member', + }) + expect( + summarize('const value = createServerFn().handler(handler)'), + ).toEqual({ + type: 'call', + callee: { + type: 'member', + object: { + type: 'call', + callee: { type: 'identifier', name: 'createServerFn' }, + }, + property: 'handler', + }, + }) + }) + + test('drops call arguments and source positions', () => { + const summary = summarize('const value = factory(() => "unused")') + + expect(summary).toEqual({ + type: 'call', + callee: { type: 'identifier', name: 'factory' }, + }) + expect(summary).not.toHaveProperty('arguments') + expect(summary).not.toHaveProperty('loc') + expect(summary).not.toHaveProperty('start') + }) + + test('looks through transparent wrappers', () => { + const unwrapped = { type: 'identifier', name: 'other' } + + expect(summarize('const value = (other)')).toEqual(unwrapped) + expect(summarize('const value = other as Something')).toEqual(unwrapped) + expect(summarize('const value = other satisfies Something')).toEqual( + unwrapped, + ) + expect(summarize('const value = other!')).toEqual(unwrapped) + }) + + test('treats computed identifier access like static access', () => { + expect(summarize('const value = ns[member]')).toEqual( + summarize('const value = ns.member'), + ) + }) + + test('returns null for expressions consumers cannot resolve', () => { + expect(summarizeExpression(null)).toBeNull() + expect(summarize('const value = { a: 1 }')).toBeNull() + expect(summarize('const value = ns["member"]')).toBeNull() + expect(summarize('const value = (() => other).call()')).toBeNull() + expect(summarize('const value = import("./x")')).toBeNull() + }) +}) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 90fd5f564c..a695e041e0 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -7,6 +7,7 @@ import { generateFromAst, getVariableDeclaratorForExpressionPath, parseAst, + summarizeExpression, unwrapExpression, } from '@tanstack/router-utils' import babel from '@babel/core' @@ -23,7 +24,10 @@ import type { RewriteCandidate, ServerFn, } from './types' -import type { ModuleInfoBinding } from '@tanstack/router-utils' +import type { + ExpressionSummary, + ModuleInfoBinding, +} from '@tanstack/router-utils' import type { CompileStartFrameworkOptions, StartCompilerEnvironment, @@ -1249,7 +1253,10 @@ export class StartCompiler { ...(await Promise.all( unresolvedCandidates.map(async (candidate) => ({ path: candidate.path, - kind: await this.resolveExprKind(candidate.path.node, id), + kind: await this.resolveExprKind( + summarizeExpression(candidate.path.node), + id, + ), })), )), ) @@ -1806,8 +1813,7 @@ export class StartCompiler { isLookupKind(resolvedKind) && getLookupSetup(resolvedKind, this.externalLookupSetup)?.type === 'directCall' && - binding.init && - t.isCallExpression(unwrapExpression(binding.init)) + binding.init?.type === 'call' ) { binding.resolvedKind = 'None' return 'None' @@ -1817,7 +1823,7 @@ export class StartCompiler { } private async resolveExprKind( - expr: t.Expression | null, + expr: ExpressionSummary | null, fileId: string, visited = new Set(), ): Promise { @@ -1825,62 +1831,48 @@ export class StartCompiler { return 'None' } - expr = unwrapExpression(expr) + if (expr.type === 'identifier') { + return this.resolveIdentifierKind(expr.name, fileId, visited) + } - let result: Kind = 'None' + if (expr.type === 'member') { + return this.resolveCalleeKind(expr.object, fileId, visited) + } - if (t.isCallExpression(expr)) { - if (!t.isExpression(expr.callee)) { - return 'None' - } - const calleeKind = await this.resolveCalleeKind( - expr.callee, - fileId, - visited, - ) - if (calleeKind === 'Root' || calleeKind === 'Builder') { - return 'Builder' - } - // For method chain patterns (callee is MemberExpression like .server() or .client()), - // return the resolved kind if valid - if (t.isMemberExpression(expr.callee)) { - if (this.validLookupKinds.has(calleeKind as LookupKind)) { - return calleeKind - } - } - // For direct calls (callee is Identifier like createServerOnlyFn()), - // trust calleeKind if it resolved to a valid LookupKind. This means - // resolveBindingKind successfully traced the import back to - // @tanstack/start-fn-stubs (via fast path or slow path through re-exports). - // This handles both direct imports from @tanstack/react-start and imports - // from intermediate packages that re-export from @tanstack/start-client-core. - if (t.isIdentifier(expr.callee)) { - if (this.validLookupKinds.has(calleeKind as LookupKind)) { - return calleeKind - } - } - } else if (t.isMemberExpression(expr) && t.isIdentifier(expr.property)) { - result = await this.resolveCalleeKind(expr.object, fileId, visited) + const calleeKind = await this.resolveCalleeKind( + expr.callee, + fileId, + visited, + ) + if (calleeKind === 'Root' || calleeKind === 'Builder') { + return 'Builder' } - if (result === 'None' && t.isIdentifier(expr)) { - result = await this.resolveIdentifierKind(expr.name, fileId, visited) + // A method chain (`.server()`, `.client()`) or a direct call to a factory + // (`createServerOnlyFn()`) takes the kind of its callee, which means + // resolveBindingKind traced the import back to @tanstack/start-fn-stubs, + // directly or through re-exports. A callee that is itself a call does not. + if ( + expr.callee.type !== 'call' && + this.validLookupKinds.has(calleeKind as LookupKind) + ) { + return calleeKind } - return result + return 'None' } private async resolveCalleeKind( - callee: t.Expression, + callee: ExpressionSummary, fileId: string, visited = new Set(), ): Promise { - if (t.isIdentifier(callee)) { + if (callee.type === 'identifier') { return this.resolveIdentifierKind(callee.name, fileId, visited) } - if (t.isMemberExpression(callee) && t.isIdentifier(callee.property)) { - const prop = callee.property.name + if (callee.type === 'member') { + const prop = callee.property // Check if this property matches any method chain pattern const possibleKinds = IdentifierToKinds.get(prop) @@ -1917,7 +1909,7 @@ export class StartCompiler { } // Check if the object is a namespace import - if (t.isIdentifier(callee.object)) { + if (callee.object.type === 'identifier') { const info = await this.getModuleInfo(fileId) const binding = info.bindings.get(callee.object.name) if ( @@ -1929,7 +1921,7 @@ export class StartCompiler { { type: 'import', source: binding.source, - importedName: callee.property.name, + importedName: prop, }, fileId, visited,