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
6 changes: 6 additions & 0 deletions .changeset/start-compiler-module-info-ast-retention.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 54 additions & 3 deletions packages/router-utils/src/compiler-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ type IdentifierScopeFrame = {
}
type IdentifierScopeStack = Array<IdentifierScopeFrame>

/**
* 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'
Expand All @@ -24,7 +33,7 @@ export type ModuleInfoBinding =
}
| {
type: 'var'
init: t.Expression | null
init: ExpressionSummary | null
}

export interface ExtractedModuleInfo {
Expand Down Expand Up @@ -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<string, ModuleInfoBinding>,
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 6 additions & 1 deletion packages/router-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
73 changes: 71 additions & 2 deletions packages/router-utils/tests/compiler-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
collectIdentifiersFromNode,
collectLocalBindingsFromStatement,
extractModuleInfoFromAst,
summarizeExpression,
} from '../src/compiler-helpers'
import { parseAst } from '../src/ast'

Expand Down Expand Up @@ -199,15 +200,15 @@ describe('extractModuleInfoFromAst', () => {
],
[
"exported",
"Identifier",
"identifier",
],
[
"loader",
null,
],
[
"local",
"Identifier",
"identifier",
],
[
"localNamed",
Expand Down Expand Up @@ -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()
})
})
88 changes: 40 additions & 48 deletions packages/start-plugin-core/src/start-compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
generateFromAst,
getVariableDeclaratorForExpressionPath,
parseAst,
summarizeExpression,
unwrapExpression,
} from '@tanstack/router-utils'
import babel from '@babel/core'
Expand All @@ -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,
Expand Down Expand Up @@ -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,
),
})),
)),
)
Expand Down Expand Up @@ -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'
Expand All @@ -1817,70 +1823,56 @@ export class StartCompiler {
}

private async resolveExprKind(
expr: t.Expression | null,
expr: ExpressionSummary | null,
fileId: string,
visited = new Set<string>(),
): Promise<Kind> {
if (!expr) {
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<string>(),
): Promise<Kind> {
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)
Expand Down Expand Up @@ -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 (
Expand All @@ -1929,7 +1921,7 @@ export class StartCompiler {
{
type: 'import',
source: binding.source,
importedName: callee.property.name,
importedName: prop,
},
fileId,
visited,
Expand Down