diff --git a/.changeset/quick-dingos-decide.md b/.changeset/quick-dingos-decide.md new file mode 100644 index 00000000000..d31f3f45366 --- /dev/null +++ b/.changeset/quick-dingos-decide.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-plugin': patch +--- + +Improve route code-splitting build performance by parsing each reference route once for grouping detection, shared-binding analysis, and compilation. diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts index 89cdae41add..11874b2f309 100644 --- a/packages/router-plugin/src/core/code-splitter/compilers.ts +++ b/packages/router-plugin/src/core/code-splitter/compilers.ts @@ -28,7 +28,11 @@ import type { CodeSplitCompilerPlugin, CompileCodeSplitReferenceRouteOptions, } from './plugins' -import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' +import type { + GeneratorResult, + ParseAstOptions, + ParseAstResult, +} from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' import type { SplitNodeMeta } from './types' @@ -153,8 +157,14 @@ export function computeSharedBindings(opts: { filename?: string codeSplitGroupings: CodeSplitGroupings }): Set { - const ast = parseAst(opts) + return computeSharedBindingsFromAst(parseAst(opts), opts.codeSplitGroupings) +} +/** Internal analysis of the original, unmodified reference AST. */ +export function computeSharedBindingsFromAst( + ast: t.File, + codeSplitGroupings: CodeSplitGroupings, +): Set { // 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 @@ -173,9 +183,7 @@ export function computeSharedBindings(opts: { } function findIndexForSplitNode(str: string) { - return opts.codeSplitGroupings.findIndex((group) => - group.includes(str as any), - ) + return codeSplitGroupings.findIndex((group) => group.includes(str as any)) } // Find the route options object — needs babel.traverse for scope resolution @@ -361,14 +369,22 @@ function removeSharedDeclarations(ast: t.File, sharedBindings: Set) { }) } +type ReferenceCompilerOptions = ParseAstOptions & + CompileCodeSplitReferenceRouteOptions & { + compilerPlugins?: Array + } + export function compileCodeSplitReferenceRoute( - opts: ParseAstOptions & - CompileCodeSplitReferenceRouteOptions & { - compilerPlugins?: Array - }, + opts: ReferenceCompilerOptions, ): GeneratorResult | null { - const ast = parseAst(opts) + return compileCodeSplitReferenceRouteFromAst(parseAst(opts), opts) +} +/** Internal compiler: consumes the AST after grouping and shared-binding analysis. */ +export function compileCodeSplitReferenceRouteFromAst( + ast: ParseAstResult, + opts: ReferenceCompilerOptions, +): GeneratorResult | null { const refIdents = findReferencedIdentifiers(ast) const knownExportedIdents = new Set() @@ -1395,8 +1411,13 @@ export function compileCodeSplitSharedRoute( export function detectCodeSplitGroupingsFromRoute(opts: ParseAstOptions): { groupings: CodeSplitGroupings | undefined } { - const ast = parseAst(opts) + return detectCodeSplitGroupingsFromAst(parseAst(opts)) +} +/** Internal analysis of the original, unmodified reference AST. */ +export function detectCodeSplitGroupingsFromAst(ast: t.File): { + groupings: CodeSplitGroupings | undefined +} { let codeSplitGroupings: CodeSplitGroupings | undefined = undefined babel.traverse(ast, { diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts index f1f75d4f3c1..af5097b96de 100644 --- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts +++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts @@ -4,14 +4,14 @@ */ import { fileURLToPath, pathToFileURL } from 'node:url' -import { decodeIdentifier, logDiff } from '@tanstack/router-utils' +import { decodeIdentifier, logDiff, parseAst } from '@tanstack/router-utils' import { getConfig, splitGroupingsSchema } from './config' import { - compileCodeSplitReferenceRoute, + compileCodeSplitReferenceRouteFromAst, compileCodeSplitSharedRoute, compileCodeSplitVirtualRoute, - computeSharedBindings, - detectCodeSplitGroupingsFromRoute, + computeSharedBindingsFromAst, + detectCodeSplitGroupingsFromAst, } from './code-splitter/compilers' import { getFrameworkHmrCompilerPlugins } from './code-splitter/plugins/framework-plugins' import { @@ -134,10 +134,11 @@ export function createRouterCodeSplitterPlugin( ): UnpluginTransformResult => { if (debug) console.info('Compiling Route: ', id) - const fromCode = detectCodeSplitGroupingsFromRoute({ + const ast = parseAst({ code, filename: id, }) + const fromCode = detectCodeSplitGroupingsFromAst(ast) if (fromCode.groupings !== undefined) { const res = splitGroupingsSchema.safeParse(fromCode.groupings) @@ -168,19 +169,15 @@ export function createRouterCodeSplitterPlugin( const splitGroupings: CodeSplitGroupings = fromCode.groupings ?? pluginSplitBehavior ?? getGlobalCodeSplitGroupings() - // Compute shared bindings before compiling the reference route - const sharedBindings = computeSharedBindings({ - code, - filename: id, - codeSplitGroupings: splitGroupings, - }) + // Both analyses must finish before the reference compiler mutates this AST. + const sharedBindings = computeSharedBindingsFromAst(ast, splitGroupings) if (sharedBindings.size > 0) { sharedBindingsMap.set(id, sharedBindings) } else { sharedBindingsMap.delete(id) } - const compiledReferenceRoute = compileCodeSplitReferenceRoute({ + const compiledReferenceRoute = compileCodeSplitReferenceRouteFromAst(ast, { code, codeSplitGroupings: splitGroupings, targetFramework: userConfig.target, diff --git a/packages/router-plugin/tests/add-hmr.test.ts b/packages/router-plugin/tests/add-hmr.test.ts index 98b3e61a5f9..999264153ed 100644 --- a/packages/router-plugin/tests/add-hmr.test.ts +++ b/packages/router-plugin/tests/add-hmr.test.ts @@ -1,8 +1,14 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { describe, expect, it } from 'vitest' - -import { compileCodeSplitReferenceRoute } from '../src/core/code-splitter/compilers' +import { parseAst } from '@tanstack/router-utils' + +import { + compileCodeSplitReferenceRoute, + compileCodeSplitReferenceRouteFromAst, + computeSharedBindingsFromAst, + detectCodeSplitGroupingsFromAst, +} from '../src/core/code-splitter/compilers' import { defaultCodeSplitGroupings } from '../src/core/constants' import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' import { createRouteHmrStatement } from '../src/core/hmr' @@ -14,6 +20,27 @@ function getFrameworkDir(framework: string) { return { files, snapshots } } +function compileWithRootParity( + opts: Parameters[0], +) { + const result = compileCodeSplitReferenceRoute(opts) + if (opts.filename.startsWith('createRootRoute')) { + // Reuse root snapshots for both paths, including WithContext/type arguments + // and the addHmr:false null-result path. Roots must never be extracted. + const ast = parseAst(opts) + expect(detectCodeSplitGroupingsFromAst(ast).groupings).toBeUndefined() + expect( + computeSharedBindingsFromAst(ast, opts.codeSplitGroupings).size, + ).toBe(0) + const parsedResult = compileCodeSplitReferenceRouteFromAst(ast, opts) + expect(parsedResult === null).toBe(result === null) + expect(parsedResult?.code).toBe(result?.code) + expect(parsedResult?.map).toEqual(result?.map) + return parsedResult + } + return result +} + describe('add-hmr works', () => { describe.each(frameworks)('FRAMEWORK=%s', async (framework) => { const dirs = getFrameworkDir(framework) @@ -25,7 +52,7 @@ describe('add-hmr works', () => { const file = await readFile(path.join(dirs.files, filename)) const code = file.toString() - const compileResult = compileCodeSplitReferenceRoute({ + const compileResult = compileWithRootParity({ code, filename, id: filename, @@ -49,7 +76,7 @@ describe('add-hmr works', () => { const file = await readFile(path.join(dirs.files, filename)) const code = file.toString() - const compileResult = compileCodeSplitReferenceRoute({ + const compileResult = compileWithRootParity({ code, filename, id: filename, diff --git a/packages/router-plugin/tests/code-splitter.test.ts b/packages/router-plugin/tests/code-splitter.test.ts index 967f102e4d4..1dcf4bda356 100644 --- a/packages/router-plugin/tests/code-splitter.test.ts +++ b/packages/router-plugin/tests/code-splitter.test.ts @@ -10,9 +10,13 @@ import { collectLocalBindingsFromStatement, collectModuleLevelRefsFromNode, compileCodeSplitReferenceRoute, + compileCodeSplitReferenceRouteFromAst, compileCodeSplitSharedRoute, compileCodeSplitVirtualRoute, computeSharedBindings, + computeSharedBindingsFromAst, + detectCodeSplitGroupingsFromAst, + detectCodeSplitGroupingsFromRoute, expandDestructuredDeclarations, expandSharedDestructuredDeclarators, expandTransitively, @@ -76,7 +80,7 @@ describe('code-splitter works', () => { codeSplitGroupings: grouping, }) - const compileResult = compileCodeSplitReferenceRoute({ + const opts = { code, filename, id: filename, @@ -85,9 +89,32 @@ describe('code-splitter works', () => { targetFramework: framework, sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, + } + const compileResult = compileCodeSplitReferenceRoute(opts) + + // Exercise the plugin's parse-once ordering across the entire + // snapshot matrix, including scope-sensitive shared extraction. + const ast = parseAst({ code, filename }) + expect(detectCodeSplitGroupingsFromAst(ast)).toEqual( + detectCodeSplitGroupingsFromRoute({ code, filename }), + ) + const parsedSharedBindings = computeSharedBindingsFromAst( + ast, + grouping, + ) + expect(parsedSharedBindings).toEqual(sharedBindings) + const parsedResult = compileCodeSplitReferenceRouteFromAst(ast, { + ...opts, + sharedBindings: + parsedSharedBindings.size > 0 + ? parsedSharedBindings + : undefined, }) + expect(parsedResult?.code).toBe(compileResult?.code) + expect(parsedResult?.map).toEqual(compileResult?.map) + expect(parsedResult === null).toBe(compileResult === null) - await expect(compileResult?.code || code).toMatchFileSnapshot( + await expect(parsedResult?.code || code).toMatchFileSnapshot( path.join(dirs.snapshots, groupName, filename), ) }, diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options.tsx new file mode 100644 index 00000000000..bb7584f70a0 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options.tsx @@ -0,0 +1,10 @@ +import { read } from "shared-identifier-options.tsx?tsr-shared=1"; +const $$splitComponentImporter = () => import('shared-identifier-options.tsx?tsr-split=component'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: () => read(), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}; +export const Route = createFileRoute('/shared-identifier-options')(options); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@component.tsx new file mode 100644 index 00000000000..7a6731415d8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@component.tsx @@ -0,0 +1,3 @@ +import { render } from "shared-identifier-options.tsx?tsr-shared=1"; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@shared.tsx new file mode 100644 index 00000000000..9b31c488884 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-identifier-options@shared.tsx @@ -0,0 +1,11 @@ +const seed = { + value: 'shared' +}; +const { + read, + render +} = { + read: () => seed.value, + render: () =>
{seed.value}
+}; +export { read, render, seed }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options.tsx new file mode 100644 index 00000000000..676bc78a5e9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options.tsx @@ -0,0 +1,11 @@ +const $$splitComponentImporter = () => import('shared-identifier-options.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +const $$splitLoaderImporter = () => import('shared-identifier-options.tsx?tsr-split=loader'); +import { lazyFn } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}; +export const Route = createFileRoute('/shared-identifier-options')(options); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..7a6731415d8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,3 @@ +import { render } from "shared-identifier-options.tsx?tsr-shared=1"; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@loader.tsx new file mode 100644 index 00000000000..f4aa78a5e67 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@loader.tsx @@ -0,0 +1,3 @@ +import { read } from "shared-identifier-options.tsx?tsr-shared=1"; +const SplitLoader = () => read(); +export { SplitLoader as loader }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@shared.tsx new file mode 100644 index 00000000000..9b31c488884 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-identifier-options@shared.tsx @@ -0,0 +1,11 @@ +const seed = { + value: 'shared' +}; +const { + read, + render +} = { + read: () => seed.value, + render: () =>
{seed.value}
+}; +export { read, render, seed }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options.tsx new file mode 100644 index 00000000000..140e67648cf --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options.tsx @@ -0,0 +1,11 @@ +const $$splitComponentImporter = () => import('shared-identifier-options.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +const $$splitLoaderImporter = () => import('shared-identifier-options.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyFn } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}; +export const Route = createFileRoute('/shared-identifier-options')(options); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..661d7851cc9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,14 @@ +const seed = { + value: 'shared' +}; +const { + read, + render +} = { + read: () => seed.value, + render: () =>
{seed.value}
+}; +const SplitLoader = () => read(); +export { SplitLoader as loader }; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-identifier-options@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options.tsx new file mode 100644 index 00000000000..dcd5a920249 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options.tsx @@ -0,0 +1,10 @@ +import { read } from "shared-identifier-options.tsx?tsr-shared=1"; +const $$splitComponentImporter = () => import('shared-identifier-options.tsx?tsr-split=component'); +import { lazyRouteComponent } from '@tanstack/solid-router'; +import { createFileRoute } from '@tanstack/solid-router'; +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: () => read(), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}; +export const Route = createFileRoute('/shared-identifier-options')(options); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@component.tsx new file mode 100644 index 00000000000..7a6731415d8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@component.tsx @@ -0,0 +1,3 @@ +import { render } from "shared-identifier-options.tsx?tsr-shared=1"; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@shared.tsx new file mode 100644 index 00000000000..9b31c488884 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-identifier-options@shared.tsx @@ -0,0 +1,11 @@ +const seed = { + value: 'shared' +}; +const { + read, + render +} = { + read: () => seed.value, + render: () =>
{seed.value}
+}; +export { read, render, seed }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options.tsx new file mode 100644 index 00000000000..628a350db68 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options.tsx @@ -0,0 +1,11 @@ +const $$splitComponentImporter = () => import('shared-identifier-options.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/solid-router'; +const $$splitLoaderImporter = () => import('shared-identifier-options.tsx?tsr-split=loader'); +import { lazyFn } from '@tanstack/solid-router'; +import { createFileRoute } from '@tanstack/solid-router'; +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}; +export const Route = createFileRoute('/shared-identifier-options')(options); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..7a6731415d8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,3 @@ +import { render } from "shared-identifier-options.tsx?tsr-shared=1"; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@loader.tsx new file mode 100644 index 00000000000..f4aa78a5e67 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@loader.tsx @@ -0,0 +1,3 @@ +import { read } from "shared-identifier-options.tsx?tsr-shared=1"; +const SplitLoader = () => read(); +export { SplitLoader as loader }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@shared.tsx new file mode 100644 index 00000000000..9b31c488884 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-identifier-options@shared.tsx @@ -0,0 +1,11 @@ +const seed = { + value: 'shared' +}; +const { + read, + render +} = { + read: () => seed.value, + render: () =>
{seed.value}
+}; +export { read, render, seed }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options.tsx new file mode 100644 index 00000000000..9e1b4852fae --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options.tsx @@ -0,0 +1,11 @@ +const $$splitComponentImporter = () => import('shared-identifier-options.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/solid-router'; +const $$splitLoaderImporter = () => import('shared-identifier-options.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyFn } from '@tanstack/solid-router'; +import { createFileRoute } from '@tanstack/solid-router'; +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}; +export const Route = createFileRoute('/shared-identifier-options')(options); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..661d7851cc9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,14 @@ +const seed = { + value: 'shared' +}; +const { + read, + render +} = { + read: () => seed.value, + render: () =>
{seed.value}
+}; +const SplitLoader = () => read(); +export { SplitLoader as loader }; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-identifier-options@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/shared-identifier-options.tsx new file mode 100644 index 00000000000..a08ff706e71 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/shared-identifier-options.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' + +const seed = { value: 'shared' } +const { read, render } = { + read: () => seed.value, + render: () =>
{seed.value}
, +} + +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: () => read(), + component: () => render(), +} + +export const Route = createFileRoute('/shared-identifier-options')(options) diff --git a/packages/router-plugin/tests/code-splitter/test-files/solid/shared-identifier-options.tsx b/packages/router-plugin/tests/code-splitter/test-files/solid/shared-identifier-options.tsx new file mode 100644 index 00000000000..6602ecd0117 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/solid/shared-identifier-options.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' + +const seed = { value: 'shared' } +const { read, render } = { + read: () => seed.value, + render: () =>
{seed.value}
, +} + +const options = { + codeSplitGroupings: [['component'], ['loader']], + loader: () => read(), + component: () => render(), +} + +export const Route = createFileRoute('/shared-identifier-options')(options) diff --git a/packages/router-plugin/tests/reference-pipeline.bench.ts b/packages/router-plugin/tests/reference-pipeline.bench.ts new file mode 100644 index 00000000000..7f2f2ffef65 --- /dev/null +++ b/packages/router-plugin/tests/reference-pipeline.bench.ts @@ -0,0 +1,137 @@ +import { bench, describe, expect } from 'vitest' +import { parseAst } from '@tanstack/router-utils' +import { + compileCodeSplitReferenceRoute, + compileCodeSplitReferenceRouteFromAst, + computeSharedBindings, + computeSharedBindingsFromAst, + detectCodeSplitGroupingsFromAst, + detectCodeSplitGroupingsFromRoute, +} from '../src/core/code-splitter/compilers' +import { defaultCodeSplitGroupings } from '../src/core/constants' + +// Compare the old three-parse pipeline with the plugin's one-parse pipeline. +// Both use identical sources/options and run all analyses before compilation. +// Each iteration owns fresh ASTs; no cached parse or mutated tree is reused. +function runPipeline(code: string, parseOnce: boolean) { + const filename = '/src/routes/benchmark.tsx' + const ast = parseOnce ? parseAst({ code, filename }) : undefined + const { groupings } = ast + ? detectCodeSplitGroupingsFromAst(ast) + : detectCodeSplitGroupingsFromRoute({ code, filename }) + const codeSplitGroupings = groupings ?? defaultCodeSplitGroupings + const sharedBindings = ast + ? computeSharedBindingsFromAst(ast, codeSplitGroupings) + : computeSharedBindings({ code, filename, codeSplitGroupings }) + const opts = { + code, + filename, + id: filename, + codeSplitGroupings, + sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, + targetFramework: 'react' as const, + addHmr: false, + } + const result = ast + ? compileCodeSplitReferenceRouteFromAst(ast, opts) + : compileCodeSplitReferenceRoute(opts) + return { groupings, sharedBindings, code: result?.code, map: result?.map } +} + +function routeSource(large: boolean, customGroups: boolean, shared: boolean) { + const helpers = large + ? Array.from( + { length: 120 }, + (_, index) => ` +function renderField${index}(value: string) { + const label = value.trim() || 'Field ${index}' + return +}`, + ).join('\n') + : '' + const fields = large + ? Array.from( + { length: 120 }, + (_, index) => `{renderField${index}('Value ${index}')}`, + ).join('\n') + : 'Small route' + return ` +import { createFileRoute } from '@tanstack/react-router' +${shared ? "const state = { title: 'Shared title' }" : ''} +${helpers} +function Component() { + return

${shared ? '{state.title}' : 'Independent title'}

${fields}
+} +const options = { + ${customGroups ? "codeSplitGroupings: [['component', 'pendingComponent'], ['loader']]," : ''} + loader: () => ${shared ? 'state.title' : "'Independent data'"}, + component: Component, + pendingComponent: () =>

Loading

, +} +export const Route = createFileRoute('/benchmark')(options) +` +} + +// Run each workload in fresh processes with both AB and BA to check ordering +// sensitivity without changing its inputs or timing settings between runs. +const order = process.env.REFERENCE_BENCH_ORDER ?? 'AB' +if (order !== 'AB' && order !== 'BA') { + throw new Error('REFERENCE_BENCH_ORDER must be AB or BA') +} + +function benchmarkPipeline(name: string, code: string, shared = false) { + describe(name, () => { + const baseline = runPipeline(code, false) + const candidate = runPipeline(code, true) + expect(candidate).toEqual(baseline) + expect(candidate.sharedBindings.size > 0).toBe(shared) + expect(candidate.code).toContain('$$splitComponentImporter') + expect(candidate.map?.sourcesContent).toEqual([code]) + + for (const parseOnce of order === 'AB' ? [false, true] : [true, false]) { + bench( + parseOnce ? 'candidate: one parse' : 'baseline: three parses', + () => { + runPipeline(code, parseOnce) + }, + { time: 5000, iterations: 200, warmupTime: 1000 }, + ) + } + }) +} + +for (const [name, body] of [ + [ + 'imported-component-only', + ` +import Component from './Component' +export const Route = createFileRoute('/benchmark')({ component: Component })`, + ], + [ + 'inline-component-only', + ` +export const Route = createFileRoute('/benchmark')({ component: () =>
Page
})`, + ], + [ + 'single-group-local', + ` +const title = 'Page' +function Component() { return
{title}
} +export const Route = createFileRoute('/benchmark')({ component: Component })`, + ], +] as const) { + benchmarkPipeline( + `tiny/default/${name}`, + `import { createFileRoute } from '@tanstack/react-router'\n${body}`, + ) +} + +for (const large of [false, true]) { + for (const customGroups of [false, true]) { + for (const shared of [false, true]) { + const name = `${large ? 'large-synthetic-declaration-heavy' : 'small'}/${customGroups ? 'custom' : 'default'}/${shared ? 'shared' : 'no-shared'}` + const code = routeSource(large, customGroups, shared) + benchmarkPipeline(name, code, shared) + } + } +} diff --git a/packages/router-plugin/tests/reference-pipeline.test.ts b/packages/router-plugin/tests/reference-pipeline.test.ts new file mode 100644 index 00000000000..24d8948293b --- /dev/null +++ b/packages/router-plugin/tests/reference-pipeline.test.ts @@ -0,0 +1,419 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import * as t from '@babel/types' +import { describe, expect, it, vi } from 'vitest' +import { parseAst } from '@tanstack/router-utils' +import { + compileCodeSplitReferenceRoute, + compileCodeSplitSharedRoute, + compileCodeSplitVirtualRoute, + computeSharedBindings, + detectCodeSplitGroupingsFromRoute, +} from '../src/core/code-splitter/compilers' +import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' +import { defaultCodeSplitGroupings } from '../src/core/constants' +import { createRouterCodeSplitterPlugin } from '../src/core/router-code-splitter-plugin' +import { createRouterPluginContext } from '../src/core/router-plugin-context' +import { normalizePath } from '../src/core/utils' +import type { Config } from '../src/core/config' +import type { CodeSplitCompilerPlugin } from '../src/core/code-splitter/plugins' +import type { CodeSplitGroupings } from '../src/core/constants' + +vi.mock('@tanstack/router-utils', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, parseAst: vi.fn(actual.parseAst) } +}) + +async function createHarness( + options: Partial = {}, + command: 'serve' | 'build' = 'serve', +) { + const filename = normalizePath(path.resolve('src/routes/pipeline.tsx')) + const context = createRouterPluginContext() + context.routesByFile.set(filename, { routeId: '/pipeline' }) + const result = createRouterCodeSplitterPlugin(options, context) + const plugins = Array.isArray(result) ? result : [result] + const reference = plugins[0]! + const hook = reference.vite!.configResolved! + const config = { + root: process.cwd(), + command, + plugins: [{ name: reference.name }], + } as never + if (typeof hook === 'function') { + await hook.call({} as never, config) + } else { + await hook.handler.call({} as never, config) + } + + return { + filename, + transform(code: string, query = '') { + const plugin = query.startsWith('?tsr-shared') + ? plugins[2]! + : query + ? plugins[1]! + : reference + const transform = plugin.transform! + if (typeof transform === 'function') { + throw new Error('Expected object transform') + } + return transform.handler.call({} as never, code, `${filename}${query}`) + }, + } +} + +describe('reference pipeline', () => { + it('parses the reference source once per transform', async () => { + const harness = await createHarness({}, 'build') + const code = ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/pipeline')({ component: () =>
})` + vi.mocked(parseAst).mockClear() + await harness.transform(code) + expect(parseAst).toHaveBeenCalledTimes(1) + }) + + it.each(['react', 'solid', 'vue'] as const)( + 'preserves %s compiler hooks, HMR and sourcemaps after analysis', + async (targetFramework) => { + const fixture = await readFile( + new URL( + './code-splitter/test-files/react/shared-identifier-options.tsx', + import.meta.url, + ), + 'utf8', + ) + const code = fixture.replace( + '@tanstack/react-router', + `@tanstack/${targetFramework}-router`, + ) + for (const hmrStyle of ['vite', 'webpack'] as const) { + const observed: Array> = [] + const mutationPlugin: CodeSplitCompilerPlugin = { + name: 'remove-loader-after-analysis', + onRouteOptions({ routeOptions, opts }) { + observed.push([...opts.sharedBindings!].sort()) + // Removing one consumer must not retroactively change sharing. + routeOptions.properties = routeOptions.properties.filter( + (prop) => + !( + t.isObjectProperty(prop) && + t.isIdentifier(prop.key, { name: 'loader' }) + ), + ) + return { modified: true } + }, + } + const harness = await createHarness({ + target: targetFramework, + plugin: { hmr: { style: hmrStyle } }, + codeSplittingOptions: { compilerPlugins: [mutationPlugin] }, + }) + const { filename } = harness + const { groupings } = detectCodeSplitGroupingsFromRoute({ + code, + filename, + }) + const sharedBindings = computeSharedBindings({ + code, + filename, + codeSplitGroupings: groupings!, + }) + const baseline = compileCodeSplitReferenceRoute({ + code, + filename, + id: filename, + codeSplitGroupings: groupings!, + targetFramework, + sharedBindings, + addHmr: true, + hmrStyle, + hmrRouteId: '/pipeline', + compilerPlugins: [ + ...(getFrameworkHmrCompilerPlugins({ + targetFramework, + hmrStyle, + }) ?? []), + mutationPlugin, + ], + }) + const candidate = await harness.transform(code) + expect(candidate).toMatchObject({ + code: baseline!.code, + map: baseline!.map, + }) + expect(baseline!.map?.sourcesContent).toEqual([code]) + expect(observed).toEqual([ + ['read', 'render', 'seed'], + ['read', 'render', 'seed'], + ]) + } + }, + ) + + it.each(['serve', 'build'] as const)( + 'refreshes reference, virtual and shared output across source changes in %s', + async (command) => { + const harness = await createHarness( + { codeSplittingOptions: { addHmr: false } }, + command, + ) + for (const name of ['a', 'b', undefined, 'a']) { + const code = ` +import { createFileRoute } from '@tanstack/react-router' +${name ? `const ${name} = { value: '${name}' }` : ''} +export const Route = createFileRoute('/pipeline')({ + loader: () => ${name ? `${name}.value` : "'none'"}, + component: () =>
{${name ? `${name}.value` : "'none'"}}
, +})` + const { filename } = harness + const sharedBindings = computeSharedBindings({ + code, + filename, + codeSplitGroupings: defaultCodeSplitGroupings, + }) + expect([...sharedBindings]).toEqual(name ? [name] : []) + const reference = compileCodeSplitReferenceRoute({ + code, + filename, + id: filename, + targetFramework: 'react', + codeSplitGroupings: defaultCodeSplitGroupings, + sharedBindings, + addHmr: false, + })! + const virtual = compileCodeSplitVirtualRoute({ + code, + filename: `${filename}?tsr-split=component`, + splitTargets: ['component'], + sharedBindings, + }) + for (const [query, expected] of [ + ['', reference], + ['?tsr-split=component', virtual], + ] as const) { + expect(await harness.transform(code, query)).toMatchObject({ + code: expected.code, + map: expected.map, + }) + expect(expected.map?.sourcesContent).toEqual([code]) + } + const shared = await harness.transform(code, '?tsr-shared=1') + if (name) { + const expected = compileCodeSplitSharedRoute({ + code, + filename: `${filename}?tsr-shared=1`, + sharedBindings, + }) + expect(shared).toMatchObject({ + code: expected.code, + map: expected.map, + }) + expect(expected.map?.sourcesContent).toEqual([code]) + expect(expected.code).toContain(`export { ${name} }`) + } else { + expect(shared).toBeNull() + } + } + }, + ) + + it('preserves scope-aware mutations and binding inspection across compiler hooks', async () => { + const observations: Array> = [] + const compilerPlugins: Array = [ + { + name: 'rename-and-insert', + onRouteOptions({ programPath, insertionPath }) { + programPath.scope.rename('message', 'renamedMessage') + insertionPath.insertBefore( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('injected'), + t.identifier('renamedMessage'), + ), + ]), + ) + programPath.scope.crawl() + return { modified: true } + }, + }, + { + name: 'inspect-renamed-bindings', + onRouteOptions({ programPath, routeOptions }) { + expect(programPath.scope.hasBinding('message')).toBe(false) + const renamed = programPath.scope.getBinding('renamedMessage')! + const inserted = programPath.scope.getBinding('injected')! + observations.push([ + renamed.identifier.name, + inserted.identifier.name, + ...renamed.referencePaths.map((reference) => { + t.assertIdentifier(reference.node) + return reference.node.name + }), + ]) + routeOptions.properties.push( + t.objectProperty( + t.identifier('context'), + t.arrowFunctionExpression([], t.identifier('injected')), + ), + ) + programPath.scope.crawl() + expect(programPath.scope.getBinding('injected')!.referenced).toBe( + true, + ) + return { modified: true } + }, + }, + ] + const harness = await createHarness( + { codeSplittingOptions: { compilerPlugins } }, + 'build', + ) + const code = ` +import { createFileRoute } from '@tanstack/react-router' +const message = 'scope-visible' +const options = { loader: () => message, component: () =>
page
} +export const Route = createFileRoute('/pipeline')(options)` + const baseline = compileCodeSplitReferenceRoute({ + code, + filename: harness.filename, + id: harness.filename, + codeSplitGroupings: defaultCodeSplitGroupings, + targetFramework: 'react', + compilerPlugins, + })! + const candidate = await harness.transform(code) + expect(candidate).toMatchObject({ code: baseline.code, map: baseline.map }) + expect(baseline.code).toContain('const injected = renamedMessage') + expect(baseline.code).toContain('context: () => injected') + expect(observations).toHaveLength(2) + expect(observations[1]).toEqual(observations[0]) + }) + + it('preserves Start client deletion, shared extraction and server-only import DCE', async () => { + // The client router integration in both Vite and Rsbuild uses these keys. + const deleteNodes = ['ssr', 'server', 'headers'] + const harness = await createHarness( + { codeSplittingOptions: { deleteNodes } }, + 'build', + ) + const code = ` +import { createFileRoute } from '@tanstack/react-router' +import { serverPolicy, handleRequest, makeHeaders } from './server-only' +const shared = { title: 'client-visible' } +export const Route = createFileRoute('/pipeline')({ + ssr: () => serverPolicy(), + server: { handlers: { GET: () => handleRequest() } }, + headers: () => makeHeaders(), + loader: () => shared.title, + component: () =>
{shared.title}
, +})` + const sharedBindings = computeSharedBindings({ + code, + filename: harness.filename, + codeSplitGroupings: defaultCodeSplitGroupings, + }) + expect([...sharedBindings]).toEqual(['shared']) + const baseline = compileCodeSplitReferenceRoute({ + code, + filename: harness.filename, + id: harness.filename, + codeSplitGroupings: defaultCodeSplitGroupings, + targetFramework: 'react', + deleteNodes: new Set(deleteNodes), + sharedBindings, + })! + expect(await harness.transform(code)).toMatchObject({ + code: baseline.code, + map: baseline.map, + }) + expect(baseline.code).not.toMatch( + /server-only|serverPolicy|handleRequest|makeHeaders|ssr:|server:|headers:/, + ) + expect(baseline.code).toContain('tsr-shared=1') + expect(baseline.code).toContain('$$splitComponentImporter') + expect(baseline.map?.sourcesContent).toEqual([code]) + }) + + it.each([ + { + sourceGroups: undefined, + callbackGroups: undefined, + expected: [['loader']], + }, + { + sourceGroups: undefined, + callbackGroups: [['component']], + expected: [['component']], + }, + { + sourceGroups: [['loader', 'component']], + callbackGroups: [['component']], + expected: [['loader', 'component']], + }, + { sourceGroups: [], callbackGroups: [['component']], expected: [] }, + ] as Array<{ + sourceGroups?: CodeSplitGroupings + callbackGroups?: CodeSplitGroupings + expected: CodeSplitGroupings + }>)( + 'preserves grouping precedence: $sourceGroups / $callbackGroups', + async ({ sourceGroups, callbackGroups, expected }) => { + const splitBehavior = vi.fn(() => callbackGroups) + const harness = await createHarness({ + codeSplittingOptions: { + addHmr: false, + defaultBehavior: [['loader']], + splitBehavior, + }, + }) + const code = ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/pipeline')({ + ${sourceGroups ? `codeSplitGroupings: ${JSON.stringify(sourceGroups)},` : ''} + loader: () => 'data', component: () =>
page
, +})` + const baseline = compileCodeSplitReferenceRoute({ + code, + filename: harness.filename, + id: harness.filename, + codeSplitGroupings: expected, + targetFramework: 'react', + addHmr: false, + }) + const candidate = await harness.transform(code) + if (baseline === null) { + expect(candidate).toBeNull() + } else { + expect(candidate).toMatchObject({ + code: baseline.code, + map: baseline.map, + }) + } + expect(splitBehavior).toHaveBeenCalledExactlyOnceWith({ + routeId: '/pipeline', + }) + }, + ) + + it('validates callback groups even when the source specifies groups', async () => { + const onRouteOptions = vi.fn() + const harness = await createHarness({ + codeSplittingOptions: { + splitBehavior: () => [['invalid']] as unknown as CodeSplitGroupings, + compilerPlugins: [{ name: 'observe', onRouteOptions }], + }, + }) + const code = ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/pipeline')({ + codeSplitGroupings: ${JSON.stringify(defaultCodeSplitGroupings)}, + component: () =>
page
, +})` + expect(() => harness.transform(code)).toThrow( + 'The groupings returned when using `splitBehavior`', + ) + expect(onRouteOptions).not.toHaveBeenCalled() + }) +})