From 108d15fe50548b2e3bb9a8c98eedcb4cfb93dbc1 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 26 Sep 2026 18:12:34 +0200 Subject: [PATCH] test: port compiler correctness coverage from Yuku migration --- .../router-generator/tests/transform.test.ts | 13 ++ .../tests/code-splitter-jsx.test.ts | 66 +++++++ .../tests/code-splitter-runtime.test.ts | 161 ++++++++++++++++++ .../react/1-default/shared-runtime.tsx | 23 +++ .../1-default/shared-runtime@component.tsx | 23 +++ .../shared-runtime@errorComponent.tsx | 11 ++ .../shared-runtime@notFoundComponent.tsx | 11 ++ .../react/1-default/shared-runtime@shared.tsx | 28 +++ .../shared-runtime.tsx | 22 +++ ...--notFoundComponent---pendingComponent.tsx | 23 +++ .../shared-runtime@loader.tsx | 16 ++ .../shared-runtime@shared.tsx | 28 +++ .../shared-runtime.tsx | 22 +++ ...--notFoundComponent---pendingComponent.tsx | 28 +++ .../shared-runtime@errorComponent.tsx | 11 ++ .../shared-runtime@shared.tsx | 28 +++ .../test-files/react/shared-runtime.tsx | 50 ++++++ .../tests/router-plugin-context.test.ts | 138 +++++++++++++++ .../start-plugin-core/tests/compiler.test.ts | 124 ++++++++++++++ .../tests/hydrate-when-transform.test.ts | 88 ++++++++++ .../tests/hydrateWhen/hydrateWhen.test.ts | 58 +++++++ .../importProtection/analysis.exports.test.ts | 14 ++ .../importProtection/analysis.usage.test.ts | 37 +++- 23 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 packages/router-plugin/tests/code-splitter-jsx.test.ts create mode 100644 packages/router-plugin/tests/code-splitter-runtime.test.ts create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx create mode 100644 packages/router-plugin/tests/code-splitter/test-files/react/shared-runtime.tsx diff --git a/packages/router-generator/tests/transform.test.ts b/packages/router-generator/tests/transform.test.ts index 8a53f3278e8..f026ecc30e6 100644 --- a/packages/router-generator/tests/transform.test.ts +++ b/packages/router-generator/tests/transform.test.ts @@ -14,6 +14,19 @@ function makeNode( } describe('transform', () => { + it('updates parenthesized route calls without changing their surrounding source', () => { + const result = transform({ + source: + "import { createFileRoute } from '@tanstack/react-router'; export const Route = ((createFileRoute('/old'))({}));", + ctx: { target: 'react', routeId: '/new', lazy: false }, + node: makeNode(), + }) + expect(result.result).toBe('modified') + if (result.result === 'modified') { + expect(result.output).toContain("((createFileRoute('/new'))({}))") + } + }) + it('does not treat root route exports as missing Route exports', async () => { const result = await transform({ source: [ diff --git a/packages/router-plugin/tests/code-splitter-jsx.test.ts b/packages/router-plugin/tests/code-splitter-jsx.test.ts new file mode 100644 index 00000000000..0718a5da2a0 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter-jsx.test.ts @@ -0,0 +1,66 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createServer } from 'vite' +import { expect, it } from 'vitest' +import { tanstackRouter } from '../src/vite' + +it.each(['Widget', '_Widget', '$Widget'])( + 'preserves the shared JSX component %s across separate loader and component chunks', + async (name) => { + // Keep the temporary app inside the package so runtime imports resolve. + const root = await mkdtemp(path.join(__dirname, '.jsx-reference-')) + let server: Awaited> | undefined + try { + await mkdir(path.join(root, 'routes')) + await writeFile( + path.join(root, 'routes/__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})`, + ) + await writeFile( + path.join(root, 'routes/widget.tsx'), + `import React from 'react' +import { createFileRoute } from '@tanstack/react-router' +const ${name} = () => 'hello' +export const Route = createFileRoute('/widget')({ + loader: () => ${name}, + component: () => <${name} />, +})`, + ) + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { watch: null }, + plugins: [ + tanstackRouter({ + target: 'react', + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + autoCodeSplitting: true, + codeSplittingOptions: { + addHmr: false, + defaultBehavior: [['loader'], ['component']], + }, + }), + ], + }) + // The physical module establishes shared ownership before chunks load. + await server.ssrLoadModule('/routes/widget.tsx') + const componentModule = await server.ssrLoadModule( + '/routes/widget.tsx?tsr-split=component', + ) + const loaderModule = await server.ssrLoadModule( + '/routes/widget.tsx?tsr-split=loader', + ) + const element = componentModule.component() + expect(typeof element.type).toBe('function') + expect(element.type()).toBe('hello') + expect(loaderModule.loader()()).toBe('hello') + } finally { + await server?.close() + await rm(root, { recursive: true, force: true }) + } + }, + 30_000, +) diff --git a/packages/router-plugin/tests/code-splitter-runtime.test.ts b/packages/router-plugin/tests/code-splitter-runtime.test.ts new file mode 100644 index 00000000000..0eb9b52ea54 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter-runtime.test.ts @@ -0,0 +1,161 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import { pathToFileURL } from 'node:url' +import { build } from 'vite' +import { expect, it } from 'vitest' +import { tanstackRouter } from '../src/vite' +import type { CodeSplitGroupings } from '../src/core/constants' + +const runNode = promisify(execFile) +const fixture = path.resolve( + __dirname, + 'code-splitter/test-files/react/shared-runtime.tsx', +) + +const groupings: Array<{ name: string; groupings: CodeSplitGroupings }> = [ + { name: 'eager loader', groupings: [['component']] }, + { name: 'separate lazy loader', groupings: [['component'], ['loader']] }, + { name: 'combined lazy loader', groupings: [['component', 'loader']] }, +] + +it.each(groupings)( + 'preserves executable route contracts with $name', + assertRuntimeContracts, + 30_000, +) + +// Shared extraction currently duplicates named default function declarations and +// drops TypeScript enum/namespace dependencies. Keep their intended contracts. +it.fails.each(['named-default', 'enum', 'namespace'] as const)( + 'preserves executable route contracts with shared %s declarations', + async (syntax) => { + await assertRuntimeContracts({ + groupings: [['component'], ['loader']], + syntax, + }) + }, + 30_000, +) + +async function assertRuntimeContracts({ + groupings, + syntax, +}: { + groupings: CodeSplitGroupings + syntax?: 'named-default' | 'enum' | 'namespace' +}) { + // Keep the temporary app inside the package so real runtime imports resolve. + const root = await mkdtemp(path.join(__dirname, '.runtime-contract-')) + try { + await mkdir(path.join(root, 'routes')) + let source = await readFile(fixture, 'utf8') + if (syntax === 'named-default') { + source = source + .replace( + 'function createSeed()', + 'export default function createSeed()', + ) + .replace('export default createSeed', '') + } else if (syntax === 'enum') { + source = source.replace( + 'const Count = { Initial: 0 }', + 'enum Count { Initial = 0 }', + ) + } else if (syntax === 'namespace') { + source = source.replace( + "const Labels = { component: 'count' }", + "namespace Labels { export const component = 'count' }", + ) + } + await writeFile(path.join(root, 'routes/shared-runtime.tsx'), source) + await writeFile( + path.join(root, 'routes/__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})`, + ) + await writeFile( + path.join(root, 'entry.ts'), + `import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { Route } from './routes/shared-runtime' +export { Route, firstState, secondState, 'odd-name' } from './routes/shared-runtime' +export function render() { + return renderToString(createElement(Route.options.component)) +}`, + ) + + await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + tanstackRouter({ + target: 'react', + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + autoCodeSplitting: true, + codeSplittingOptions: { + addHmr: false, + defaultBehavior: groupings, + }, + }), + ], + build: { + ssr: path.join(root, 'entry.ts'), + outDir: 'dist', + minify: false, + sourcemap: true, + rollupOptions: { + output: { + entryFileNames: 'entry.mjs', + chunkFileNames: '[name].mjs', + }, + }, + }, + }) + + const throwLine = + source.split('\n').findIndex((line) => line.includes('throw new Error')) + + 1 + const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href + const combined = groupings.some( + (group) => group.includes('component') && group.includes('loader'), + ) + const { stdout } = await runNode(process.execPath, [ + '--enable-source-maps', + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict' +const messages = [] +console.log = (message) => messages.push(message) +const { Route, render, firstState, secondState, 'odd-name': oddState } = await import(${JSON.stringify(entryUrl)}) +assert.deepEqual(messages, ['shared-runtime:seed', 'shared-runtime:state', 'shared-runtime:reference']) +const state = Route.options.beforeLoad({}) +assert.strictEqual(firstState, state) +assert.strictEqual(secondState, state) +assert.strictEqual(oddState, state) +assert.equal(state.count, 0) +assert.strictEqual(await Route.options.loader({}), state) +assert.equal(state.count, 1) +assert.deepEqual(messages, ${JSON.stringify(combined ? ['shared-runtime:seed', 'shared-runtime:state', 'shared-runtime:reference', 'shared-runtime:component'] : ['shared-runtime:seed', 'shared-runtime:state', 'shared-runtime:reference'])}) +await Route.options.component.preload() +assert.equal(render(), 'count:1') +assert.strictEqual(await Route.options.loader({}), state) +assert.equal(render(), 'count:2') +await Route.options.component.preload() +assert.deepEqual(messages, ['shared-runtime:seed', 'shared-runtime:state', 'shared-runtime:reference', 'shared-runtime:component']) +await Route.options.loader({}) +assert.throws(render, (error) => { + assert.equal(error.message, 'route source-map contract') + assert.match(error.stack, /shared-runtime\\.tsx(?:\\?[^\\n:]+)?:${throwLine}:/) + return true +}) +process.stdout.write('route contracts passed')`, + ]) + expect(stdout).toBe('route contracts passed') + } finally { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx new file mode 100644 index 00000000000..c230b4d37f8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx @@ -0,0 +1,23 @@ +import { state, createState, createSeed, increment } from "shared-runtime.tsx?tsr-shared=1"; +const $$splitComponentImporter = () => import('shared-runtime.tsx?tsr-split=component'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +console.log('shared-runtime:reference'); +export const Route = createFileRoute('/shared-runtime')({ + beforeLoad: () => state, + loader: () => { + increment(); + return state; + }, + component: lazyRouteComponent($$splitComponentImporter, 'component') +}); +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx new file mode 100644 index 00000000000..fbccf6c90bc --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx @@ -0,0 +1,23 @@ +import { state, createState, createSeed, Labels } from "shared-runtime.tsx?tsr-shared=1"; +const label = createComponentLabel(); +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +function createComponentLabel() { + console.log('shared-runtime:component'); + return Labels.component; +} +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; +const SplitComponent = () => { + if (state.count === 3) { + throw new Error('route source-map contract'); + } + return `${label}:${state.count}`; +}; +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx new file mode 100644 index 00000000000..d890a562c94 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx @@ -0,0 +1,11 @@ +import { state, createState, createSeed } from "shared-runtime.tsx?tsr-shared=1"; +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx new file mode 100644 index 00000000000..d890a562c94 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx @@ -0,0 +1,11 @@ +import { state, createState, createSeed } from "shared-runtime.tsx?tsr-shared=1"; +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx new file mode 100644 index 00000000000..54239b6cc59 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx @@ -0,0 +1,28 @@ +const Count = { + Initial: 0 +}; +const Labels = { + component: 'count' +}; +const seed = createSeed(); +const { + state, + increment +} = createState(seed); +function createSeed() { + console.log('shared-runtime:seed'); + return { + count: Count.Initial, + label: Labels.component + }; +} +function createState(initialState: { + count: number; +}) { + console.log('shared-runtime:state'); + return { + state: initialState, + increment: () => initialState.count++ + }; +} +export { Count, createSeed, createState, increment, Labels, seed, state }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx new file mode 100644 index 00000000000..bceb12d39a4 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx @@ -0,0 +1,22 @@ +import { state, createState, createSeed } from "shared-runtime.tsx?tsr-shared=1"; +const $$splitComponentImporter = () => import('shared-runtime.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +const $$splitLoaderImporter = () => import('shared-runtime.tsx?tsr-split=loader'); +import { lazyFn } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +console.log('shared-runtime:reference'); +export const Route = createFileRoute('/shared-runtime')({ + beforeLoad: () => state, + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}); +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..fbccf6c90bc --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,23 @@ +import { state, createState, createSeed, Labels } from "shared-runtime.tsx?tsr-shared=1"; +const label = createComponentLabel(); +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +function createComponentLabel() { + console.log('shared-runtime:component'); + return Labels.component; +} +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; +const SplitComponent = () => { + if (state.count === 3) { + throw new Error('route source-map contract'); + } + return `${label}:${state.count}`; +}; +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-runtime@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx new file mode 100644 index 00000000000..6c797c0226f --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx @@ -0,0 +1,16 @@ +import { state, createState, createSeed, increment } from "shared-runtime.tsx?tsr-shared=1"; +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; +const SplitLoader = () => { + increment(); + return state; +}; +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-runtime@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx new file mode 100644 index 00000000000..54239b6cc59 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx @@ -0,0 +1,28 @@ +const Count = { + Initial: 0 +}; +const Labels = { + component: 'count' +}; +const seed = createSeed(); +const { + state, + increment +} = createState(seed); +function createSeed() { + console.log('shared-runtime:seed'); + return { + count: Count.Initial, + label: Labels.component + }; +} +function createState(initialState: { + count: number; +}) { + console.log('shared-runtime:state'); + return { + state: initialState, + increment: () => initialState.count++ + }; +} +export { Count, createSeed, createState, increment, Labels, seed, state }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx new file mode 100644 index 00000000000..d88eff9d9b9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx @@ -0,0 +1,22 @@ +import { state, createState, createSeed } from "shared-runtime.tsx?tsr-shared=1"; +const $$splitComponentImporter = () => import('shared-runtime.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +const $$splitLoaderImporter = () => import('shared-runtime.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyFn } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +console.log('shared-runtime:reference'); +export const Route = createFileRoute('/shared-runtime')({ + beforeLoad: () => state, + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}); +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..0af87c21c51 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,28 @@ +import { state, createState, createSeed, Labels, increment } from "shared-runtime.tsx?tsr-shared=1"; +const label = createComponentLabel(); +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +function createComponentLabel() { + console.log('shared-runtime:component'); + return Labels.component; +} +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; +const SplitLoader = () => { + increment(); + return state; +}; +export { SplitLoader as loader }; +const SplitComponent = () => { + if (state.count === 3) { + throw new Error('route source-map contract'); + } + return `${label}:${state.count}`; +}; +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-runtime@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx new file mode 100644 index 00000000000..d890a562c94 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx @@ -0,0 +1,11 @@ +import { state, createState, createSeed } from "shared-runtime.tsx?tsr-shared=1"; +function createState(initialState: { + count: number; +}): { + state: { + count: number; + }; + increment: () => number; +}; +export { state as firstState, state as secondState, state as 'odd-name' }; +export default createSeed; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx new file mode 100644 index 00000000000..54239b6cc59 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx @@ -0,0 +1,28 @@ +const Count = { + Initial: 0 +}; +const Labels = { + component: 'count' +}; +const seed = createSeed(); +const { + state, + increment +} = createState(seed); +function createSeed() { + console.log('shared-runtime:seed'); + return { + count: Count.Initial, + label: Labels.component + }; +} +function createState(initialState: { + count: number; +}) { + console.log('shared-runtime:state'); + return { + state: initialState, + increment: () => initialState.count++ + }; +} +export { Count, createSeed, createState, increment, Labels, seed, state }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/shared-runtime.tsx new file mode 100644 index 00000000000..f9fd708a0ba --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/shared-runtime.tsx @@ -0,0 +1,50 @@ +import { createFileRoute } from '@tanstack/react-router' + +const Count = { Initial: 0 } + +const Labels = { component: 'count' } + +const seed = createSeed() +const { state, increment } = createState(seed) +const label = createComponentLabel() +console.log('shared-runtime:reference') + +export const Route = createFileRoute('/shared-runtime')({ + beforeLoad: () => state, + loader: () => { + increment() + return state + }, + component: () => { + if (state.count === 3) { + throw new Error('route source-map contract') + } + return `${label}:${state.count}` + }, +}) + +function createSeed() { + console.log('shared-runtime:seed') + return { count: Count.Initial, label: Labels.component } +} + +function createState(initialState: { count: number }): { + state: { count: number } + increment: () => number +} +function createState(initialState: { count: number }) { + console.log('shared-runtime:state') + return { + state: initialState, + increment: () => initialState.count++, + } +} + +function createComponentLabel() { + console.log('shared-runtime:component') + return Labels.component +} + +export { state as firstState, state as secondState, state as 'odd-name' } + +export default createSeed diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index b8e317c8ecb..d3fb0563e60 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -90,6 +90,144 @@ function countProgramHotDeclarations(code: string) { } describe('router plugin context', () => { + it('splits parenthesized route factories, option values, and grouping arrays', async () => { + const routeFile = normalizePath( + path.join(process.cwd(), 'src/routes/wrapped.tsx'), + ) + const context = createRouterPluginContext() + context.routesByFile.set(routeFile, { routeId: '/wrapped' }) + const plugins = createRouterCodeSplitterPlugin( + { + target: 'react', + autoCodeSplitting: true, + codeSplittingOptions: { addHmr: false }, + }, + context, + ) + await configurePlugin(getReferencePlugin(plugins), 'build') + const code = `import { createFileRoute } from '@tanstack/react-router' +const Component = () => 'wrapped component' +const loader = () => 'wrapped loader' +export const Route = (createFileRoute('/wrapped'))({ + component: (Component), loader: (loader), + codeSplitGroupings: (([(['component']), (['loader'])])), +})` + const reference = getCode( + await transformReferenceRoute( + getReferencePlugin(plugins), + code, + routeFile, + ), + ) + expect(reference).toContain('lazyRouteComponent') + expect(reference).toContain('lazyFn') + const virtual = getCode( + await transformReferenceRoute( + getCodeSplitterPlugin(plugins, virtualPluginName), + code, + `${routeFile}?tsr-split=component`, + ), + ) + expect(virtual).toContain('wrapped component') + expect(virtual).not.toContain('wrapped loader') + }) + + it('refreshes chunk ownership after source changes and isolates split settings', async () => { + const routeFile = normalizePath( + path.join(process.cwd(), 'src/routes/changing.tsx'), + ) + const source = (name: string, value: string) => ` +import { createFileRoute } from '@tanstack/react-router' +const ${name} = { value: ${JSON.stringify(value)} } +export const Route = createFileRoute('/changing')({ + loader: () => ${name}, + component: () => ${name}.value, +})` + const setup = async (combined: boolean) => { + const context = createRouterPluginContext() + context.routesByFile.set(routeFile, { routeId: '/changing' }) + const plugins = createRouterCodeSplitterPlugin( + { + target: 'react', + autoCodeSplitting: true, + codeSplittingOptions: { + addHmr: false, + defaultBehavior: combined + ? [['component', 'loader']] + : [['component'], ['loader']], + }, + }, + context, + ) + await configurePlugin(getReferencePlugin(plugins), 'build') + return async (code: string) => { + const reference = getCode( + await transformReferenceRoute( + getReferencePlugin(plugins), + code, + routeFile, + ), + )! + const virtual = getCode( + await transformReferenceRoute( + getCodeSplitterPlugin(plugins, virtualPluginName), + code, + `${routeFile}?tsr-split=${combined ? 'component---loader' : 'component'}`, + ), + )! + const shared = getCode( + await transformReferenceRoute( + getCodeSplitterPlugin( + plugins, + 'tanstack-router:code-splitter:compile-shared-file', + ), + code, + `${routeFile}?tsr-shared=1`, + ), + )! + return { reference, virtual, shared } + } + } + const separate = await setup(false) + const combined = await setup(true) + const first = await separate(source('firstState', 'first version')) + expect(first.reference).toContain('tsr-split=component') + const virtualImports = parseAst({ + code: first.virtual, + }).program.body.filter((statement) => t.isImportDeclaration(statement)) + expect(virtualImports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: expect.objectContaining({ + value: expect.stringContaining('tsr-shared'), + }), + specifiers: expect.arrayContaining([ + expect.objectContaining({ + type: 'ImportSpecifier', + imported: expect.objectContaining({ name: 'firstState' }), + local: expect.objectContaining({ name: 'firstState' }), + }), + ]), + }), + ]), + ) + expect(first.shared).toContain('first version') + + const second = await separate(source('secondState', 'second version')) + for (const output of Object.values(second)) { + expect(output).not.toContain('firstState') + } + expect(second.virtual).toContain('secondState') + expect(second.shared).toContain('second version') + const together = await combined(source('secondState', 'second version')) + expect(together.reference).not.toContain('tsr-shared') + expect(together.virtual).toContain('second version') + expect(together.virtual).not.toContain('tsr-shared') + expect(await separate(source('secondState', 'second version'))).toEqual( + second, + ) + }) + it.each([ { mode: 'production', production: true }, { mode: 'development with addHmr disabled', production: false }, diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts index 94780d140b2..c3ea245facf 100644 --- a/packages/start-plugin-core/tests/compiler.test.ts +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -515,6 +515,35 @@ describe('compiler handles external import transforms', () => { }) describe('server function provider module directives', () => { + test.each([ + 'export const Route = { component: Page }', + 'export function Route() { return Page() }', + 'export default function Route() { return Page() }', + ])( + 'removes browser-only dependencies of stripped exports: %s', + async (route) => { + const compiler = createFullCompiler('server') + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getMessage = createServerFn().handler(() => 'server message') + const browserState = { window } + function Page() { return browserState } + ${route} + `, + id: '/test/src/routes/message.tsx?tss-serverfn-split', + }) + + expect(result).not.toBeNull() + expect(result!.code).toContain('server message') + expect(result!.code).toContain('createServerRpc') + expect(result!.code).not.toContain('window') + expect(result!.code).not.toContain('browserState') + expect(result!.code).not.toContain('Page') + expect(result!.code).not.toContain('Route') + }, + ) + const code = ` import { createServerFn } from '@tanstack/react-start' @@ -1266,3 +1295,98 @@ describe('re-export chain resolution', () => { expect(updatedResult!.code).not.toContain('server-only-value') }) }) + +test('leaves unrelated SSR middleware chains to the bundler when a server function factory is present', async () => { + const compiler = new StartCompiler({ + ...getDefaultTestOptions('server'), + env: 'server', + mode: 'build', + lookupKinds: getLookupKindsForEnv('server'), + lookupConfigurations: getLookupConfigurationsForEnv('server', 'react'), + getKnownServerFns: () => ({}), + resolveId: async () => { + throw new Error( + 'SSR host has no package import conditions for this unrelated binding', + ) + }, + loadModule: async () => { + throw new Error('Unexpected compiler dependency load') + }, + }) + const code = `import { createMiddleware, createServerFn } from '@tanstack/react-start'; +const middleware = createMiddleware({ type: 'function' }).server(({ next }) => next({ context: { role: 'admin' } })); +export const factory = createServerFn().middleware([middleware]);` + await expect( + compiler.compile({ + id: '/test/auth-wrapper.ts', + code, + detectedKinds: detectKindsInCode(code, 'server'), + }), + ).resolves.toBeNull() +}) + +test.each([ + `(createServerOnlyFn)(() => 'server-value')`, + `(createIsomorphicFn)().server(() => 'server-value').client(() => 'client-value')`, + `(createIsomorphicFn().server(() => 'server-value')).client(() => 'client-value')`, +])( + 'compiles transparent parentheses around native call chains: %s', + async (expression) => { + const compiler = createFullCompiler('server') + const result = await compiler.compile({ + id: '/test/parenthesized.ts', + code: `import { createServerOnlyFn, createIsomorphicFn } from '@tanstack/react-start'; export const fn = ${expression}`, + }) + + expect(result).not.toBeNull() + const evaluate = new Function( + `${result!.code.replace(/^import .+;$/gm, '').replace(/\bexport (?=const)/g, '')}\nreturn fn`, + ) + expect(evaluate()()).toBe('server-value') + }, +) + +test('compiles a server function through a parenthesized namespace receiver', async () => { + const compiler = createFullCompiler('client') + const result = await compiler.compile({ + id: '/test/namespace-parenthesized.ts', + code: `import * as Start from '@tanstack/react-start'; export const fn = (Start).createServerFn().handler(() => 'private-server-body')`, + }) + expect(result).not.toBeNull() + expect(result!.code).not.toContain('private-server-body') + expect(result!.code).toContain('createClientRpc') +}) + +// The compiler currently mistakes the local parameter for the namespace import. +// Keep the intended behavior executable until binding resolution is corrected. +test.fails( + 'preserves a shadowed parenthesized namespace receiver', + async () => { + const compiler = createFullCompiler('client') + const code = `import * as Start from '@tanstack/react-start'; export function fn(Start) { return (Start).createServerFn().handler(() => 'local-runtime-body') }` + const result = await compiler.compile({ + id: '/test/shadowed-namespace.ts', + code, + }) + const output = result?.code ?? code + expect(output).toContain('local-runtime-body') + expect(output).not.toContain('createClientRpc') + }, +) + +test.each([ + `(Start).createServerOnlyFn(() => 'private-server-body')`, + `(Start).createIsomorphicFn().server(() => 'private-server-body').client(() => 'client-value')`, + `(Start).createMiddleware().server(() => 'private-server-body')`, +])( + 'removes server-only code through a parenthesized namespace: %s', + async (expression) => { + const compiler = createFullCompiler('client') + const result = await compiler.compile({ + id: '/test/namespace-builtins.ts', + code: `import * as Start from '@tanstack/react-start'; export const fn = ${expression}`, + }) + expect(result).not.toBeNull() + expect(result!.code).not.toContain('private-server-body') + }, +) diff --git a/packages/start-plugin-core/tests/hydrate-when-transform.test.ts b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts index 13e1d909bd1..442482bd86c 100644 --- a/packages/start-plugin-core/tests/hydrate-when-transform.test.ts +++ b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts @@ -117,6 +117,94 @@ function loadVirtualHydrateModule(options: { } describe('Hydrate compiler transform', () => { + test.each(['client', 'server'] as const)( + 'resolves spread props declared after their containing function in %s output', + (env) => { + const result = compileHydrate({ + code: `import { Hydrate } from '@tanstack/react-start' +export function Page() { return

child

} +const props = { when: true, fallback:

server-fallback

}`, + id, + root, + env, + }) + expect(result).not.toBeNull() + expect(result?.code).toContain('when: true') + if (env === 'client') { + expect(result?.code).not.toContain('.preload') + expect(result?.code).toContain('server-fallback') + } else { + expect(result?.code).not.toContain('server-fallback') + } + }, + ) + + test.each(['client', 'server'] as const)( + 'respects parenthesized split={false} in %s output', + (env) => { + const result = compileHydrate({ + code: `import { Hydrate } from '@tanstack/react-start'; export function Page() { return {() =>

child

}
}`, + id, + root, + env, + }) + expect(result).toBeNull() + }, + ) + + test.each(['{...({ fallback:

server-fallback

})}', '{...(props)}'])( + 'strips fallback from a parenthesized spread %s', + (spread) => { + const result = compileHydrate({ + code: `import { Hydrate } from '@tanstack/react-start'; ${spread.includes('props') ? 'const props = ({ fallback:

server-fallback

});' : ''} export function Page() { return

child

}`, + id, + root, + env: 'server', + }) + expect(result).not.toBeNull() + expect(result?.code).not.toContain('server-fallback') + }, + ) + + test('strips a statically computed fallback property from server output', () => { + const result = compileHydrate({ + code: `import { Hydrate } from '@tanstack/react-start'; export function Page() { return server-fallback

}}>

child

}`, + id, + root, + env: 'server', + }) + expect(result).not.toBeNull() + expect(result?.code).not.toContain('server-fallback') + }) + + test.each(['{...({ when: true })}', '{...(props)}'])( + 'avoids generating a preload callback for known props %s', + (spread) => { + const result = compileHydrate({ + code: `import { Hydrate } from '@tanstack/react-start'; const props = ({ when: true }); export function Page() { return

child

}`, + id, + root, + env: 'client', + }) + expect(result).not.toBeNull() + expect(result?.code).not.toContain('.preload') + }, + ) + + test.each([ + ['{(() =>

child

)}', /function-as-children/], + ['{(useThing)()}', /hooks/], + ])('rejects parenthesized unsafe extraction %s', (child, message) => { + expect(() => + compileHydrate({ + code: `import { Hydrate } from '@tanstack/react-start'; export function Page() { return ${child} }`, + id, + root, + env: 'client', + }), + ).toThrow(message) + }) + test('splits Hydrate children behind a lazy import', () => { const result = compileHydrate({ code: ` diff --git a/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts index 9e393a93c77..a027fac5057 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts +++ b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts @@ -1,4 +1,5 @@ import { readFile, readdir } from 'node:fs/promises' +import { traverse } from '@babel/core' import * as t from '@babel/types' import { generateFromAst, parseAst } from '@tanstack/router-utils' import path from 'pathe' @@ -148,6 +149,63 @@ describe('Hydrate compiler transform fixtures', async () => { }) }) + test('generated lazy names do not shadow existing global references', () => { + const code = `import { Hydrate } from '@tanstack/react-start'; export const before = _H0; export function Page() { return
}` + const compiled = compile({ + env: 'client', + code, + id: fixtureId('global.tsx'), + })! + const output = parseAst({ code: compiled.code }) + const globalReferences: Array = [] + traverse(output, { + ReferencedIdentifier(path) { + if (path.node.name === '_H0' && !path.scope.hasBinding('_H0', true)) { + globalReferences.push(path.node.name) + } + }, + }) + expect(globalReferences).toEqual(['_H0']) + }) + + test('retains captured local components and values across extraction', async () => { + const code = ` + import { Hydrate } from '@tanstack/react-start' + export function Page({ name }) { + const count = 1 + const Local = () => {name} + return {name} + } + ` + const id = fixtureId('captured.tsx') + const compiled = compile({ env: 'client', code, id })! + expect(compiled.code).toContain('Local={Local}') + expect(compiled.code).toContain('count={count}') + expect(compiled.code).toContain('name={name}') + expect(compiled.code).toContain('const count = 1') + expect(compiled.code).toContain('const Local') + const loaded = await loadVirtualHydrateModule({ + code, + id: virtualHydrateId(id, compiled.boundaries[0]!), + root: fixtureRoot, + }) + expect(loaded).toBeTruthy() + const output = parseAst({ code: loaded!.code }) + const capturedReferences = new Set() + traverse(output, { + Program(path) { + expect(path.scope.getBinding('Page')).toBeUndefined() + }, + ReferencedIdentifier(path) { + if (['Local', 'count', 'name'].includes(path.node.name)) { + capturedReferences.add(path.node.name) + expect(path.scope.hasBinding(path.node.name, true)).toBe(true) + } + }, + }) + expect([...capturedReferences].sort()).toEqual(['Local', 'count', 'name']) + }) + test('should extract virtual modules and keep nested ids stable', async () => { const filename = 'hydrateWhenNested.tsx' const code = await readFixture(filename) diff --git a/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts b/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts index dbb0830af7c..1e14f5fd801 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts @@ -4,6 +4,7 @@ import { isValidExportName, getMockExportNamesBySource, getNamedExports, + getImportSources, } from '../../src/import-protection/analysis' describe('isValidExportName', () => { @@ -245,3 +246,16 @@ describe('collectNamedExports', () => { expect(getNamedExports(code)).toEqual(['foo-bar']) }) }) + +test('discovers mock exports through parenthesized namespace access', () => { + const exports = getMockExportNamesBySource( + `import * as denied from 'denied'; (denied).read(); (denied)['write']()`, + ) + expect(exports.get('denied')).toEqual(['read', 'write']) +}) + +test('discovers parenthesized literal dynamic import sources', () => { + expect(getImportSources(`export const value = import(('denied'))`)).toEqual([ + 'denied', + ]) +}) diff --git a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts index 69642e890ab..8db2c2db6cf 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts @@ -53,7 +53,7 @@ describe('findPostCompileUsagePos', () => { test('ignores binding positions (variable declarator id)', () => { const p = pos( - `import { x } from 'denied';\nconst x = 1;\nconst y = x;`, + `import { x } from 'denied';\n{ const x = 1;\nconst y = x; }`, 'denied', ) expect(p).toBeUndefined() @@ -279,3 +279,38 @@ describe('findOriginalUnsafeUsagePos', () => { expect(p).toBeUndefined() }) }) + +test.each([ + `createServerFn().handler((() => denied()))`, + `(createServerFn)().handler(() => denied())`, +])( + 'preserves safe-boundary recognition through parentheses: %s', + (expression) => { + const code = `import { denied } from 'denied';\nexport const result = ${expression}` + expect(findOriginalUnsafeUsagePos(code, 'denied', 'client')).toBeUndefined() + }, +) + +test.each([ + `createIsomorphicFn().client((() => denied()))`, + `(createClientOnlyFn)((() => denied()))`, +])('preserves server safe boundaries through parentheses: %s', (expression) => { + const code = `import { denied } from 'denied';\nexport const result = ${expression}` + expect(findOriginalUnsafeUsagePos(code, 'denied', 'server')).toBeUndefined() +}) + +test('does not treat an immediately invoked function as a safe handler argument', () => { + const code = `import { denied } from 'denied';\nexport const result = createServerFn().handler((() => denied())())` + expect(findOriginalUnsafeUsagePos(code, 'denied', 'client')).toEqual({ + line: 2, + column0: code.split('\n')[1]!.indexOf('denied'), + }) +}) + +test('prefers parenthesized call usage over an earlier value reference', () => { + const code = `import { denied } from 'denied';\nconst value = denied;\n(denied)()` + expect(findPostCompileUsagePos(code, 'denied')).toEqual({ + line: 3, + column0: 1, + }) +})