UNPKG

@tanstack/router-plugin

Version:

Modern and scalable routing for React applications

968 lines (864 loc) 34.4 kB
import * as t from '@babel/types' import babel from '@babel/core' import * as template from '@babel/template' import { deadCodeElimination, findReferencedIdentifiers, } from 'babel-dead-code-elimination' import { generateFromAst, parseAst } from '@tanstack/router-utils' import { tsrSplit } from '../constants' import { routeHmrStatement } from '../route-hmr-statement' import { createIdentifier } from './path-ids' import { getFrameworkOptions } from './framework-options' import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' import type { Config } 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)) return `${bareFilename}?${params.toString()}` } function removeSplitSearchParamFromFilename(filename: string) { const [bareFilename] = filename.split('?') return bareFilename! } export function compileCodeSplitReferenceRoute( opts: ParseAstOptions & { runtimeEnv: 'dev' | 'prod' codeSplitGroupings: CodeSplitGroupings targetFramework: Config['target'] filename: string id: string }, ): GeneratorResult { const ast = parseAst(opts) const refIdents = findReferencedIdentifiers(ast) 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 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 ( !( path.node.callee.name === 'createRoute' || path.node.callee.name === 'createFileRoute' ) ) { return } function babelHandleReference(routeOptions: t.Node | undefined) { const hasImportedOrDefinedIdentifier = (name: string) => { return programPath.scope.hasBinding(name) } if (t.isObjectExpression(routeOptions)) { routeOptions.properties.forEach((prop) => { if (t.isObjectProperty(prop)) { if (t.isIdentifier(prop.key)) { // If the user has not specified a split grouping for this key // then we should not split it const codeSplitGroupingByKey = findIndexForSplitNode( prop.key.name, ) if (codeSplitGroupingByKey === -1) { return } const codeSplitGroup = [ ...new Set( opts.codeSplitGroupings[codeSplitGroupingByKey], ), ] const key = prop.key.name // find key in nodeSplitConfig const isNodeConfigAvailable = SPLIT_NODES_CONFIG.has( key as any, ) if (!isNodeConfigAvailable) { 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) shouldSplit = !isExported if (shouldSplit) { removeIdentifierLiteral(path, value) } } if (!shouldSplit) { return } // 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}')`, )(), ]) } // If it's a component, we need to pass the function to check the Route.ssr value if (key === 'component') { prop.value = template.expression( `${LAZY_ROUTE_COMPONENT_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}', () => Route.ssr)`, )() } else { prop.value = template.expression( `${LAZY_ROUTE_COMPONENT_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`, )() } // add HMR handling if (opts.runtimeEnv !== 'prod') { programPath.pushContainer('body', routeHmrStatement) } } 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) shouldSplit = !isExported if (shouldSplit) { removeIdentifierLiteral(path, value) } } if (!shouldSplit) { return } // 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) { programPath.traverse({ ImportDeclaration(path) { if (path.node.specifiers.length > 0) return if (removableImportPaths.has(path.node.source.value)) { path.remove() } }, }) } }, }, }) deadCodeElimination(ast, refIdents) return generateFromAst(ast, { sourceMaps: true, sourceFileName: opts.filename, filename: opts.filename, }) } export function compileCodeSplitVirtualRoute( opts: ParseAstOptions & { splitTargets: Array<SplitRouteIdentNodes> filename: string }, ): GeneratorResult { const ast = parseAst(opts) const refIdents = findReferencedIdentifiers(ast) 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 ( !( path.node.callee.name === 'createRoute' || path.node.callee.name === 'createFileRoute' ) ) { 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 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 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)) { programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), t.functionExpression( splitNode.id || null, // Anonymize the function expression splitNode.params, splitNode.body, splitNode.generator, splitNode.async, ), ), ]), ) } 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)) { programPath.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(splitMeta.localExporterIdent), splitNode.init, ), ]), ) } 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 { console.info('Unexpected splitNode type:', splitNode) throw new Error(`Unexpected splitNode type ☝️: ${splitNode.type}`) } } // 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)) { path.replaceWith( t.importDeclaration( path.node.declaration.declarations.map((decl) => t.importSpecifier( t.identifier((decl.id as any).name), t.identifier((decl.id as any).name), ), ), t.stringLiteral( removeSplitSearchParamFromFilename(opts.filename), ), ), ) } } }, }) }, }, }) 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 list = Array.from(knownExportedIdents).reduce((str, ident) => { str += `\n- ${ident}` return str }, '') const warningMessage = `These exports from "${opts.filename}" are not being code-split and will increase your bundle size: ${list}\nThese should either have their export statements removed or be imported from another file that is not a route.` 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) } } return generateFromAst(ast, { sourceMaps: true, sourceFileName: opts.filename, filename: opts.filename, }) } /** * This function should read get the options from by searching for the key `codeSplitGroupings` * on createFileRoute and return it's values if it exists, else return undefined */ export function detectCodeSplitGroupingsFromRoute(opts: ParseAstOptions): { groupings: CodeSplitGroupings | undefined } { const ast = parseAst(opts) let codeSplitGroupings: CodeSplitGroupings | undefined = undefined babel.traverse(ast, { Program: { enter(programPath) { programPath.traverse({ CallExpression(path) { if (!t.isIdentifier(path.node.callee)) { return } if ( !( path.node.callee.name === 'createRoute' || path.node.callee.name === 'createFileRoute' ) ) { return } function babelHandleSplittingGroups( routeOptions: t.Node | undefined, ) { if (t.isObjectExpression(routeOptions)) { routeOptions.properties.forEach((prop) => { if (t.isObjectProperty(prop)) { if (t.isIdentifier(prop.key)) { if (prop.key.name === 'codeSplitGroupings') { const value = prop.value if (t.isArrayExpression(value)) { codeSplitGroupings = value.elements.map((group) => { if (t.isArrayExpression(group)) { return group.elements.map((node) => { if (!t.isStringLiteral(node)) { throw new Error( 'You must provide a string literal for the codeSplitGroupings', ) } return node.value }) as Array<SplitRouteIdentNodes> } throw new Error( 'You must provide arrays with codeSplitGroupings options.', ) }) } else { throw new Error( 'You must provide an array of arrays for the codeSplitGroupings.', ) } } } } }) } } // Extracting the codeSplitGroupings if (t.isCallExpression(path.parentPath.node)) { // createFileRoute('/')({ ... }) const options = resolveIdentifier( path, path.parentPath.node.arguments[0], ) babelHandleSplittingGroups(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]) babelHandleSplittingGroups(options) } } }, }) }, }, }) return { groupings: codeSplitGroupings } } function getImportSpecifierAndPathFromLocalName( programPath: babel.NodePath<t.Program>, name: string, ): { specifier: | t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier | null path: string | null } { let specifier: | t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier | null = null let path: string | null = null programPath.traverse({ ImportDeclaration(importPath) { const found = importPath.node.specifiers.find( (targetSpecifier) => targetSpecifier.local.name === name, ) if (found) { specifier = found path = importPath.node.source.value } }, }) return { specifier, path } } // Reusable function to get literal value or resolve variable to literal function resolveIdentifier(path: any, node: any): t.Node | undefined { if (t.isIdentifier(node)) { const binding = path.scope.getBinding(node.name) if ( binding // && binding.kind === 'const' ) { const declarator = binding.path.node if (t.isObjectExpression(declarator.init)) { return declarator.init } else if (t.isFunctionDeclaration(declarator.init)) { return declarator.init } } return undefined } return node } function removeIdentifierLiteral(path: any, node: any) { if (t.isIdentifier(node)) { const binding = path.scope.getBinding(node.name) if (binding) { binding.path.remove() } } } function hasExport(ast: t.File, node: t.Identifier): boolean { let found = false babel.traverse(ast, { ExportNamedDeclaration(path) { if (path.node.declaration) { // declared as `const loaderFn = () => {}` if (t.isVariableDeclaration(path.node.declaration)) { path.node.declaration.declarations.forEach((decl) => { if (t.isVariableDeclarator(decl)) { if (t.isIdentifier(decl.id)) { if (decl.id.name === node.name) { found = true } } } }) } // declared as `function loaderFn() {}` if (t.isFunctionDeclaration(path.node.declaration)) { if (t.isIdentifier(path.node.declaration.id)) { if (path.node.declaration.id.name === node.name) { found = true } } } } }, ExportDefaultDeclaration(path) { // declared as `export default loaderFn` if (t.isIdentifier(path.node.declaration)) { if (path.node.declaration.name === node.name) { found = true } } // declared as `export default function loaderFn() {}` if (t.isFunctionDeclaration(path.node.declaration)) { if (t.isIdentifier(path.node.declaration.id)) { if (path.node.declaration.id.name === node.name) { found = true } } } }, }) return found } function removeExports(ast: t.File, node: t.Identifier): boolean { let removed = false // The checks use sequential if/else if statements since it // directly mutates the AST and as such doing normal checks // (using only if statements) could lead to a situation where // `path.node` is null since it has been already removed from // the program tree but typescript doesn't know that. babel.traverse(ast, { ExportNamedDeclaration(path) { if (path.node.declaration) { if (t.isVariableDeclaration(path.node.declaration)) { // declared as `const loaderFn = () => {}` path.node.declaration.declarations.forEach((decl) => { if (t.isVariableDeclarator(decl)) { if (t.isIdentifier(decl.id)) { if (decl.id.name === node.name) { path.remove() removed = true } } } }) } else if (t.isFunctionDeclaration(path.node.declaration)) { // declared as `export const loaderFn = () => {}` if (t.isIdentifier(path.node.declaration.id)) { if (path.node.declaration.id.name === node.name) { path.remove() removed = true } } } } }, ExportDefaultDeclaration(path) { // declared as `export default loaderFn` if (t.isIdentifier(path.node.declaration)) { if (path.node.declaration.name === node.name) { path.remove() removed = true } } else if (t.isFunctionDeclaration(path.node.declaration)) { // declared as `export default function loaderFn() {}` if (t.isIdentifier(path.node.declaration.id)) { if (path.node.declaration.id.name === node.name) { path.remove() removed = true } } } }, }) return removed }