UNPKG

@tanstack/router-plugin

Version:

Modern and scalable routing for React applications

1,393 lines (1,233 loc) 69.8 kB
import * as t from '@babel/types' import * as babel from '@babel/core' import * as template from '@babel/template' import { deadCodeElimination, findReferencedIdentifiers, generateFromAst, parseAst, } from '@tanstack/router-utils' import { tsrShared, tsrSplit } from '../constants' import { routeHmrStatement } from '../route-hmr-statement' import { createIdentifier } from './path-ids' import { getFrameworkOptions } from './framework-options' import type { CompileCodeSplitReferenceRouteOptions, ReferenceRouteCompilerPlugin, } from './plugins' import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' import type { Config, DeletableNodes } from '../config' type SplitNodeMeta = { routeIdent: SplitRouteIdentNodes splitStrategy: 'lazyFn' | 'lazyRouteComponent' localImporterIdent: string exporterIdent: string localExporterIdent: string } const SPLIT_NODES_CONFIG = new Map<SplitRouteIdentNodes, SplitNodeMeta>([ [ 'loader', { routeIdent: 'loader', localImporterIdent: '$$splitLoaderImporter', // const $$splitLoaderImporter = () => import('...') splitStrategy: 'lazyFn', localExporterIdent: 'SplitLoader', // const SplitLoader = ... exporterIdent: 'loader', // export { SplitLoader as loader } }, ], [ 'component', { routeIdent: 'component', localImporterIdent: '$$splitComponentImporter', // const $$splitComponentImporter = () => import('...') splitStrategy: 'lazyRouteComponent', localExporterIdent: 'SplitComponent', // const SplitComponent = ... exporterIdent: 'component', // export { SplitComponent as component } }, ], [ 'pendingComponent', { routeIdent: 'pendingComponent', localImporterIdent: '$$splitPendingComponentImporter', // const $$splitPendingComponentImporter = () => import('...') splitStrategy: 'lazyRouteComponent', localExporterIdent: 'SplitPendingComponent', // const SplitPendingComponent = ... exporterIdent: 'pendingComponent', // export { SplitPendingComponent as pendingComponent } }, ], [ 'errorComponent', { routeIdent: 'errorComponent', localImporterIdent: '$$splitErrorComponentImporter', // const $$splitErrorComponentImporter = () => import('...') splitStrategy: 'lazyRouteComponent', localExporterIdent: 'SplitErrorComponent', // const SplitErrorComponent = ... exporterIdent: 'errorComponent', // export { SplitErrorComponent as errorComponent } }, ], [ 'notFoundComponent', { routeIdent: 'notFoundComponent', localImporterIdent: '$$splitNotFoundComponentImporter', // const $$splitNotFoundComponentImporter = () => import('...') splitStrategy: 'lazyRouteComponent', localExporterIdent: 'SplitNotFoundComponent', // const SplitNotFoundComponent = ... exporterIdent: 'notFoundComponent', // export { SplitNotFoundComponent as notFoundComponent } }, ], ]) const KNOWN_SPLIT_ROUTE_IDENTS = [...SPLIT_NODES_CONFIG.keys()] as const function addSplitSearchParamToFilename( filename: string, grouping: Array<string>, ) { const [bareFilename] = filename.split('?') const params = new URLSearchParams() params.append(tsrSplit, createIdentifier(grouping)) const result = `${bareFilename}?${params.toString()}` return result } function removeSplitSearchParamFromFilename(filename: string) { const [bareFilename] = filename.split('?') return bareFilename! } export function addSharedSearchParamToFilename(filename: string) { const [bareFilename] = filename.split('?') return `${bareFilename}?${tsrShared}=1` } const splittableCreateRouteFns = ['createFileRoute'] const unsplittableCreateRouteFns = [ 'createRootRoute', 'createRootRouteWithContext', ] const allCreateRouteFns = [ ...splittableCreateRouteFns, ...unsplittableCreateRouteFns, ] /** * Recursively walk an AST node and collect referenced identifier-like names. * Much cheaper than babel.traverse — no path/scope overhead. * * Notes: * - Uses @babel/types `isReferenced` to avoid collecting non-references like * object keys, member expression properties, or binding identifiers. * - Also handles JSX identifiers for component references. */ export function collectIdentifiersFromNode(node: t.Node): Set<string> { const ids = new Set<string>() ;(function walk( n: t.Node | null | undefined, parent?: t.Node, grandparent?: t.Node, parentKey?: string, ) { if (!n) return if (t.isIdentifier(n)) { // When we don't have parent info (node passed in isolation), treat as referenced. if (!parent || t.isReferenced(n, parent, grandparent)) { ids.add(n.name) } return } if (t.isJSXIdentifier(n)) { // Skip attribute names: <div data-testid="x" /> if (parent && t.isJSXAttribute(parent) && parentKey === 'name') { return } // Skip member properties: <Foo.Bar /> should count Foo, not Bar if ( parent && t.isJSXMemberExpression(parent) && parentKey === 'property' ) { return } // Intrinsic elements (lowercase) are not identifiers const first = n.name[0] if (first && first === first.toLowerCase()) { return } ids.add(n.name) return } for (const key of t.VISITOR_KEYS[n.type] || []) { const child = (n as any)[key] if (Array.isArray(child)) { for (const c of child) { if (c && typeof c.type === 'string') { walk(c, n, parent, key) } } } else if (child && typeof child.type === 'string') { walk(child, n, parent, key) } } })(node) return ids } /** * Build a map from binding name → declaration AST node for all * locally-declared module-level bindings. Built once, O(1) lookup. */ export function buildDeclarationMap(ast: t.File): Map<string, t.Node> { const map = new Map<string, t.Node>() for (const stmt of ast.program.body) { const decl = t.isExportNamedDeclaration(stmt) && stmt.declaration ? stmt.declaration : stmt if (t.isVariableDeclaration(decl)) { for (const declarator of decl.declarations) { for (const name of collectIdentifiersFromPattern(declarator.id)) { map.set(name, declarator) } } } else if (t.isFunctionDeclaration(decl) && decl.id) { map.set(decl.id.name, decl) } else if (t.isClassDeclaration(decl) && decl.id) { map.set(decl.id.name, decl) } } return map } /** * Build a dependency graph: for each local binding, the set of other local * bindings its declaration references. Built once via simple node walking. */ export function buildDependencyGraph( declMap: Map<string, t.Node>, localBindings: Set<string>, ): Map<string, Set<string>> { const graph = new Map<string, Set<string>>() for (const [name, declNode] of declMap) { if (!localBindings.has(name)) continue const allIds = collectIdentifiersFromNode(declNode) const deps = new Set<string>() for (const id of allIds) { if (id !== name && localBindings.has(id)) deps.add(id) } graph.set(name, deps) } return graph } /** * Computes module-level bindings that are shared between split and non-split * route properties. These bindings need to be extracted into a shared virtual * module to avoid double-initialization. * * A binding is "shared" if it is referenced by at least one split property * AND at least one non-split property. Only locally-declared module-level * bindings are candidates (not imports — bundlers dedupe those). */ export function computeSharedBindings(opts: { code: string codeSplitGroupings: CodeSplitGroupings }): Set<string> { const ast = parseAst(opts) // Early bailout: collect all module-level locally-declared binding names. // This is a cheap loop over program.body (no traversal). If the file has // no local bindings (aside from `Route`), nothing can be shared — skip // the expensive babel.traverse entirely. const localModuleLevelBindings = new Set<string>() for (const node of ast.program.body) { collectLocalBindingsFromStatement(node, localModuleLevelBindings) } // File-based routes always export a route config binding (usually `Route`). // This must never be extracted into the shared module. localModuleLevelBindings.delete('Route') if (localModuleLevelBindings.size === 0) { return new Set() } function findIndexForSplitNode(str: string) { return opts.codeSplitGroupings.findIndex((group) => group.includes(str as any), ) } // Find the route options object — needs babel.traverse for scope resolution let routeOptions: t.ObjectExpression | undefined babel.traverse(ast, { CallExpression(path) { if (!t.isIdentifier(path.node.callee)) return if (!splittableCreateRouteFns.includes(path.node.callee.name)) return if (t.isCallExpression(path.parentPath.node)) { const opts = resolveIdentifier(path, path.parentPath.node.arguments[0]) if (t.isObjectExpression(opts)) routeOptions = opts } else if (t.isVariableDeclarator(path.parentPath.node)) { const caller = resolveIdentifier(path, path.parentPath.node.init) if (t.isCallExpression(caller)) { const opts = resolveIdentifier(path, caller.arguments[0]) if (t.isObjectExpression(opts)) routeOptions = opts } } }, }) if (!routeOptions) return new Set() // Fast path: if fewer than 2 distinct groups are referenced by route options, // nothing can be shared and we can skip the rest of the work. const splitGroupsPresent = new Set<number>() let hasNonSplit = false for (const prop of routeOptions.properties) { if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) continue if (prop.key.name === 'codeSplitGroupings') continue if (t.isIdentifier(prop.value) && prop.value.name === 'undefined') continue const groupIndex = findIndexForSplitNode(prop.key.name) // -1 if non-split if (groupIndex === -1) { hasNonSplit = true } else { splitGroupsPresent.add(groupIndex) } } if (!hasNonSplit && splitGroupsPresent.size < 2) return new Set() // Build dependency graph up front — needed for transitive expansion per-property. // This graph excludes `Route` (deleted above) so group attribution works correctly. const declMap = buildDeclarationMap(ast) const depGraph = buildDependencyGraph(declMap, localModuleLevelBindings) // Build a second dependency graph that includes `Route` so we can detect // bindings that transitively depend on it. Such bindings must NOT be // extracted into the shared module because they would drag the Route // singleton with them, duplicating it across modules. const allLocalBindings = new Set(localModuleLevelBindings) allLocalBindings.add('Route') const fullDepGraph = buildDependencyGraph(declMap, allLocalBindings) // For each route property, track which "group" it belongs to. // Non-split properties get group index -1. // Split properties get their codeSplitGroupings index (0, 1, ...). // A binding is "shared" if it appears in 2+ distinct groups. // We expand each property's refs transitively BEFORE comparing groups, // so indirect refs (e.g., component: MyComp where MyComp uses `shared`) // are correctly attributed. const refsByGroup = new Map<string, Set<number>>() for (const prop of routeOptions.properties) { if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) continue const key = prop.key.name if (key === 'codeSplitGroupings') continue const groupIndex = findIndexForSplitNode(key) // -1 if non-split const directRefs = collectModuleLevelRefsFromNode( prop.value, localModuleLevelBindings, ) // Expand transitively: if component references SharedComp which references // `shared`, then `shared` is also attributed to component's group. const allRefs = new Set(directRefs) expandTransitively(allRefs, depGraph) for (const ref of allRefs) { let groups = refsByGroup.get(ref) if (!groups) { groups = new Set() refsByGroup.set(ref, groups) } groups.add(groupIndex) } } // Shared = bindings appearing in 2+ distinct groups const shared = new Set<string>() for (const [name, groups] of refsByGroup) { if (groups.size >= 2) shared.add(name) } // Destructured declarators (e.g. `const { a, b } = fn()`) must be treated // as a single initialization unit. Even if each binding is referenced by // only one group, if *different* bindings from the same declarator are // referenced by different groups, the declarator must be extracted to the // shared module to avoid double initialization. expandSharedDestructuredDeclarators(ast, refsByGroup, shared) if (shared.size === 0) return shared // If any binding from a destructured declaration is shared, // all bindings from that declaration must be shared expandDestructuredDeclarations(ast, shared) // Remove shared bindings that transitively depend on `Route`. // The Route singleton must stay in the reference file; extracting a // binding that references it would duplicate Route in the shared module. removeBindingsDependingOnRoute(shared, fullDepGraph) return shared } /** * If bindings from the same destructured declarator are referenced by * different groups, mark all bindings from that declarator as shared. */ export function expandSharedDestructuredDeclarators( ast: t.File, refsByGroup: Map<string, Set<number>>, shared: Set<string>, ) { for (const stmt of ast.program.body) { const decl = t.isExportNamedDeclaration(stmt) && stmt.declaration ? stmt.declaration : stmt if (!t.isVariableDeclaration(decl)) continue for (const declarator of decl.declarations) { if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id)) continue const names = collectIdentifiersFromPattern(declarator.id) const usedGroups = new Set<number>() for (const name of names) { const groups = refsByGroup.get(name) if (!groups) continue for (const g of groups) usedGroups.add(g) } if (usedGroups.size >= 2) { for (const name of names) { shared.add(name) } } } } } /** * Collect locally-declared module-level binding names from a statement. * Pure node inspection, no traversal. */ export function collectLocalBindingsFromStatement( node: t.Statement | t.ModuleDeclaration, bindings: Set<string>, ) { const decl = t.isExportNamedDeclaration(node) && node.declaration ? node.declaration : node if (t.isVariableDeclaration(decl)) { for (const declarator of decl.declarations) { for (const name of collectIdentifiersFromPattern(declarator.id)) { bindings.add(name) } } } else if (t.isFunctionDeclaration(decl) && decl.id) { bindings.add(decl.id.name) } else if (t.isClassDeclaration(decl) && decl.id) { bindings.add(decl.id.name) } } /** * Collect direct module-level binding names referenced from a given AST node. * Uses a simple recursive walk instead of babel.traverse. */ export function collectModuleLevelRefsFromNode( node: t.Node, localModuleLevelBindings: Set<string>, ): Set<string> { const allIds = collectIdentifiersFromNode(node) const refs = new Set<string>() for (const name of allIds) { if (localModuleLevelBindings.has(name)) refs.add(name) } return refs } /** * Expand the shared set transitively using a prebuilt dependency graph. * No AST traversals — pure graph BFS. */ export function expandTransitively( shared: Set<string>, depGraph: Map<string, Set<string>>, ) { const queue = [...shared] const visited = new Set<string>() while (queue.length > 0) { const name = queue.pop()! if (visited.has(name)) continue visited.add(name) const deps = depGraph.get(name) if (!deps) continue for (const dep of deps) { if (!shared.has(dep)) { shared.add(dep) queue.push(dep) } } } } /** * Remove any bindings from `shared` that transitively depend on `Route`. * The Route singleton must remain in the reference file; if a shared binding * references it (directly or transitively), extracting that binding would * duplicate Route in the shared module. * * Uses `depGraph` which must include `Route` as a node so the dependency * chain is visible. */ export function removeBindingsDependingOnRoute( shared: Set<string>, depGraph: Map<string, Set<string>>, ) { const reverseGraph = new Map<string, Set<string>>() for (const [name, deps] of depGraph) { for (const dep of deps) { let parents = reverseGraph.get(dep) if (!parents) { parents = new Set<string>() reverseGraph.set(dep, parents) } parents.add(name) } } // Walk backwards from Route to find all bindings that can reach it. const visited = new Set<string>() const queue = ['Route'] while (queue.length > 0) { const cur = queue.pop()! if (visited.has(cur)) continue visited.add(cur) const parents = reverseGraph.get(cur) if (!parents) continue for (const parent of parents) { if (!visited.has(parent)) queue.push(parent) } } for (const name of [...shared]) { if (visited.has(name)) { shared.delete(name) } } } /** * If any binding from a destructured declaration is shared, * ensure all bindings from that same declaration are also shared. * Pure node inspection of program.body, no traversal. */ export function expandDestructuredDeclarations( ast: t.File, shared: Set<string>, ) { for (const stmt of ast.program.body) { const decl = t.isExportNamedDeclaration(stmt) && stmt.declaration ? stmt.declaration : stmt if (!t.isVariableDeclaration(decl)) continue for (const declarator of decl.declarations) { if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id)) continue const names = collectIdentifiersFromPattern(declarator.id) const hasShared = names.some((n) => shared.has(n)) if (hasShared) { for (const n of names) { shared.add(n) } } } } } /** * Find which shared bindings are user-exported in the original source. * These need to be re-exported from the shared module. */ function findExportedSharedBindings( ast: t.File, sharedBindings: Set<string>, ): Set<string> { const exported = new Set<string>() for (const stmt of ast.program.body) { if (!t.isExportNamedDeclaration(stmt) || !stmt.declaration) continue if (t.isVariableDeclaration(stmt.declaration)) { for (const decl of stmt.declaration.declarations) { for (const name of collectIdentifiersFromPattern(decl.id)) { if (sharedBindings.has(name)) exported.add(name) } } } else if ( t.isFunctionDeclaration(stmt.declaration) && stmt.declaration.id ) { if (sharedBindings.has(stmt.declaration.id.name)) exported.add(stmt.declaration.id.name) } else if (t.isClassDeclaration(stmt.declaration) && stmt.declaration.id) { if (sharedBindings.has(stmt.declaration.id.name)) exported.add(stmt.declaration.id.name) } } return exported } /** * Remove declarations of shared bindings from the AST. * Handles both plain and exported declarations, including destructured patterns. * Removes the entire statement if all bindings in it are shared. */ function removeSharedDeclarations(ast: t.File, sharedBindings: Set<string>) { ast.program.body = ast.program.body.filter((stmt) => { const decl = t.isExportNamedDeclaration(stmt) && stmt.declaration ? stmt.declaration : stmt if (t.isVariableDeclaration(decl)) { // Filter out declarators where all bound names are shared decl.declarations = decl.declarations.filter((declarator) => { const names = collectIdentifiersFromPattern(declarator.id) return !names.every((n) => sharedBindings.has(n)) }) // If no declarators remain, remove the entire statement if (decl.declarations.length === 0) return false } else if (t.isFunctionDeclaration(decl) && decl.id) { if (sharedBindings.has(decl.id.name)) return false } else if (t.isClassDeclaration(decl) && decl.id) { if (sharedBindings.has(decl.id.name)) return false } return true }) } export function compileCodeSplitReferenceRoute( opts: ParseAstOptions & { codeSplitGroupings: CodeSplitGroupings deleteNodes?: Set<DeletableNodes> targetFramework: Config['target'] filename: string id: string addHmr?: boolean sharedBindings?: Set<string> compilerPlugins?: Array<ReferenceRouteCompilerPlugin> }, ): GeneratorResult | null { const ast = parseAst(opts) const refIdents = findReferencedIdentifiers(ast) const knownExportedIdents = new Set<string>() function findIndexForSplitNode(str: string) { return opts.codeSplitGroupings.findIndex((group) => group.includes(str as any), ) } const frameworkOptions = getFrameworkOptions(opts.targetFramework) const PACKAGE = frameworkOptions.package const LAZY_ROUTE_COMPONENT_IDENT = frameworkOptions.idents.lazyRouteComponent const LAZY_FN_IDENT = frameworkOptions.idents.lazyFn let createRouteFn: string let modified = false as boolean let hmrAdded = false as boolean let sharedExportedNames: Set<string> | undefined babel.traverse(ast, { Program: { enter(programPath) { /** * If the component for the route is being imported from * another file, this is to track the path to that file * the path itself doesn't matter, we just need to keep * track of it so that we can remove it from the imports * list if it's not being used like: * * `import '../shared/imported'` */ const removableImportPaths = new Set<string>([]) programPath.traverse({ CallExpression: (path) => { if (!t.isIdentifier(path.node.callee)) { return } if (!allCreateRouteFns.includes(path.node.callee.name)) { return } createRouteFn = path.node.callee.name function babelHandleReference(routeOptions: t.Node | undefined) { const hasImportedOrDefinedIdentifier = (name: string) => { return programPath.scope.hasBinding(name) } if (t.isObjectExpression(routeOptions)) { if (opts.deleteNodes && opts.deleteNodes.size > 0) { routeOptions.properties = routeOptions.properties.filter( (prop) => { if (t.isObjectProperty(prop)) { if (t.isIdentifier(prop.key)) { if (opts.deleteNodes!.has(prop.key.name as any)) { modified = true return false } } } return true }, ) } if (!splittableCreateRouteFns.includes(createRouteFn)) { const insertionPath = path.getStatementParent() ?? path opts.compilerPlugins?.forEach((plugin) => { const pluginResult = plugin.onUnsplittableRoute?.({ programPath, callExpressionPath: path, insertionPath, routeOptions, createRouteFn, opts: opts as CompileCodeSplitReferenceRouteOptions, }) if (pluginResult?.modified) { modified = true } }) // we can't split this route but we still add HMR handling if enabled if (opts.addHmr && !hmrAdded) { programPath.pushContainer('body', routeHmrStatement) modified = true hmrAdded = true } // exit traversal so this route is not split return programPath.stop() } routeOptions.properties.forEach((prop) => { if (t.isObjectProperty(prop)) { if (t.isIdentifier(prop.key)) { const key = prop.key.name // If the user has not specified a split grouping for this key // then we should not split it const codeSplitGroupingByKey = findIndexForSplitNode(key) if (codeSplitGroupingByKey === -1) { return } const codeSplitGroup = [ ...new Set( opts.codeSplitGroupings[codeSplitGroupingByKey], ), ] // find key in nodeSplitConfig const isNodeConfigAvailable = SPLIT_NODES_CONFIG.has( key as any, ) if (!isNodeConfigAvailable) { return } // Exit early if the value is a boolean, null, or undefined. // These values mean "don't use this component, fallback to parent" // No code splitting needed to preserve fallback behavior if ( t.isBooleanLiteral(prop.value) || t.isNullLiteral(prop.value) || (t.isIdentifier(prop.value) && prop.value.name === 'undefined') ) { return } const splitNodeMeta = SPLIT_NODES_CONFIG.get(key as any)! // We need to extract the existing search params from the filename, if any // and add the relevant codesplitPrefix to them, then write them back to the filename const splitUrl = addSplitSearchParamToFilename( opts.filename, codeSplitGroup, ) if ( splitNodeMeta.splitStrategy === 'lazyRouteComponent' ) { const value = prop.value let shouldSplit = true if (t.isIdentifier(value)) { const existingImportPath = getImportSpecifierAndPathFromLocalName( programPath, value.name, ).path if (existingImportPath) { removableImportPaths.add(existingImportPath) } // exported identifiers should not be split // since they are already being imported // and need to be retained in the compiled file const isExported = hasExport(ast, value) if (isExported) { knownExportedIdents.add(value.name) } shouldSplit = !isExported if (shouldSplit) { removeIdentifierLiteral(path, value) } } if (!shouldSplit) { return } modified = true // Prepend the import statement to the program along with the importer function // Check to see if lazyRouteComponent is already imported before attempting // to import it again if ( !hasImportedOrDefinedIdentifier( LAZY_ROUTE_COMPONENT_IDENT, ) ) { programPath.unshiftContainer('body', [ template.statement( `import { ${LAZY_ROUTE_COMPONENT_IDENT} } from '${PACKAGE}'`, )(), ]) } // Check to see if the importer function is already defined // If not, define it with the dynamic import statement if ( !hasImportedOrDefinedIdentifier( splitNodeMeta.localImporterIdent, ) ) { programPath.unshiftContainer('body', [ template.statement( `const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`, )(), ]) } prop.value = template.expression( `${LAZY_ROUTE_COMPONENT_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`, )() // add HMR handling if (opts.addHmr && !hmrAdded) { programPath.pushContainer('body', routeHmrStatement) modified = true hmrAdded = true } } else { // if (splitNodeMeta.splitStrategy === 'lazyFn') { const value = prop.value let shouldSplit = true if (t.isIdentifier(value)) { const existingImportPath = getImportSpecifierAndPathFromLocalName( programPath, value.name, ).path if (existingImportPath) { removableImportPaths.add(existingImportPath) } // exported identifiers should not be split // since they are already being imported // and need to be retained in the compiled file const isExported = hasExport(ast, value) if (isExported) { knownExportedIdents.add(value.name) } shouldSplit = !isExported if (shouldSplit) { removeIdentifierLiteral(path, value) } } if (!shouldSplit) { return } modified = true // Prepend the import statement to the program along with the importer function if (!hasImportedOrDefinedIdentifier(LAZY_FN_IDENT)) { programPath.unshiftContainer( 'body', template.smart( `import { ${LAZY_FN_IDENT} } from '${PACKAGE}'`, )(), ) } // Check to see if the importer function is already defined // If not, define it with the dynamic import statement if ( !hasImportedOrDefinedIdentifier( splitNodeMeta.localImporterIdent, ) ) { programPath.unshiftContainer('body', [ template.statement( `const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`, )(), ]) } // Add the lazyFn call with the dynamic import to the prop value prop.value = template.expression( `${LAZY_FN_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`, )() } } } programPath.scope.crawl() }) } } if (t.isCallExpression(path.parentPath.node)) { // createFileRoute('/')({ ... }) const options = resolveIdentifier( path, path.parentPath.node.arguments[0], ) babelHandleReference(options) } else if (t.isVariableDeclarator(path.parentPath.node)) { // createFileRoute({ ... }) const caller = resolveIdentifier(path, path.parentPath.node.init) if (t.isCallExpression(caller)) { const options = resolveIdentifier(path, caller.arguments[0]) babelHandleReference(options) } } }, }) /** * If the component for the route is being imported, * and it's not being used, remove the import statement * from the program, by checking that the import has no * specifiers */ if (removableImportPaths.size > 0) { modified = true programPath.traverse({ ImportDeclaration(path) { if (path.node.specifiers.length > 0) return if (removableImportPaths.has(path.node.source.value)) { path.remove() } }, }) } // Handle shared bindings inside the Program visitor so we have // access to programPath for cheap refIdents registration. if (opts.sharedBindings && opts.sharedBindings.size > 0) { sharedExportedNames = findExportedSharedBindings( ast, opts.sharedBindings, ) removeSharedDeclarations(ast, opts.sharedBindings) const sharedModuleUrl = addSharedSearchParamToFilename(opts.filename) const sharedImportSpecifiers = [...opts.sharedBindings].map((name) => t.importSpecifier(t.identifier(name), t.identifier(name)), ) const [sharedImportPath] = programPath.unshiftContainer( 'body', t.importDeclaration( sharedImportSpecifiers, t.stringLiteral(sharedModuleUrl), ), ) // Register import specifier locals in refIdents so DCE can remove unused ones sharedImportPath.traverse({ Identifier(identPath) { if ( identPath.parentPath.isImportSpecifier() && identPath.key === 'local' ) { refIdents.add(identPath) } }, }) // Re-export user-exported shared bindings from the shared module if (sharedExportedNames.size > 0) { const reExportSpecifiers = [...sharedExportedNames].map((name) => t.exportSpecifier(t.identifier(name), t.identifier(name)), ) programPath.pushContainer( 'body', t.exportNamedDeclaration( null, reExportSpecifiers, t.stringLiteral(sharedModuleUrl), ), ) } } }, }, }) if (!modified) { return null } deadCodeElimination(ast, refIdents) // if there are exported identifiers, then we need to add a warning // to the file to let the user know that the exported identifiers // will not in the split file but in the original file, therefore // increasing the bundle size if (knownExportedIdents.size > 0) { const warningMessage = createNotExportableMessage( opts.filename, knownExportedIdents, ) console.warn(warningMessage) // append this warning to the file using a template if (process.env.NODE_ENV !== 'production') { const warningTemplate = template.statement( `console.warn(${JSON.stringify(warningMessage)})`, )() ast.program.body.unshift(warningTemplate) } } const result = generateFromAst(ast, { sourceMaps: true, sourceFileName: opts.filename, filename: opts.filename, }) // @babel/generator does not populate sourcesContent because it only has // the AST, not the original text. Without this, Vite's composed // sourcemap omits the original source, causing downstream consumers // (e.g. import-protection snippet display) to fall back to the shorter // compiled output and fail to resolve original line numbers. if (result.map) { result.map.sourcesContent = [opts.code] } return result } export function compileCodeSplitVirtualRoute( opts: ParseAstOptions & { splitTargets: Array<SplitRouteIdentNodes> filename: string sharedBindings?: Set<string> }, ): GeneratorResult { const ast = parseAst(opts) const refIdents = findReferencedIdentifiers(ast) // Remove shared declarations BEFORE babel.traverse so the scope never sees // conflicting bindings (avoids checkBlockScopedCollisions crash in DCE) if (opts.sharedBindings && opts.sharedBindings.size > 0) { removeSharedDeclarations(ast, opts.sharedBindings) } const intendedSplitNodes = new Set(opts.splitTargets) const knownExportedIdents = new Set<string>() babel.traverse(ast, { Program: { enter(programPath) { const trackedNodesToSplitByType: Record< SplitRouteIdentNodes, { node: t.Node | undefined; meta: SplitNodeMeta } | undefined > = { component: undefined, loader: undefined, pendingComponent: undefined, errorComponent: undefined, notFoundComponent: undefined, } // Find and track all the known split-able nodes programPath.traverse({ CallExpression: (path) => { if (!t.isIdentifier(path.node.callee)) { return } if (!splittableCreateRouteFns.includes(path.node.callee.name)) { return } function babelHandleVirtual(options: t.Node | undefined) { if (t.isObjectExpression(options)) { options.properties.forEach((prop) => { if (t.isObjectProperty(prop)) { // do not use `intendedSplitNodes` here // since we have special considerations that need // to be accounted for like (not splitting exported identifiers) KNOWN_SPLIT_ROUTE_IDENTS.forEach((splitType) => { if ( !t.isIdentifier(prop.key) || prop.key.name !== splitType ) { return } const value = prop.value // If the value for the `key` is `undefined`, then we don't need to include it // in the split file, so we can just return, since it will kept in-place in the // reference file // This is useful for cases like: `createFileRoute('/')({ component: undefined })` if (t.isIdentifier(value) && value.name === 'undefined') { return } let isExported = false if (t.isIdentifier(value)) { isExported = hasExport(ast, value) if (isExported) { knownExportedIdents.add(value.name) } } // If the node is exported, we need to remove // the export from the split file if (isExported && t.isIdentifier(value)) { removeExports(ast, value) } else { const meta = SPLIT_NODES_CONFIG.get(splitType)! trackedNodesToSplitByType[splitType] = { node: prop.value, meta, } } }) } }) // Remove all of the options options.properties = [] } } if (t.isCallExpression(path.parentPath.node)) { // createFileRoute('/')({ ... }) const options = resolveIdentifier( path, path.parentPath.node.arguments[0], ) babelHandleVirtual(options) } else if (t.isVariableDeclarator(path.parentPath.node)) { // createFileRoute({ ... }) const caller = resolveIdentifier(path, path.parentPath.node.init) if (t.isCallExpression(caller)) { const options = resolveIdentifier(path, caller.arguments[0]) babelHandleVirtual(options) } } }, }) // Start the transformation to only exported the intended split nodes intendedSplitNodes.forEach((SPLIT_TYPE) => { const splitKey = trackedNodesToSplitByType[SPLIT_TYPE] if (!splitKey) { return } let splitNode = splitKey.node const splitMeta = { ...splitKey.meta, shouldRemoveNode: true } // Track the original identifier name before resolving through bindings, // needed for destructured patterns where the binding resolves to the // entire VariableDeclarator (ObjectPattern) rather than the specific binding let originalIdentName: string | undefined if (t.isIdentifier(splitNode)) { originalIdentName = splitNode.name } while (t.isIdentifier(splitNode)) { const binding = programPath.scope.getBinding(splitNode.name) splitNode = binding?.path.node } // Add the node to the program if (splitNode) { if (t.isFunctionDeclaration(splitNode)) { // an anonymous function declaration should only happen for `export default function() {...}` // so we should never get here if (!splitNode.id) { throw new Error( `Function declaration for "${SPLIT_TYPE}" must have an identifier.`, ) } splitMeta.shouldRemoveNode = false splitMeta.localExporterIdent = splitNode.id.name } else if ( t.isFunctionExpression(splitNode) || t.isArrowFunctionExpression(splitNode) ) { programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), splitNode as any, ), ]), ) } else if ( t.isImportSpecifier(splitNode) || t.isImportDefaultSpecifier(splitNode) ) { programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), splitNode.local, ), ]), ) } else if (t.isVariableDeclarator(splitNode)) { if (t.isIdentifier(splitNode.id)) { splitMeta.localExporterIdent = splitNode.id.name splitMeta.shouldRemoveNode = false } else if (t.isObjectPattern(splitNode.id)) { // Destructured binding like `const { component: MyComp } = createBits()` // Use the original identifier name that was tracked before resolving if (originalIdentName) { splitMeta.localExporterIdent = originalIdentName } splitMeta.shouldRemoveNode = false } else { throw new Error( `Unexpected splitNode type ☝️: ${splitNode.type}`, ) } } else if (t.isCallExpression(splitNode)) { const outputSplitNodeCode = generateFromAst(splitNode).code const splitNodeAst = babel.parse(outputSplitNodeCode) if (!splitNodeAst) { throw new Error( `Failed to parse the generated code for "${SPLIT_TYPE}" in the node type "${splitNode.type}"`, ) } const statement = splitNodeAst.program.body[0] if (!statement) { throw new Error( `Failed to parse the generated code for "${SPLIT_TYPE}" in the node type "${splitNode.type}" as no statement was found in the program body`, ) } if (t.isExpressionStatement(statement)) { const expression = statement.expression programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), expression, ), ]), ) } else { throw new Error( `Unexpected expression type encounter for "${SPLIT_TYPE}" in the node type "${splitNode.type}"`, ) } } else if (t.isConditionalExpression(splitNode)) { programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), splitNode, ), ]), ) } else if (t.isTSAsExpression(splitNode)) { // remove the type assertion splitNode = splitNode.expression programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), splitNode, ), ]), ) } else if (t.isBooleanLiteral(splitNode)) { // Handle boolean literals // This exits early here, since this value will be kept in the reference file return } else if (t.isNullLiteral(splitNode)) { // Handle null literals // This exits early here, since this value will be kept in the reference file return } else { console.info('Unexpected splitNode type:', splitNode) throw new Error(`Unexpected splitNode type ☝️: ${splitNode.type}`) } } if (splitMeta.shouldRemoveNode) { // If the splitNode exists at the top of the program // then we need to remove that copy programPath.node.body = programPath.node.body.filter((node) => { return node !== splitNode }) } // Export the node programPath.pushContainer('body', [ t.exportNamedDeclaration(null, [ t.exportSpecifier( t.identifier(splitMeta.localExporterIdent), // local variable name t.identifier(splitMeta.exporterIdent), // as what name it should be exported as ), ]), ]) }) // convert exports to imports from the original file programPath.traverse({ ExportNamedDeclaration(path) { // e.g. export const x = 1 or export { x } // becomes // import { x } from '${opts.id}' if (path.node.declaration) { if (t.isVariableDeclaration(path.node.declaration)) { const specifiers = path.node.declaration.declarations.flatMap( (decl) => { if (t.isIdentifier(decl.id)) { return [ t.importSpecifier( t.identifier(decl.id.name), t.identifier(decl.id.name), ), ] } if (t.isObjectPattern(decl.id)) { return collectIdentifiersFromPattern(decl.id).map( (name) => t.importSpecifier( t.identifier(name), t.identifier(name), ), ) } if (t.isArrayPattern(decl.id)