From 216ec30d7059fa242e8caab39d46dc9bd9daa38b Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 19:41:42 +0200
Subject: [PATCH 0001/1425] test(router-plugin, start-plugin-core): cover
compiler output regressions
Add engine-agnostic regression tests for compiler output found while
reviewing the compiler migration (#8504). They only use the public
compiler entry points and validate emitted code with Vite's Oxc
transform/parser (TypeScript erased, parsed as a JS module with semantic
checks), so they run unchanged against any compiler implementation.
router-plugin:
- every emitted code-splitter module stays valid for re-exported imports,
partially exported destructuring and anonymous default exports
- self-/mutually-referencing side effects stay in the reference module
- namespace imports used by `import X = ns.X` and decorator placement
before `export` are preserved
- React Refresh hoisting keeps closures in scope, hoists the root route
component, and declares hoisted components before use
start-plugin-core:
- self-referencing user code survives client and server compilation
- nested and switch-case createServerFn keep provider modules valid
- aliased createClientOnlyFn re-exports are transformed in SSR
- Hydrate split modules: parent/fallback-only bindings are removed, text
children render decoded entities, `'use client'` stays first
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../code-splitter-output-validity.test.ts | 197 ++++++++++++++++++
.../react-refresh-route-components.test.ts | 141 +++++++++++++
.../router-plugin/tests/validate-module.ts | 33 +++
.../tests/aliased-env-only-fns.test.ts | 22 ++
.../tests/compile-start-module.ts | 55 +++++
.../compiler-preserves-user-code.test.ts | 59 ++++++
.../nested-createServerFn.test.ts | 94 +++++++++
.../tests/hydrate-split-modules.test.ts | 129 ++++++++++++
.../tests/validate-module.ts | 33 +++
9 files changed, 763 insertions(+)
create mode 100644 packages/router-plugin/tests/code-splitter-output-validity.test.ts
create mode 100644 packages/router-plugin/tests/react-refresh-route-components.test.ts
create mode 100644 packages/router-plugin/tests/validate-module.ts
create mode 100644 packages/start-plugin-core/tests/aliased-env-only-fns.test.ts
create mode 100644 packages/start-plugin-core/tests/compile-start-module.ts
create mode 100644 packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts
create mode 100644 packages/start-plugin-core/tests/createServerFn/nested-createServerFn.test.ts
create mode 100644 packages/start-plugin-core/tests/hydrate-split-modules.test.ts
create mode 100644 packages/start-plugin-core/tests/validate-module.ts
diff --git a/packages/router-plugin/tests/code-splitter-output-validity.test.ts b/packages/router-plugin/tests/code-splitter-output-validity.test.ts
new file mode 100644
index 00000000000..5993f999e07
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter-output-validity.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { getModuleErrors } from './validate-module'
+
+const filename = 'route.tsx'
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(code: string) {
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })
+ const modules: Record = {
+ reference: reference?.code ?? code,
+ }
+ for (const targets of defaultCodeSplitGroupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return modules
+}
+
+async function getErrorsByModule(modules: Record) {
+ const errors: Record> = {}
+ for (const [name, code] of Object.entries(modules)) {
+ errors[name] = await getModuleErrors(code)
+ }
+ return errors
+}
+
+function noErrors(modules: Record) {
+ return Object.fromEntries(Object.keys(modules).map((name) => [name, []]))
+}
+
+describe('code-splitter emits valid modules', () => {
+ it.each([
+ { name: 'as-is', exports: 'export { Foo }' },
+ { name: 'renamed', exports: 'export { Foo as Bar }' },
+ ])(
+ 'does not redeclare an imported binding that is re-exported $name',
+ async ({ exports }) => {
+ const modules = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import { Foo } from './foo'
+${exports}
+export const Route = createFileRoute('/')({ component: Page })
+function Page() {
+ return
+}
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+
+ it('does not redeclare an exported binding destructured next to a non-exported one', async () => {
+ const modules = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+const { a, b } = getStuff()
+export { a }
+export const Route = createFileRoute('/')({
+ component: () => {a}{b}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it.each([
+ { name: 'function', declaration: 'function () {\n return null\n}' },
+ { name: 'class', declaration: 'class {\n x = 1\n}' },
+ ])(
+ 'keeps an anonymous default-exported $name valid in split chunks',
+ async ({ declaration }) => {
+ const modules = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({ component: Page })
+function Page() {
+ return
+}
+export default ${declaration}
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+
+ it.each([
+ { name: 'function', declaration: 'function () {\n return null\n}' },
+ { name: 'class', declaration: 'class {\n x = 1\n}' },
+ ])(
+ 'keeps an anonymous default-exported $name valid when bindings are shared',
+ async ({ declaration }) => {
+ const modules = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+const cache = new Map()
+export const Route = createFileRoute('/')({
+ loader: () => cache.get('x'),
+ component: () => {cache.size}
,
+})
+export default ${declaration}
+`)
+ expect(Object.keys(modules)).toContain('shared')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+})
+
+describe('code-splitter preserves module semantics', () => {
+ it.each([
+ {
+ name: 'a store subscription that unsubscribes itself',
+ sideEffect: 'store.subscribe(',
+ setup: `import { store } from './store'
+const unsubscribe = store.subscribe(() => {
+ if (store.state.done) unsubscribe()
+})`,
+ },
+ {
+ name: 'an interval that clears itself',
+ sideEffect: 'setInterval(',
+ setup: `let ticks = 0
+const interval = setInterval(() => {
+ ticks++
+ if (ticks > 3) clearInterval(interval)
+}, 1000)`,
+ },
+ {
+ name: 'mutually-referencing declarations',
+ sideEffect: 'createPersister(',
+ setup: `import { createPersister } from './persist'
+const persister = createPersister({ onRestore: () => restore() })
+function restore() {
+ persister.restore()
+}`,
+ },
+ ])('keeps $name in the reference module', async ({ setup, sideEffect }) => {
+ const { reference } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+${setup}
+export const Route = createFileRoute('/')({ component: () =>
})
+`)
+ expect(reference).toContain(sideEffect)
+ expect(await getModuleErrors(reference!)).toEqual([])
+ })
+
+ it('keeps the namespace import that a TypeScript import alias refers to', () => {
+ const modules = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import * as lib from './lib'
+import Helper = lib.Helper
+export const Route = createFileRoute('/')({
+ component: () => {Helper.x}
,
+})
+`)
+ expect(modules['virtual component']).toMatch(
+ /import \* as lib from ['"]\.\/lib['"]/,
+ )
+ })
+
+ it('keeps decorators before `export` for legacy decorator transforms', () => {
+ // TypeScript `experimentalDecorators` and Babel `decorators-legacy` only
+ // accept decorators before the `export` keyword.
+ const { reference } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+@observable export class Store { @observable count = 0 }
+export const Route = createFileRoute('/')({ component: () =>
})
+`)
+ expect(reference).toMatch(/@observable\s+export class Store\b/)
+ })
+})
diff --git a/packages/router-plugin/tests/react-refresh-route-components.test.ts b/packages/router-plugin/tests/react-refresh-route-components.test.ts
new file mode 100644
index 00000000000..1909a56c81e
--- /dev/null
+++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts
@@ -0,0 +1,141 @@
+import { parseSync } from 'vite'
+import { describe, expect, it } from 'vitest'
+import { compileCodeSplitReferenceRoute } from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins'
+import type { ESTree } from 'vite'
+
+function compileWithReactRefresh(code: string) {
+ const result = compileCodeSplitReferenceRoute({
+ code,
+ filename: 'route.tsx',
+ id: 'route.tsx',
+ addHmr: true,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ compilerPlugins: getFrameworkHmrCompilerPlugins({
+ targetFramework: 'react',
+ }),
+ })
+ expect(result).toBeTruthy()
+ return result!.code
+}
+
+function parseModule(code: string) {
+ const { program, errors } = parseSync('route.tsx', code, {
+ sourceType: 'module',
+ })
+ expect(errors).toEqual([])
+ return program
+}
+
+function getTopLevelDeclarators(program: ESTree.Program) {
+ const declarators: Array = []
+ for (const statement of program.body) {
+ const declaration =
+ statement.type === 'ExportNamedDeclaration'
+ ? statement.declaration
+ : statement
+ if (declaration?.type === 'VariableDeclaration') {
+ declarators.push(...declaration.declarations)
+ }
+ }
+ return declarators
+}
+
+function getDeclaratorName(declarator: ESTree.VariableDeclarator) {
+ return declarator.id.type === 'Identifier' ? declarator.id.name : undefined
+}
+
+/** Returns the value of `option` in `const Route = createXRoute({ ... })`. */
+function getRouteOption(program: ESTree.Program, option: string) {
+ const route = getTopLevelDeclarators(program).find(
+ (declarator) => getDeclaratorName(declarator) === 'Route',
+ )
+ const options =
+ route?.init?.type === 'CallExpression' ? route.init.arguments[0] : null
+ if (options?.type !== 'ObjectExpression') {
+ throw new Error('expected `Route` to be created with an options object')
+ }
+ const property = options.properties.find(
+ (candidate) =>
+ candidate.type === 'Property' &&
+ candidate.key.type === 'Identifier' &&
+ candidate.key.name === option,
+ )
+ if (property?.type !== 'Property') {
+ throw new Error(`expected the \`${option}\` route option`)
+ }
+ return { route, value: property.value }
+}
+
+describe('React Refresh route component hoisting', () => {
+ it('does not hoist a component out of the function scope it closes over', () => {
+ const code = compileWithReactRefresh(`
+import { createRootRoute, createRoute } from '@tanstack/react-router'
+export function makeChild(label: string) {
+ return createRoute({
+ getParentRoute: () => Route,
+ path: label,
+ component: () => {label}
,
+ })
+}
+export const Route = createRootRoute({ component: () =>
})
+`)
+ // `label` only exists inside `makeChild`; any other top-level statement
+ // that mentions it reads an undefined global at render time.
+ const statementsUsingLabel = parseModule(code)
+ .body.map((statement) => code.slice(statement.start, statement.end))
+ .filter(
+ (statement) =>
+ /\blabel\b/.test(statement) &&
+ !statement.includes('function makeChild('),
+ )
+ expect(statementsUsingLabel).toEqual([])
+ })
+
+ it('hoists the inline component of the root route next to a nested route factory', () => {
+ const code = compileWithReactRefresh(`
+import { createRootRoute, createRoute } from '@tanstack/react-router'
+export function makeChild(label: string) {
+ return createRoute({
+ getParentRoute: () => Route,
+ path: label,
+ component: () => {label}
,
+ })
+}
+export const Route = createRootRoute({ component: () =>
})
+`)
+ // React Refresh can only register components bound to top-level names.
+ const program = parseModule(code)
+ const { value } = getRouteOption(program, 'component')
+ expect(value.type).toBe('Identifier')
+ const name = (value as ESTree.IdentifierReference).name
+ expect(getTopLevelDeclarators(program).map(getDeclaratorName)).toContain(
+ name,
+ )
+ })
+
+ it('declares hoisted components before the route that uses them', () => {
+ const code = compileWithReactRefresh(`
+import { createRootRoute } from '@tanstack/react-router'
+export const page = () =>
, Route = createRootRoute({
+ component: page,
+ pendingComponent: () =>
,
+})
+`)
+ const program = parseModule(code)
+ const { route, value } = getRouteOption(program, 'pendingComponent')
+ if (value.type !== 'Identifier') {
+ // Not hoisted: there is no declaration to order.
+ return
+ }
+ const declaration = getTopLevelDeclarators(program).find(
+ (declarator) => getDeclaratorName(declarator) === value.name,
+ )
+ // A `const` used before its declaration throws a TDZ ReferenceError when
+ // the route is created.
+ expect(declaration).toBeDefined()
+ expect(declaration!.start).toBeLessThan(route!.start)
+ })
+})
diff --git a/packages/router-plugin/tests/validate-module.ts b/packages/router-plugin/tests/validate-module.ts
new file mode 100644
index 00000000000..448c3b33cc6
--- /dev/null
+++ b/packages/router-plugin/tests/validate-module.ts
@@ -0,0 +1,33 @@
+import { stripVTControlCharacters } from 'node:util'
+import { parseSync, transformWithOxc } from 'vite'
+
+/**
+ * Validates generated code the way a browser or bundler sees it, without using
+ * the compiler's own parser: erase TypeScript (keeping every value import) and
+ * parse the result as a JavaScript module with semantic checks. This rejects
+ * syntax errors, nameless function/class declarations, duplicate bindings
+ * (including an import redeclared by a local declaration) and exports of
+ * undeclared bindings.
+ *
+ * @returns the error messages; an empty array means the module is valid.
+ */
+export async function getModuleErrors(
+ code: string,
+ filename = 'module.tsx',
+): Promise> {
+ let javascript: string
+ try {
+ const result = await transformWithOxc(code, filename, {
+ jsx: 'preserve',
+ typescript: { onlyRemoveTypeImports: true },
+ })
+ javascript = result.code
+ } catch (error) {
+ return [stripVTControlCharacters((error as Error).message)]
+ }
+ const { errors } = parseSync('module.jsx', javascript, {
+ sourceType: 'module',
+ showSemanticErrors: true,
+ })
+ return errors.map((error) => error.message)
+}
diff --git a/packages/start-plugin-core/tests/aliased-env-only-fns.test.ts b/packages/start-plugin-core/tests/aliased-env-only-fns.test.ts
new file mode 100644
index 00000000000..ca474e7c46a
--- /dev/null
+++ b/packages/start-plugin-core/tests/aliased-env-only-fns.test.ts
@@ -0,0 +1,22 @@
+import { expect, test } from 'vitest'
+import { compileStartModule } from './compile-start-module'
+
+test('transforms an aliased re-export of createClientOnlyFn next to createServerOnlyFn on the server', async () => {
+ const output = await compileStartModule({
+ env: 'server',
+ files: {
+ '/test/src/env.ts': `export { createClientOnlyFn as clientOnly } from '@tanstack/react-start'`,
+ },
+ code: `import { createServerOnlyFn } from '@tanstack/react-start'
+import { clientOnly } from './env'
+import { secret } from './secret.server'
+import { readWindow } from './browser'
+export const serverValue = createServerOnlyFn(() => secret())
+export const clientValue = clientOnly(() => readWindow())`,
+ })
+ // The client-only implementation (and its browser import) must not reach SSR.
+ expect(output).not.toContain('readWindow')
+ expect(output).toContain(
+ 'createClientOnlyFn() functions can only be called on the client!',
+ )
+})
diff --git a/packages/start-plugin-core/tests/compile-start-module.ts b/packages/start-plugin-core/tests/compile-start-module.ts
new file mode 100644
index 00000000000..dd490e97057
--- /dev/null
+++ b/packages/start-plugin-core/tests/compile-start-module.ts
@@ -0,0 +1,55 @@
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import type { StartCompilerPlugin } from '../src/types'
+
+/**
+ * Compiles one module through `StartCompiler` the way the bundler plugins do:
+ * React lookups for the environment, kinds pre-detected from the source, and
+ * `@tanstack/*` imports resolved as packages.
+ */
+export async function compileStartModule(options: {
+ env: 'client' | 'server'
+ code: string
+ /** Compile the server-function provider module (`?tss-serverfn-split`). */
+ provider?: boolean
+ /** Extra project modules by absolute id; `./name` resolves to `/test/src/name.ts`. */
+ files?: Record
+ compilerPlugins?: Array
+}) {
+ const { env, files = {} } = options
+ const compiler: StartCompiler = new StartCompiler({
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ getKnownServerFns: () => ({}),
+ loadModule: async (id) => {
+ const code = files[id]
+ if (code !== undefined) {
+ compiler.ingestModule({ code, id })
+ }
+ },
+ resolveId: async (id) => {
+ if (id.startsWith('@tanstack/')) {
+ return id
+ }
+ const file = id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id
+ return file in files ? file : null
+ },
+ compilerPlugins: options.compilerPlugins,
+ })
+ const result = await compiler.compile({
+ code: options.code,
+ id: `/test/src/module.tsx${options.provider ? '?tss-serverfn-split' : ''}`,
+ detectedKinds: detectKindsInCode(options.code, env),
+ })
+ return result?.code ?? null
+}
diff --git a/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts b/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts
new file mode 100644
index 00000000000..c9fa41b297c
--- /dev/null
+++ b/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from 'vitest'
+import { compileStartModule } from './compile-start-module'
+
+// The compiler removes code that only served a transformed call. Declarations
+// that merely reference themselves (or each other) are still live user code:
+// their initializers run side effects and must survive.
+const selfReferencingCode = [
+ {
+ name: 'a store subscription that unsubscribes itself',
+ sideEffect: 'store.subscribe(',
+ code: `import { createIsomorphicFn } from '@tanstack/react-start'
+import { store } from './store'
+const unsubscribe = store.subscribe(() => {
+ if (store.state.done) unsubscribe()
+})
+export const value = createIsomorphicFn().server(() => 1).client(() => 2)`,
+ },
+ {
+ name: 'an interval that clears itself',
+ sideEffect: 'setInterval(',
+ code: `import { createServerFn } from '@tanstack/react-start'
+let ticks = 0
+const interval = setInterval(() => {
+ ticks++
+ if (ticks > 3) clearInterval(interval)
+}, 1000)
+export const fn = createServerFn().handler(async () => 1)`,
+ },
+ {
+ name: 'mutually-referencing declarations',
+ sideEffect: 'createPersister(',
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { createPersister } from './persist'
+const persister = createPersister({ onRestore: () => restore() })
+function restore() {
+ persister.restore()
+}
+export const fn = createServerFn().handler(async () => 1)`,
+ },
+ {
+ name: 'a self-referencing local inside an exported function',
+ sideEffect: 'new MutationObserver(',
+ code: `import { createServerFn } from '@tanstack/react-start'
+export function useObserver() {
+ const observer = new MutationObserver(() => observer.disconnect())
+ return 1
+}
+export const fn = createServerFn().handler(async () => 1)`,
+ },
+]
+
+describe('Start compiler keeps self-referencing user code', () => {
+ describe.each(['client', 'server'] as const)('%s', (env) => {
+ test.each(selfReferencingCode)('$name', async ({ code, sideEffect }) => {
+ const output = await compileStartModule({ env, code })
+ expect(output).toContain(sideEffect)
+ })
+ })
+})
diff --git a/packages/start-plugin-core/tests/createServerFn/nested-createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/nested-createServerFn.test.ts
new file mode 100644
index 00000000000..70c37247d84
--- /dev/null
+++ b/packages/start-plugin-core/tests/createServerFn/nested-createServerFn.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from 'vitest'
+import { compileStartModule } from '../compile-start-module'
+import { getModuleErrors } from '../validate-module'
+
+/** Compiles the server-function provider module and checks that it is valid. */
+async function compileProvider(code: string) {
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code,
+ })
+ return {
+ provider,
+ errors: provider === null ? [] : await getModuleErrors(provider),
+ }
+}
+
+describe('createServerFn declared outside the module top level', () => {
+ test('keeps the provider module valid for a top-level server fn', async () => {
+ const { provider, errors } = await compileProvider(`
+import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export const top = createServerFn().handler(async () => db.top())
+export function make() {
+ const inner = createServerFn().handler(async () => db.inner())
+ return inner
+}`)
+ expect(errors).toEqual([])
+ expect(provider).toContain('db.top()')
+ })
+
+ test('does not emit an invalid provider for an only-nested server fn', async () => {
+ const { errors } = await compileProvider(`
+import { createServerFn } from '@tanstack/react-start'
+export function make() {
+ const fn = createServerFn().handler(async () => 1)
+ return fn
+}`)
+ expect(errors).toEqual([])
+ })
+
+ test.each([
+ {
+ name: 'inside a function',
+ code: `import { createServerFn } from '@tanstack/react-start'
+export function pick(kind: string) {
+ switch (kind) {
+ case 'a':
+ const fn = createServerFn().handler(async () => 1)
+ return fn
+ }
+}`,
+ },
+ {
+ name: 'at the module top level',
+ code: `import { createServerFn } from '@tanstack/react-start'
+switch (import.meta.env.MODE) {
+ case 'a':
+ const fn = createServerFn().handler(async () => 1)
+ console.log(fn)
+}`,
+ },
+ {
+ name: 'next to a top-level server fn',
+ code: `import { createServerFn } from '@tanstack/react-start'
+export const top = createServerFn().handler(async () => 1)
+export function pick(kind: string) {
+ switch (kind) {
+ case 'a':
+ const fn = createServerFn().handler(async () => 2)
+ return fn
+ }
+}`,
+ },
+ ])(
+ 'compiles a server fn declared in a switch case $name',
+ async ({ code }) => {
+ const errors: Record> = {}
+ for (const [name, options] of [
+ ['client', { env: 'client' }],
+ ['server caller', { env: 'server' }],
+ ['server provider', { env: 'server', provider: true }],
+ ] as const) {
+ const output = await compileStartModule({ ...options, code })
+ errors[name] = output === null ? [] : await getModuleErrors(output)
+ }
+ expect(errors).toEqual({
+ client: [],
+ 'server caller': [],
+ 'server provider': [],
+ })
+ },
+ )
+})
diff --git a/packages/start-plugin-core/tests/hydrate-split-modules.test.ts b/packages/start-plugin-core/tests/hydrate-split-modules.test.ts
new file mode 100644
index 00000000000..1b72bd226bc
--- /dev/null
+++ b/packages/start-plugin-core/tests/hydrate-split-modules.test.ts
@@ -0,0 +1,129 @@
+import { transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform'
+import { compileStartModule } from './compile-start-module'
+
+/** Compiles a module with `` and loads the split chunks it imports. */
+async function compileHydrate(env: 'client' | 'server', code: string) {
+ const plugin = createHydrateCompilerPlugin()
+ const parent = await compileStartModule({
+ env,
+ code,
+ compilerPlugins: [plugin],
+ })
+ if (parent === null) {
+ throw new Error('expected the module to be transformed')
+ }
+ const chunks = [...parent.matchAll(/import\((["'])(.+?)\1\)/g)].map(
+ ([, , id]) => {
+ const chunk = plugin.loadVirtualModule?.({
+ id: id!,
+ root: '/test',
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ })
+ if (!chunk) {
+ throw new Error(`expected virtual module ${id} to load`)
+ }
+ return chunk.code
+ },
+ )
+ return { parent, chunks }
+}
+
+/**
+ * Compiles a chunk's JSX like a bundler would and returns the text its
+ * components render, so JSX text and string literals are compared by meaning.
+ */
+async function renderChunkText(chunk: string) {
+ const { code } = await transformWithOxc(chunk, 'chunk.tsx', {
+ jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' },
+ })
+ const runtime = `const Fragment = null
+const h = (type, props, ...children) => children.flat().join('')
+`
+ const module: Record = await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(runtime + code)}`
+ )
+ return Object.values(module)
+ .map((value) => (typeof value === 'function' ? value() : ''))
+ .join('')
+}
+
+const widgetPage = `
+import { Hydrate } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+import { Chart, FallbackPane } from './widgets'
+import { formatValue } from './format'
+
+const chartTitle = formatValue('Revenue')
+
+export function Page() {
+ return (
+
+
+ )
+}
+`
+
+describe('Hydrate split modules', () => {
+ test('client: bindings used only by the split children leave the parent module', async () => {
+ const { parent, chunks } = await compileHydrate('client', widgetPage)
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toContain('chartTitle')
+ expect(parent).not.toContain('./format')
+ expect(parent).not.toContain('chartTitle')
+ expect(parent).not.toMatch(/\bChart\b/)
+ })
+
+ test('server: bindings used only by the stripped fallback are removed', async () => {
+ const { parent } = await compileHydrate('server', widgetPage)
+ expect(parent).not.toContain('FallbackPane')
+ })
+
+ test.each([
+ { name: 'named entities', text: 'Fish & Chips ©' },
+ { name: 'decimal entities', text: 'Fish & Chips ©' },
+ { name: 'hexadecimal entities', text: 'Fish & Chips ©' },
+ {
+ name: 'multi-line text',
+ text: '\n Fish &\n Chips ©\n ',
+ },
+ ])(
+ 'client: a text-only split child renders decoded $name',
+ async ({ text }) => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return ${text}
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ // Only whitespace collapsing is left out: the entities must be decoded
+ // like the server-rendered JSX text.
+ const rendered = await renderChunkText(chunks[0]!)
+ expect(rendered.replace(/\s+/g, ' ').trim()).toBe('Fish & Chips ©')
+ },
+ )
+
+ test("client: 'use client' stays first in the parent module and the split chunk", async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `'use client'
+import { Hydrate } from '@tanstack/react-start'
+import { Chart } from './chart'
+export function Page() {
+ return
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(parent.trimStart()).toMatch(/^['"]use client['"]/)
+ expect(chunks[0]!.trimStart()).toMatch(/^['"]use client['"]/)
+ })
+})
diff --git a/packages/start-plugin-core/tests/validate-module.ts b/packages/start-plugin-core/tests/validate-module.ts
new file mode 100644
index 00000000000..448c3b33cc6
--- /dev/null
+++ b/packages/start-plugin-core/tests/validate-module.ts
@@ -0,0 +1,33 @@
+import { stripVTControlCharacters } from 'node:util'
+import { parseSync, transformWithOxc } from 'vite'
+
+/**
+ * Validates generated code the way a browser or bundler sees it, without using
+ * the compiler's own parser: erase TypeScript (keeping every value import) and
+ * parse the result as a JavaScript module with semantic checks. This rejects
+ * syntax errors, nameless function/class declarations, duplicate bindings
+ * (including an import redeclared by a local declaration) and exports of
+ * undeclared bindings.
+ *
+ * @returns the error messages; an empty array means the module is valid.
+ */
+export async function getModuleErrors(
+ code: string,
+ filename = 'module.tsx',
+): Promise> {
+ let javascript: string
+ try {
+ const result = await transformWithOxc(code, filename, {
+ jsx: 'preserve',
+ typescript: { onlyRemoveTypeImports: true },
+ })
+ javascript = result.code
+ } catch (error) {
+ return [stripVTControlCharacters((error as Error).message)]
+ }
+ const { errors } = parseSync('module.jsx', javascript, {
+ sourceType: 'module',
+ showSemanticErrors: true,
+ })
+ return errors.map((error) => error.message)
+}
From ed258f924b68368de8a9de9b7d284f4076d24900 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 20:07:27 +0200
Subject: [PATCH 0002/1425] test: require hoisting and declared bindings in
compiler regression tests
- The React Refresh ordering test now requires the inline pendingComponent
to be hoisted instead of passing when it stays inline, since main hoists it
and React Refresh needs a top-level binding.
- The self-referencing side-effect tests also assert that each binding the
callbacks use is still declared. The module validator does not report
references to undeclared names. The Start variant now validates the module.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../code-splitter-output-validity.test.ts | 23 ++++++++++++++-----
.../react-refresh-route-components.test.ts | 9 ++++----
.../router-plugin/tests/validate-module.ts | 5 ++++
.../compiler-preserves-user-code.test.ts | 22 ++++++++++++++----
.../tests/validate-module.ts | 5 ++++
5 files changed, 49 insertions(+), 15 deletions(-)
diff --git a/packages/router-plugin/tests/code-splitter-output-validity.test.ts b/packages/router-plugin/tests/code-splitter-output-validity.test.ts
index 5993f999e07..e0cd3f962d0 100644
--- a/packages/router-plugin/tests/code-splitter-output-validity.test.ts
+++ b/packages/router-plugin/tests/code-splitter-output-validity.test.ts
@@ -6,7 +6,7 @@ import {
computeSharedBindings,
} from '../src/core/code-splitter/compilers'
import { defaultCodeSplitGroupings } from '../src/core/constants'
-import { getModuleErrors } from './validate-module'
+import { declarationOf, getModuleErrors } from './validate-module'
const filename = 'route.tsx'
@@ -137,6 +137,7 @@ describe('code-splitter preserves module semantics', () => {
{
name: 'a store subscription that unsubscribes itself',
sideEffect: 'store.subscribe(',
+ declared: ['unsubscribe'],
setup: `import { store } from './store'
const unsubscribe = store.subscribe(() => {
if (store.state.done) unsubscribe()
@@ -145,6 +146,7 @@ const unsubscribe = store.subscribe(() => {
{
name: 'an interval that clears itself',
sideEffect: 'setInterval(',
+ declared: ['ticks', 'interval'],
setup: `let ticks = 0
const interval = setInterval(() => {
ticks++
@@ -154,21 +156,30 @@ const interval = setInterval(() => {
{
name: 'mutually-referencing declarations',
sideEffect: 'createPersister(',
+ declared: ['persister', 'restore'],
setup: `import { createPersister } from './persist'
const persister = createPersister({ onRestore: () => restore() })
function restore() {
persister.restore()
}`,
},
- ])('keeps $name in the reference module', async ({ setup, sideEffect }) => {
- const { reference } = compileRouteModules(`
+ ])(
+ 'keeps $name in the reference module',
+ async ({ setup, sideEffect, declared }) => {
+ const { reference } = compileRouteModules(`
import { createFileRoute } from '@tanstack/react-router'
${setup}
export const Route = createFileRoute('/')({ component: () =>
})
`)
- expect(reference).toContain(sideEffect)
- expect(await getModuleErrors(reference!)).toEqual([])
- })
+ expect(reference).toContain(sideEffect)
+ // The side effects' callbacks reference these bindings, and the module
+ // validator does not report references to undeclared names.
+ for (const name of declared) {
+ expect(reference).toMatch(declarationOf(name))
+ }
+ expect(await getModuleErrors(reference!)).toEqual([])
+ },
+ )
it('keeps the namespace import that a TypeScript import alias refers to', () => {
const modules = compileRouteModules(`
diff --git a/packages/router-plugin/tests/react-refresh-route-components.test.ts b/packages/router-plugin/tests/react-refresh-route-components.test.ts
index 1909a56c81e..a1ed30da0bc 100644
--- a/packages/router-plugin/tests/react-refresh-route-components.test.ts
+++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts
@@ -126,12 +126,11 @@ export const page = () =>
, Route = createRootRoute({
`)
const program = parseModule(code)
const { route, value } = getRouteOption(program, 'pendingComponent')
- if (value.type !== 'Identifier') {
- // Not hoisted: there is no declaration to order.
- return
- }
+ // React Refresh needs the inline component hoisted to a top-level binding.
+ expect(value.type).toBe('Identifier')
+ const name = (value as ESTree.IdentifierReference).name
const declaration = getTopLevelDeclarators(program).find(
- (declarator) => getDeclaratorName(declarator) === value.name,
+ (declarator) => getDeclaratorName(declarator) === name,
)
// A `const` used before its declaration throws a TDZ ReferenceError when
// the route is created.
diff --git a/packages/router-plugin/tests/validate-module.ts b/packages/router-plugin/tests/validate-module.ts
index 448c3b33cc6..4e0884a4368 100644
--- a/packages/router-plugin/tests/validate-module.ts
+++ b/packages/router-plugin/tests/validate-module.ts
@@ -31,3 +31,8 @@ export async function getModuleErrors(
})
return errors.map((error) => error.message)
}
+
+/** Matches a declaration of `name` in generated code, however it is printed. */
+export function declarationOf(name: string): RegExp {
+ return new RegExp(String.raw`\b(?:const|let|var|function|class)\s+${name}\b`)
+}
diff --git a/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts b/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts
index c9fa41b297c..9894c95938d 100644
--- a/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts
+++ b/packages/start-plugin-core/tests/compiler-preserves-user-code.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest'
import { compileStartModule } from './compile-start-module'
+import { declarationOf, getModuleErrors } from './validate-module'
// The compiler removes code that only served a transformed call. Declarations
// that merely reference themselves (or each other) are still live user code:
@@ -8,6 +9,7 @@ const selfReferencingCode = [
{
name: 'a store subscription that unsubscribes itself',
sideEffect: 'store.subscribe(',
+ declared: ['unsubscribe'],
code: `import { createIsomorphicFn } from '@tanstack/react-start'
import { store } from './store'
const unsubscribe = store.subscribe(() => {
@@ -18,6 +20,7 @@ export const value = createIsomorphicFn().server(() => 1).client(() => 2)`,
{
name: 'an interval that clears itself',
sideEffect: 'setInterval(',
+ declared: ['ticks', 'interval'],
code: `import { createServerFn } from '@tanstack/react-start'
let ticks = 0
const interval = setInterval(() => {
@@ -29,6 +32,7 @@ export const fn = createServerFn().handler(async () => 1)`,
{
name: 'mutually-referencing declarations',
sideEffect: 'createPersister(',
+ declared: ['persister', 'restore'],
code: `import { createServerFn } from '@tanstack/react-start'
import { createPersister } from './persist'
const persister = createPersister({ onRestore: () => restore() })
@@ -40,6 +44,7 @@ export const fn = createServerFn().handler(async () => 1)`,
{
name: 'a self-referencing local inside an exported function',
sideEffect: 'new MutationObserver(',
+ declared: ['observer'],
code: `import { createServerFn } from '@tanstack/react-start'
export function useObserver() {
const observer = new MutationObserver(() => observer.disconnect())
@@ -51,9 +56,18 @@ export const fn = createServerFn().handler(async () => 1)`,
describe('Start compiler keeps self-referencing user code', () => {
describe.each(['client', 'server'] as const)('%s', (env) => {
- test.each(selfReferencingCode)('$name', async ({ code, sideEffect }) => {
- const output = await compileStartModule({ env, code })
- expect(output).toContain(sideEffect)
- })
+ test.each(selfReferencingCode)(
+ '$name',
+ async ({ code, sideEffect, declared }) => {
+ const output = await compileStartModule({ env, code })
+ expect(output).toContain(sideEffect)
+ // The side effects' callbacks reference these bindings, and the module
+ // validator does not report references to undeclared names.
+ for (const name of declared) {
+ expect(output).toMatch(declarationOf(name))
+ }
+ expect(await getModuleErrors(output!)).toEqual([])
+ },
+ )
})
})
diff --git a/packages/start-plugin-core/tests/validate-module.ts b/packages/start-plugin-core/tests/validate-module.ts
index 448c3b33cc6..4e0884a4368 100644
--- a/packages/start-plugin-core/tests/validate-module.ts
+++ b/packages/start-plugin-core/tests/validate-module.ts
@@ -31,3 +31,8 @@ export async function getModuleErrors(
})
return errors.map((error) => error.message)
}
+
+/** Matches a declaration of `name` in generated code, however it is printed. */
+export function declarationOf(name: string): RegExp {
+ return new RegExp(String.raw`\b(?:const|let|var|function|class)\s+${name}\b`)
+}
From a1a870eaff139579376de6a95fdcb790324fa203 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 20:48:44 +0200
Subject: [PATCH 0003/1425] test(router-plugin, start-plugin-core,
router-generator, react-start-rsc): cover compiler edge cases
Engine-agnostic tests through public compiler entry points, passing on
main. They cover:
- code-splitter module state shared with exported functions, `var`
declared inside top-level statements, long method chains, duplicate
split keys, split options that read `Route`, route options declared
in a variable, re-exports, type-only syntax, private class members,
and namespace JSX tags
- the code-splitter plugin pipeline: analysis cache keys and eviction,
plugin-level `splitBehavior`, config `deleteNodes`, and the
non-split HMR plugin for Solid and Vue
- the route generator transform with parenthesized call parts, and
byte-exact edits after a BOM or astral characters
- the Start compiler: dev-mode server function ids, provider
directives, handler name versioning, provider export removal,
cross-module factory resolution, invalidation, and diagnostic
locations
- Hydrate children with block-bodied callbacks, self-closing
boundaries, captures, split attributes, and the virtual module cache
- import-protection exports, member-chain boundaries, source-mapped
locations, and executed denied-import rewrites
- RSC CSS transform import shapes and argument unwrapping
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tests/rscCssTransform-edge-cases.test.tsx | 117 +++++
.../tests/transform-parenthesized.test.ts | 69 +++
.../tests/transform-preservation.test.ts | 133 ++++++
.../tests/code-splitter-edge-cases.test.ts | 420 ++++++++++++++++++
.../code-splitter-plugin-pipeline.test.ts | 253 +++++++++++
.../code-splitter-route-references.test.ts | 173 ++++++++
.../tests/hydrate-split-edge-cases.test.ts | 379 ++++++++++++++++
.../tests/importProtection/edge-cases.test.ts | 254 +++++++++++
.../tests/start-compiler-diagnostics.test.ts | 52 +++
.../tests/start-compiler-edge-cases.test.ts | 358 +++++++++++++++
10 files changed, 2208 insertions(+)
create mode 100644 packages/react-start-rsc/tests/rscCssTransform-edge-cases.test.tsx
create mode 100644 packages/router-generator/tests/transform-parenthesized.test.ts
create mode 100644 packages/router-generator/tests/transform-preservation.test.ts
create mode 100644 packages/router-plugin/tests/code-splitter-edge-cases.test.ts
create mode 100644 packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
create mode 100644 packages/router-plugin/tests/code-splitter-route-references.test.ts
create mode 100644 packages/start-plugin-core/tests/hydrate-split-edge-cases.test.ts
create mode 100644 packages/start-plugin-core/tests/importProtection/edge-cases.test.ts
create mode 100644 packages/start-plugin-core/tests/start-compiler-diagnostics.test.ts
create mode 100644 packages/start-plugin-core/tests/start-compiler-edge-cases.test.ts
diff --git a/packages/react-start-rsc/tests/rscCssTransform-edge-cases.test.tsx b/packages/react-start-rsc/tests/rscCssTransform-edge-cases.test.tsx
new file mode 100644
index 00000000000..ef99c54a2c0
--- /dev/null
+++ b/packages/react-start-rsc/tests/rscCssTransform-edge-cases.test.tsx
@@ -0,0 +1,117 @@
+import { parseSync, transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../../start-plugin-core/src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../../start-plugin-core/src/start-compiler/config'
+import { createRscCssCompilerTransforms } from '../src/plugin/rscCssTransform'
+
+async function compileWithRscCssTransform(code: string) {
+ const compilerTransforms = createRscCssCompilerTransforms({
+ loadCssExpression: 'import.meta.viteRsc.loadCss()',
+ })
+ const compiler = new StartCompiler({
+ env: 'server',
+ envName: 'rsc',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'rsc',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv('server', { compilerTransforms }),
+ lookupConfigurations: getLookupConfigurationsForEnv('server', 'react', {
+ compilerTransforms,
+ }),
+ compilerTransforms,
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (id) => id,
+ })
+ const result = await compiler.compile({
+ id: '/test/src/route.tsx',
+ code,
+ detectedKinds: detectKindsInCode(code, 'server', { compilerTransforms }),
+ })
+ return result?.code ?? null
+}
+
+/** Erase TypeScript and parse the output as a JavaScript module. */
+async function getModuleErrors(code: string) {
+ const { code: javascript } = await transformWithOxc(code, 'module.tsx', {
+ jsx: 'preserve',
+ typescript: { onlyRemoveTypeImports: true },
+ })
+ return parseSync('module.jsx', javascript, {
+ sourceType: 'module',
+ showSemanticErrors: true,
+ }).errors.map((error) => error.message)
+}
+
+const optionsCount = (code: string) =>
+ code.match(/__tanstackStartRscCss:\s*import\.meta\.viteRsc\.loadCss\(\)/g)
+ ?.length ?? 0
+const fragmentCount = (code: string) =>
+ code.match(/<>\{import\.meta\.viteRsc\.loadCss\(\)\}/g)?.length ?? 0
+
+describe('RSC CSS compiler transforms: import and argument shapes', () => {
+ test('aliased, namespace and @tanstack/react-start-rsc imports are rewritten', async () => {
+ const code = await compileWithRscCssTransform(`
+ import { renderServerComponent as render, renderToReadableStream as toStream } from '@tanstack/react-start/rsc'
+ import * as RSC from '@tanstack/react-start/rsc'
+ import { createCompositeComponent } from '@tanstack/react-start-rsc'
+
+ export const a = render( )
+ export const b = RSC.renderServerComponent( )
+ export const c = createCompositeComponent(() => ({ Card: }))
+ export const d = toStream( )
+ export const e = RSC.renderToReadableStream(<> >)
+ `)
+ expect(code).not.toBeNull()
+ expect(await getModuleErrors(code!)).toEqual([])
+ expect(optionsCount(code!)).toBe(3)
+ expect(fragmentCount(code!)).toBe(2)
+ })
+
+ test('renderToReadableStream unwraps transparent wrappers around JSX only', async () => {
+ const code = await compileWithRscCssTransform(`
+ import { renderToReadableStream } from '@tanstack/react-start/rsc'
+
+ const element =
+ export const wrapped = [
+ renderToReadableStream(( )),
+ renderToReadableStream( as any),
+ renderToReadableStream( !),
+ renderToReadableStream((<> >) satisfies unknown),
+ ]
+ export const untouched = [
+ renderToReadableStream(element),
+ renderToReadableStream(flag ? : ),
+ renderToReadableStream(...args),
+ ]
+ `)
+ expect(code).not.toBeNull()
+ expect(await getModuleErrors(code!)).toEqual([])
+ expect(fragmentCount(code!)).toBe(4)
+ expect(code).toContain('renderToReadableStream(element)')
+ expect(code).toContain('renderToReadableStream(...args)')
+ expect(code).not.toMatch(/as any|satisfies unknown|\/>!/)
+ })
+
+ test('calls with extra arguments and shadowed names are left alone', async () => {
+ const code = await compileWithRscCssTransform(`
+ import { renderServerComponent } from '@tanstack/react-start/rsc'
+
+ export const configured = renderServerComponent( , { existing: true })
+ export function local(renderServerComponent) {
+ return renderServerComponent( )
+ }
+ export const transformed = renderServerComponent( )
+ `)
+ expect(code).not.toBeNull()
+ expect(await getModuleErrors(code!)).toEqual([])
+ expect(optionsCount(code!)).toBe(1)
+ // Existing options are not followed by a second options object.
+ expect(code).toMatch(/existing: true\s*\}\)/)
+ })
+})
diff --git a/packages/router-generator/tests/transform-parenthesized.test.ts b/packages/router-generator/tests/transform-parenthesized.test.ts
new file mode 100644
index 00000000000..2aa7773a1a4
--- /dev/null
+++ b/packages/router-generator/tests/transform-parenthesized.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import { transform } from '../src/transform/transform'
+import type { RouteNode } from '../src/types'
+
+function makeNode(): RouteNode {
+ return {
+ filePath: '/test.tsx',
+ fullPath: '/test.tsx',
+ variableName: 'TestRoute',
+ _fsRouteType: 'static',
+ }
+}
+
+const routeImport = "import { createFileRoute } from '@tanstack/react-router'\n"
+
+function run(source: string, node = makeNode()) {
+ return transform({
+ source,
+ ctx: { target: 'react', routeId: '/new', lazy: false },
+ node,
+ })
+}
+
+// Parentheses are transparent in JavaScript, and the generator rewrites the
+// user's file in place: it must recognize the same route call with or without
+// them and change only the route id.
+describe('transform with parenthesized route call parts', () => {
+ it.each([
+ {
+ name: 'string route id',
+ route: "createFileRoute(('/old'))({})",
+ expected: "createFileRoute(('/new'))({})",
+ },
+ {
+ name: 'template route id',
+ route: 'createFileRoute((`/old`))({})',
+ expected: 'createFileRoute((`/new`))({})',
+ },
+ {
+ name: 'route factory',
+ route: "(createFileRoute)('/old')({})",
+ expected: "(createFileRoute)('/new')({})",
+ },
+ ])('updates the route id of a parenthesized $name', ({ route, expected }) => {
+ const result = run(`${routeImport}export const Route = ${route}\n`)
+
+ expect(result).toEqual({
+ result: 'modified',
+ output: `${routeImport}export const Route = ${expected}\n`,
+ })
+ })
+
+ it('accepts a parenthesized route id that is already correct', () => {
+ expect(
+ run(`${routeImport}export const Route = createFileRoute(('/new'))({})\n`),
+ ).toEqual({ result: 'not-modified' })
+ })
+
+ it('records the options of a parenthesized options object', () => {
+ const node = makeNode()
+ const result = run(
+ `${routeImport}export const Route = createFileRoute('/new')(({ component: Page, server: {} }))\n`,
+ node,
+ )
+
+ expect(result).toEqual({ result: 'not-modified' })
+ expect(node.createFileRouteProps).toEqual(new Set(['component', 'server']))
+ })
+})
diff --git a/packages/router-generator/tests/transform-preservation.test.ts b/packages/router-generator/tests/transform-preservation.test.ts
new file mode 100644
index 00000000000..bfedee0ac85
--- /dev/null
+++ b/packages/router-generator/tests/transform-preservation.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest'
+import { transform } from '../src/transform/transform'
+import type { RouteNode } from '../src/types'
+
+function makeNode(): RouteNode {
+ return {
+ filePath: '/test.tsx',
+ fullPath: '/test.tsx',
+ variableName: 'TestRoute',
+ _fsRouteType: 'static',
+ }
+}
+
+function run(source: string, node = makeNode()) {
+ return transform({
+ source,
+ ctx: { target: 'react', routeId: '/new', lazy: false },
+ node,
+ })
+}
+
+// The generator writes its output back over the user's route file, so every
+// byte outside the route id and the router import must survive unchanged.
+describe('transform preserves the rest of the route file', () => {
+ it('edits the right spans after non-ASCII and astral characters', () => {
+ const before = [
+ '// 😀 héllo 日本語',
+ "import { createLazyFileRoute } from '@tanstack/react-router'",
+ "import { createFileRoute } from '@tanstack/react-router'",
+ "const label = '✓ 𝒳'",
+ ]
+ const after = [
+ 'export const Route = createFileRoute(/* 🚀 */ `/old`)({',
+ ' component: () => {label} é😀
, // ünïcode',
+ '})',
+ '',
+ ]
+ const result = run([...before, ...after].join('\n'))
+
+ expect(result).toEqual({
+ result: 'modified',
+ output: [
+ before[0],
+ before[2],
+ before[3],
+ after[0]!.replace('`/old`', '`/new`'),
+ ...after.slice(1),
+ ].join('\n'),
+ })
+ })
+
+ it('adds the constructor next to an aliased import of it', () => {
+ const result = run(
+ [
+ "import { createFileRoute as cfr, Link } from '@tanstack/react-router'",
+ 'export const Route = createFileRoute(\'/old\')({ component: () => })',
+ "export const Other = cfr('/other')",
+ '',
+ ].join('\n'),
+ )
+
+ expect(result).toEqual({
+ result: 'modified',
+ output: [
+ "import { createFileRoute as cfr, Link, createFileRoute } from '@tanstack/react-router'",
+ 'export const Route = createFileRoute(\'/new\')({ component: () => })',
+ "export const Other = cfr('/other')",
+ '',
+ ].join('\n'),
+ })
+ })
+
+ it.each([
+ {
+ name: 'a default import',
+ imports: "import Router, { Link } from '@tanstack/react-router'",
+ expected:
+ "import Router, { Link, createFileRoute } from '@tanstack/react-router'",
+ },
+ {
+ name: 'a namespace import',
+ imports: [
+ "import * as Router from '@tanstack/react-router'",
+ "import { Link } from '@tanstack/react-router'",
+ ].join('\n'),
+ expected: [
+ "import * as Router from '@tanstack/react-router'",
+ "import { Link, createFileRoute } from '@tanstack/react-router'",
+ ].join('\n'),
+ },
+ ])('adds the missing constructor next to $name', ({ imports, expected }) => {
+ const route = "export const Route = createFileRoute('/old')({})\n"
+ expect(run(`${imports}\n${route}`)).toEqual({
+ result: 'modified',
+ output: `${expected}\n${route.replace('/old', '/new')}`,
+ })
+ })
+
+ it('updates only the Route declarator of a multi-declarator export', () => {
+ const result = run(
+ [
+ "import { createFileRoute } from '@tanstack/react-router'",
+ "export const Route = createFileRoute('/old')({}), path = '/old'",
+ '',
+ ].join('\n'),
+ )
+
+ expect(result).toEqual({
+ result: 'modified',
+ output: [
+ "import { createFileRoute } from '@tanstack/react-router'",
+ "export const Route = createFileRoute('/new')({}), path = '/old'",
+ '',
+ ].join('\n'),
+ })
+ })
+
+ it('records string-literal option keys and skips computed and spread keys', () => {
+ const node = makeNode()
+ const result = run(
+ [
+ "import { createFileRoute } from '@tanstack/react-router'",
+ "const key = 'loader'",
+ "export const Route = createFileRoute('/new')({ 'component': Page, [key]: load, ['head']: head, ...rest, server: {} })",
+ '',
+ ].join('\n'),
+ node,
+ )
+
+ expect(result).toEqual({ result: 'not-modified' })
+ expect(node.createFileRouteProps).toEqual(new Set(['component', 'server']))
+ })
+})
diff --git a/packages/router-plugin/tests/code-splitter-edge-cases.test.ts b/packages/router-plugin/tests/code-splitter-edge-cases.test.ts
new file mode 100644
index 00000000000..93d280bc943
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter-edge-cases.test.ts
@@ -0,0 +1,420 @@
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { promisify } from 'node:util'
+import { build } from 'vite'
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { tanstackRouter } from '../src/vite'
+import { declarationOf, getModuleErrors } from './validate-module'
+import type { CodeSplitGroupings } from '../src/core/constants'
+
+const filename = 'route.tsx'
+const runNode = promisify(execFile)
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(
+ code: string,
+ groupings: CodeSplitGroupings = defaultCodeSplitGroupings,
+) {
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: groupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: groupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })
+ const modules: Record = {
+ reference: reference?.code ?? code,
+ }
+ for (const targets of groupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return { modules, sharedBindings }
+}
+
+async function getErrorsByModule(modules: Record) {
+ const errors: Record> = {}
+ for (const [name, code] of Object.entries(modules)) {
+ errors[name] = await getModuleErrors(code)
+ }
+ return errors
+}
+
+function noErrors(modules: Record) {
+ return Object.fromEntries(Object.keys(modules).map((name) => [name, []]))
+}
+
+/**
+ * Builds `source` as a code-split route with the real Vite plugin, then renders
+ * its component twice in a separate Node process and returns both renders.
+ */
+async function renderSplitRouteTwice(
+ source: string,
+ groupings: CodeSplitGroupings,
+) {
+ // Keep the temporary app inside the package so real runtime imports resolve.
+ const root = await mkdtemp(path.join(__dirname, '.edge-case-runtime-'))
+ try {
+ await mkdir(path.join(root, 'routes'))
+ await writeFile(path.join(root, 'routes/edge.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/edge'
+export async function renderTwice() {
+ const component: any = Route.options.component
+ await component.preload?.()
+ return [renderToString(createElement(component)), renderToString(createElement(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,
+ rollupOptions: {
+ output: { entryFileNames: 'entry.mjs', chunkFileNames: '[name].mjs' },
+ },
+ },
+ })
+ const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href
+ const { stdout } = await runNode(process.execPath, [
+ '--input-type=module',
+ '--eval',
+ `const { renderTwice } = await import(${JSON.stringify(entryUrl)})
+process.stdout.write(JSON.stringify(await renderTwice()))`,
+ ])
+ return JSON.parse(stdout) as Array
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+}
+
+describe('code-splitter keeps one module state', () => {
+ it.each([
+ {
+ name: 'an exported function mutating private state',
+ source: `import { createFileRoute } from '@tanstack/react-router'
+const state = { count: 0 }
+export function increment() {
+ return ++state.count
+}
+function Page() {
+ increment()
+ return {state.count}
+}
+export const Route = createFileRoute('/')({ component: Page })
+`,
+ },
+ {
+ name: 'an exported getter reading a private let',
+ source: `import { createFileRoute } from '@tanstack/react-router'
+let renders = 0
+export function getRenders() {
+ return renders
+}
+function Page() {
+ renders++
+ return {getRenders()}
+}
+export const Route = createFileRoute('/')({ component: Page })
+`,
+ },
+ ])(
+ 'renders a split component that uses $name',
+ async ({ source }) => {
+ // Without splitting, the component renders 1 and then 2: the exported
+ // function and the component must observe the same module-level binding.
+ expect(await renderSplitRouteTwice(source, [['component']])).toEqual([
+ '1
',
+ '2
',
+ ])
+ },
+ 30_000,
+ )
+})
+
+describe('code-splitter handles var declarations nested in top-level statements', () => {
+ it.each([
+ {
+ name: 'a for loop head',
+ setup: 'for (var value = 0; value < 3; value++) {}',
+ },
+ { name: 'a for-in head', setup: 'for (var value in { a: 1 }) {}' },
+ { name: 'a for-of head', setup: "for (var value of ['a']) {}" },
+ {
+ name: 'an if block',
+ setup: "if (typeof window === 'undefined') { var value = 'server' }",
+ },
+ {
+ name: 'a try block',
+ setup: "try { var value = JSON.parse('1') } catch {}",
+ },
+ { name: 'a labeled block', setup: 'init: { var value = 1; break init }' },
+ { name: 'a switch case', setup: 'switch (1) { case 1: var value = 1 }' },
+ ])(
+ 'emits valid modules when $name declares a binding used by the loader and the component',
+ async ({ setup }) => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+${setup}
+export const Route = createFileRoute('/')({
+ loader: () => value,
+ component: () => {value}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+
+ it('emits valid modules when a block-scoped var is exported and used by the component', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+if (typeof window === 'undefined') { var flag = 'server' }
+export { flag }
+export const Route = createFileRoute('/')({
+ component: () => {flag}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('keeps a for-var declaration in the chunk of the only component using it', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+for (var index = 0; index < 3; index++) {}
+for (var key in { a: 1 }) {}
+export const Route = createFileRoute('/')({
+ component: () => {index}{key}
,
+})
+`)
+ expect(modules['virtual component']).toMatch(/for \(var index = 0/)
+ expect(modules['virtual component']).toMatch(/for \(var key in/)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+})
+
+describe('code-splitter handles large expressions', () => {
+ it('compiles a route with a long method chain', async () => {
+ // Builder APIs (schemas, query builders) produce deep left-nested calls
+ const chain = Array.from({ length: 200 }, (_, i) => `.add(${i})`).join('')
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import { builder } from './builder'
+const schema = builder()${chain}
+export const Route = createFileRoute('/')({
+ loader: () => schema,
+ component: () => {String(schema)}
,
+})
+`)
+ expect(Object.values(modules).join('\n')).toContain('.add(199)')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+})
+
+describe('code-splitter follows object literal semantics', () => {
+ it('splits route options that repeat a split key like the runtime object does', async () => {
+ // Duplicate keys are legal JavaScript: the last property wins at runtime.
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({
+ component: () => first
,
+ component: () => last
,
+})
+`)
+ expect(modules['virtual component']).toContain('last')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+})
+
+describe('code-splitter coverage for syntax inside route files', () => {
+ it('splits route options declared in a variable', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import { fetchPosts } from './api'
+function Posts() {
+ return {Route.useLoaderData().length}
+}
+const options = {
+ loader: () => fetchPosts(),
+ component: Posts,
+}
+export const Route = createFileRoute('/posts')(options)
+`)
+ expect(modules.reference).toContain('tsr-split=component')
+ expect(modules.reference).not.toMatch(declarationOf('Posts'))
+ expect(modules.reference).toContain('fetchPosts()')
+ expect(modules['virtual component']).toMatch(declarationOf('Posts'))
+ // The split component reads the Route singleton from the reference module
+ expect(modules['virtual component']).toMatch(
+ /import \{ Route \} from ['"]route\.tsx['"]/,
+ )
+ expect(modules['virtual component']).not.toContain('fetchPosts')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('never extracts a destructuring whose sibling depends on Route', async () => {
+ const { modules, sharedBindings } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+const getRoute = () => Route
+const [cache, routeGetter] = [new Map(), getRoute]
+export const Route = createFileRoute('/')({
+ loader: () => cache.size,
+ component: () => {cache.size}{typeof routeGetter}
,
+})
+`)
+ expect([...sharedBindings]).toEqual([])
+ expect(modules.reference).toMatch(declarationOf('Route'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('keeps module-level re-exports and empty exports valid in every module', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+export * from './lib'
+export * as lib from './lib'
+export {}
+const cache = new Map()
+export const Route = createFileRoute('/')({
+ loader: () => cache.size,
+ component: () => {cache.size}
,
+})
+`)
+ expect(modules.reference).toMatch(/export \* from ['"]\.\/lib['"]/)
+ expect(modules.reference).toMatch(/export \* as lib from ['"]\.\/lib['"]/)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('erases type-only imports and exports from split chunks', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import type { User } from './types'
+import { type Settings, defaults } from './settings'
+export type * from './types'
+export type { User }
+const current: User | Settings = defaults
+export const Route = createFileRoute('/')({
+ loader: () => current,
+ component: () => {String(current)}
,
+})
+`)
+ for (const [name, code] of Object.entries(modules)) {
+ if (name === 'reference') {
+ continue
+ }
+ expect(code).not.toContain('./types')
+ }
+ expect(modules.shared).toMatch(
+ /import \{ defaults \} from ['"]\.\/settings['"]/,
+ )
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('re-exports a shared binding under string-literal and default names', async () => {
+ const { modules, sharedBindings } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+const store = { count: 0 }
+export { store as "my-store", store as default }
+export const Route = createFileRoute('/')({
+ loader: () => ++store.count,
+ component: () => {store.count}
,
+})
+`)
+ expect([...sharedBindings]).toEqual(['store'])
+ expect(modules.reference).toMatch(/["']my-store["']/)
+ expect(modules.reference).toMatch(/\bdefault\b/)
+ expect(modules['virtual component']).not.toMatch(declarationOf('store'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('shares a class with private members and a static block', async () => {
+ const { modules, sharedBindings } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+class Counter {
+ static registry: Array = []
+ static { Counter.registry.push('init') }
+ #count = 0
+ #bump() { return ++this.#count }
+ increment() { return this.#bump() }
+ get count() { return this.#count }
+ static owns(value: object) { return #count in value }
+}
+const counter = new Counter()
+export const Route = createFileRoute('/')({
+ loader: () => counter.increment(),
+ component: () => {counter.count}
,
+})
+`)
+ expect([...sharedBindings].sort()).toEqual(['Counter', 'counter'])
+ expect(modules.shared).toContain('static {')
+ expect(modules['virtual component']).not.toMatch(declarationOf('Counter'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ it('keeps namespace imports used as JSX member tags in the split chunk', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import * as UI from './ui'
+import { "kebab-name" as Kebab } from './icons'
+export const Route = createFileRoute('/')({
+ loader: () => typeof UI.Box,
+ component: () => hi ,
+})
+`)
+ expect(modules['virtual component']).toMatch(
+ /import \* as UI from ['"]\.\/ui['"]/,
+ )
+ expect(modules['virtual component']).toMatch(
+ /import \{ ["']kebab-name["'] as Kebab \} from ['"]\.\/icons['"]/,
+ )
+ expect(modules.reference).not.toContain('./icons')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+})
diff --git a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
new file mode 100644
index 00000000000..da0ac729b3a
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
@@ -0,0 +1,253 @@
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { createRouterCodeSplitterPlugin } from '../src/core/router-code-splitter-plugin'
+import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin'
+import { createRouterPluginContext } from '../src/core/router-plugin-context'
+import { normalizePath } from '../src/core/utils'
+import { getModuleErrors } from './validate-module'
+import type { Config } from '../src/core/config'
+import type { TransformResult, UnpluginOptions } from 'unplugin'
+
+const referencePluginName =
+ 'tanstack-router:code-splitter:compile-reference-file'
+const virtualPluginName = 'tanstack-router:code-splitter:compile-virtual-file'
+const sharedPluginName = 'tanstack-router:code-splitter:compile-shared-file'
+
+function routeFile(name: string) {
+ return normalizePath(path.join(process.cwd(), `src/routes/${name}.tsx`))
+}
+
+function getCode(result: TransformResult | null | undefined) {
+ if (!result) {
+ return null
+ }
+ return typeof result === 'string' ? result : result.code
+}
+
+function runTransform(plugin: UnpluginOptions, code: string, id: string) {
+ const transform = plugin.transform
+ if (!transform || typeof transform === 'function') {
+ throw new Error('Expected object transform')
+ }
+ return getCode(
+ transform.handler.call({} as never, code, id) as
+ | TransformResult
+ | null
+ | undefined,
+ )
+}
+
+/** Drives the three code-splitter transforms the way a bundler does. */
+async function createSplitter(
+ options: Partial,
+ routes: Record,
+) {
+ const context = createRouterPluginContext()
+ for (const [file, routeId] of Object.entries(routes)) {
+ context.routesByFile.set(file, { routeId })
+ }
+ const plugins = createRouterCodeSplitterPlugin(
+ { target: 'react', autoCodeSplitting: true, ...options },
+ context,
+ )
+ const pluginArray = Array.isArray(plugins) ? plugins : [plugins]
+ const byName = (name: string) => {
+ const plugin = pluginArray.find((candidate) => candidate.name === name)
+ if (!plugin) {
+ throw new Error(`Code-splitter plugin "${name}" not found`)
+ }
+ return plugin
+ }
+ const hook = byName(referencePluginName).vite?.configResolved
+ const config = {
+ root: process.cwd(),
+ command: 'build',
+ plugins: [{ name: referencePluginName }],
+ } as never
+ if (typeof hook === 'function') {
+ await hook.call({} as never, config)
+ } else if (hook) {
+ await hook.handler.call({} as never, config)
+ }
+ return {
+ reference: (code: string, id: string) =>
+ runTransform(byName(referencePluginName), code, id),
+ virtual: (code: string, id: string) =>
+ runTransform(byName(virtualPluginName), code, id),
+ shared: (code: string, id: string) =>
+ runTransform(byName(sharedPluginName), code, id),
+ }
+}
+
+const routeSource = (name: string) => `
+import { createFileRoute } from '@tanstack/react-router'
+const state = { name: ${JSON.stringify(name)} }
+export const Route = createFileRoute('/${name}')({
+ loader: () => state,
+ head: () => ({ meta: [{ title: state.name }] }),
+ component: () => {state.name}
,
+ errorComponent: () => error in ${name}
,
+})
+`
+
+describe('code-splitter plugin pipeline', () => {
+ it('compiles a split module the same way when its id has extra query parameters', async () => {
+ const file = routeFile('query')
+ const code = routeSource('query')
+ const splitter = await createSplitter({}, { [file]: '/query' })
+ const reference = splitter.reference(code, file)
+ expect(reference).toContain('tsr-split=component')
+
+ const virtual = splitter.virtual(code, `${file}?tsr-split=component`)
+ expect(virtual).toContain('state.name')
+ expect(splitter.virtual(code, `${file}?tsr-split=component&t=123`)).toBe(
+ virtual,
+ )
+ expect(splitter.virtual(code, `${file}?t=123&tsr-split=component`)).toBe(
+ virtual,
+ )
+ expect(splitter.shared(code, `${file}?tsr-shared=1&t=123`)).toBe(
+ splitter.shared(code, `${file}?tsr-shared=1`),
+ )
+ })
+
+ it('recompiles a route identically after many other routes were compiled', async () => {
+ const names = Array.from({ length: 140 }, (_, index) => `route${index}`)
+ const splitter = await createSplitter(
+ {},
+ Object.fromEntries(names.map((name) => [routeFile(name), `/${name}`])),
+ )
+ const compileAll = (name: string) => {
+ const file = routeFile(name)
+ const code = routeSource(name)
+ return {
+ reference: splitter.reference(code, file),
+ component: splitter.virtual(code, `${file}?tsr-split=component`),
+ errorComponent: splitter.virtual(
+ code,
+ `${file}?tsr-split=errorComponent`,
+ ),
+ shared: splitter.shared(code, `${file}?tsr-shared=1`),
+ }
+ }
+ const first = compileAll('route0')
+ expect(first.shared).toContain('"route0"')
+ expect(first.errorComponent).toContain('error in route0')
+ for (const name of names.slice(1)) {
+ compileAll(name)
+ }
+ expect(compileAll('route0')).toEqual(first)
+ })
+
+ it('splits by the plugin-level splitBehavior for the route id', async () => {
+ const file = routeFile('behavior')
+ const code = routeSource('behavior')
+ const splitter = await createSplitter(
+ {
+ codeSplittingOptions: {
+ splitBehavior: ({ routeId }) =>
+ routeId === '/behavior'
+ ? [['loader', 'component', 'errorComponent']]
+ : undefined,
+ },
+ },
+ { [file]: '/behavior' },
+ )
+ const reference = splitter.reference(code, file)!
+ expect(reference).toContain('tsr-split=component---errorComponent---loader')
+ expect(reference).not.toMatch(/tsr-split=component["']/)
+ const virtual = splitter.virtual(
+ code,
+ `${file}?tsr-split=component---errorComponent---loader`,
+ )!
+ expect(virtual).toContain('error in behavior')
+ expect(await getModuleErrors(virtual)).toEqual([])
+ })
+
+ it('rejects invalid splitBehavior groupings', async () => {
+ const file = routeFile('invalid')
+ const splitter = await createSplitter(
+ {
+ codeSplittingOptions: {
+ splitBehavior: () => [['component'], ['component']],
+ },
+ },
+ { [file]: '/invalid' },
+ )
+ expect(() => splitter.reference(routeSource('invalid'), file)).toThrow(
+ 'The groupings returned when using `splitBehavior` for the route',
+ )
+ })
+
+ it('deletes the configured route options from the reference module', async () => {
+ const file = routeFile('deleted')
+ const splitter = await createSplitter(
+ { codeSplittingOptions: { deleteNodes: ['head'] } },
+ { [file]: '/deleted' },
+ )
+ const reference = splitter.reference(routeSource('deleted'), file)!
+ expect(reference).not.toContain('head')
+ expect(reference).toContain('loader')
+ })
+
+ it('reports a split module id without a split value', async () => {
+ const file = routeFile('missing')
+ const splitter = await createSplitter({}, { [file]: '/missing' })
+ expect(() =>
+ splitter.virtual(routeSource('missing'), `${file}?tsr-split=`),
+ ).toThrow('The split value for the virtual route')
+ })
+
+ it('leaves a shared module id alone when its route has no shared bindings', async () => {
+ const file = routeFile('unshared')
+ const code = `
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/unshared')({
+ component: () => unshared
,
+})
+`
+ const splitter = await createSplitter({}, { [file]: '/unshared' })
+ expect(splitter.reference(code, file)).toContain('tsr-split=component')
+ expect(splitter.shared(code, `${file}?tsr-shared=1`)).toBeNull()
+ })
+})
+
+describe('route HMR plugin without code splitting', () => {
+ const file = routeFile('hmr')
+ const code = `
+import { createFileRoute } from '@tanstack/solid-router'
+export const Route = createFileRoute('/hmr')({
+ component: () => hmr
,
+})
+`
+ function transformWith(options: Partial) {
+ const context = createRouterPluginContext()
+ context.routesByFile.set(file, { routeId: '/hmr' })
+ const plugin = createRouterHmrPlugin(options, context)
+ return runTransform(plugin as UnpluginOptions, code, file)
+ }
+
+ it.each(['solid', 'vue'] as const)(
+ 'appends Vite HMR handling to %s routes without changing them',
+ async (target) => {
+ const output = transformWith({ target })!
+ expect(output).toContain('import.meta.hot')
+ expect(output).toContain('"/hmr"')
+ expect(output).toContain('hmr
')
+ expect(output).not.toContain('TSRComponent')
+ expect(await getModuleErrors(output)).toEqual([])
+ },
+ )
+
+ it('uses webpack HMR with the generated route id for non-React routes', async () => {
+ const output = transformWith({
+ target: 'solid',
+ plugin: { hmr: { style: 'webpack' } },
+ })!
+ expect(output).toContain('import.meta.webpackHot')
+ expect(output).not.toContain('import.meta.hot.')
+ expect(output).toContain('"/hmr"')
+ expect(output).not.toContain('__react_refresh_utils__')
+ expect(await getModuleErrors(output)).toEqual([])
+ })
+})
diff --git a/packages/router-plugin/tests/code-splitter-route-references.test.ts b/packages/router-plugin/tests/code-splitter-route-references.test.ts
new file mode 100644
index 00000000000..03054932052
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter-route-references.test.ts
@@ -0,0 +1,173 @@
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, 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 { describe, expect, it } from 'vitest'
+import { tanstackRouter } from '../src/vite'
+import {
+ compileCodeSplitReferenceRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { declarationOf, getModuleErrors } from './validate-module'
+
+const runNode = promisify(execFile)
+const filename = 'route.tsx'
+
+function compileReference(code: string) {
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ })
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined,
+ })
+ return { sharedBindings, reference: reference?.code ?? code }
+}
+
+describe('split options that read the Route singleton', () => {
+ // `Route` itself stays in the reference module, so reading it from a split
+ // option must not attribute every dependency of the route options to that
+ // option's chunk.
+ it.each([
+ {
+ name: 'an inline arrow component',
+ component: `() => {
+ const posts = Route.useLoaderData()
+ return
+ }`,
+ },
+ {
+ name: 'an inline named function component',
+ component: `function Posts() {
+ const { page } = Route.useSearch()
+ return {page}
+ }`,
+ },
+ ])(
+ 'keeps loader-only helpers in the reference module for $name',
+ async ({ component }) => {
+ const { sharedBindings, reference } = compileReference(`
+import { createFileRoute } from '@tanstack/react-router'
+const formatter = new Intl.NumberFormat('en')
+async function fetchPosts() {
+ return [formatter.format(1)]
+}
+export const Route = createFileRoute('/posts')({
+ loader: () => fetchPosts(),
+ component: ${component},
+})
+`)
+
+ expect([...sharedBindings]).toEqual([])
+ expect(reference).not.toContain('tsr-shared')
+ expect(reference).toMatch(declarationOf('fetchPosts'))
+ expect(reference).toMatch(declarationOf('formatter'))
+ expect(await getModuleErrors(reference)).toEqual([])
+ },
+ )
+
+ it('does not turn a reassigned loader-only binding into an import', () => {
+ const { sharedBindings, reference } = compileReference(`
+import { createFileRoute } from '@tanstack/react-router'
+let label = 'initial'
+label = 'updated'
+export const Route = createFileRoute('/label')({
+ loader: () => label,
+ component: () => {Route.useLoaderData()}
,
+})
+`)
+
+ expect([...sharedBindings]).toEqual([])
+ // An imported binding is read-only: `label = ...` would throw at runtime
+ expect(reference).not.toMatch(/import\s*\{[^}]*\blabel\b/)
+ expect(reference).toMatch(declarationOf('label'))
+ })
+
+ it('still runs a route whose loader reads a reassigned binding', async () => {
+ // Keep the temporary app inside the package so real runtime imports resolve.
+ const root = await mkdtemp(path.join(__dirname, '.route-reference-'))
+ try {
+ await mkdir(path.join(root, 'routes'))
+ await writeFile(
+ path.join(root, 'routes/label.tsx'),
+ `import { createFileRoute } from '@tanstack/react-router'
+let label = 'initial'
+label = 'updated'
+export const Route = createFileRoute('/label')({
+ loader: () => label,
+ component: () => Route.useLoaderData(),
+})
+`,
+ )
+ 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'),
+ `export { Route } from './routes/label'`,
+ )
+
+ await build({
+ root,
+ configFile: false,
+ logLevel: 'silent',
+ plugins: [
+ tanstackRouter({
+ target: 'react',
+ routesDirectory: './routes',
+ generatedRouteTree: './routeTree.gen.ts',
+ autoCodeSplitting: true,
+ codeSplittingOptions: { addHmr: false },
+ }),
+ ],
+ build: {
+ ssr: path.join(root, 'entry.ts'),
+ outDir: 'dist',
+ minify: false,
+ rollupOptions: {
+ output: {
+ entryFileNames: 'entry.mjs',
+ chunkFileNames: '[name].mjs',
+ },
+ },
+ },
+ })
+
+ const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href
+ const { stdout } = await runNode(process.execPath, [
+ '--input-type=module',
+ '--eval',
+ `const { Route } = await import(${JSON.stringify(entryUrl)})
+process.stdout.write(String(await Route.options.loader({})))`,
+ ])
+ expect(stdout).toBe('updated')
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ }, 30_000)
+
+ it('keeps sharing a binding that the component and the loader both read', () => {
+ const { sharedBindings } = compileReference(`
+import { createFileRoute } from '@tanstack/react-router'
+const formatName = (name: string) => name.toUpperCase()
+export const Route = createFileRoute('/user')({
+ loader: () => formatName('a'),
+ component: () => {formatName(Route.useLoaderData())} ,
+})
+`)
+
+ expect([...sharedBindings]).toEqual(['formatName'])
+ })
+})
diff --git a/packages/start-plugin-core/tests/hydrate-split-edge-cases.test.ts b/packages/start-plugin-core/tests/hydrate-split-edge-cases.test.ts
new file mode 100644
index 00000000000..8f9644fa9a2
--- /dev/null
+++ b/packages/start-plugin-core/tests/hydrate-split-edge-cases.test.ts
@@ -0,0 +1,379 @@
+import { parseSync, transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform'
+import { compileStartModule } from './compile-start-module'
+import { getModuleErrors } from './validate-module'
+
+/** Compiles a module with `` and loads the split chunks it imports. */
+async function compileHydrate(env: 'client' | 'server', code: string) {
+ const plugin = createHydrateCompilerPlugin()
+ const parent = await compileStartModule({
+ env,
+ code,
+ compilerPlugins: [plugin],
+ })
+ if (parent === null) {
+ throw new Error('expected the module to be transformed')
+ }
+ const chunks = [...parent.matchAll(/import\((["'])(.+?)\1\)/g)].map(
+ ([, , id]) => {
+ const chunk = plugin.loadVirtualModule?.({
+ id: id!,
+ root: '/test',
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ })
+ if (!chunk) {
+ throw new Error(`expected virtual module ${id} to load`)
+ }
+ return chunk.code
+ },
+ )
+ // Order chunks by boundary index, however the parent declares them.
+ const index = (chunk: string) =>
+ Number(chunk.match(/export function H(\d+)\(/)?.[1] ?? -1)
+ chunks.sort((a, b) => index(a) - index(b))
+ return { parent, chunks, plugin }
+}
+
+/**
+ * Evaluates a self-contained chunk (no imports) like a bundler would and
+ * renders one of its exports to a string: JSX becomes plain function calls
+ * and intrinsic elements become tags.
+ */
+async function renderChunkExport(
+ chunk: string,
+ exportName: string,
+ props: Record = {},
+) {
+ const { code } = await transformWithOxc(chunk, 'chunk.tsx', {
+ jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' },
+ })
+ const runtime = `const Fragment = Symbol('Fragment')
+const h = (type, props, ...children) => {
+ const text = children.flat(Infinity).filter((c) => c != null && c !== false).join('')
+ if (type === Fragment) return text
+ if (typeof type === 'function') return type({ ...props, children: text })
+ return '<' + type + '>' + text + '' + type + '>'
+}
+`
+ const module: Record string> = await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(runtime + code)}`
+ )
+ return module[exportName]!(props)
+}
+
+/** Every split-chunk component name and the props the parent passes to it. */
+function getChunkParams(chunk: string) {
+ const params = chunk.match(/export function H\d+\(([^)]*)\)/)?.[1] ?? ''
+ return [...params.matchAll(/[\w$]+/g)].map(([name]) => name).sort()
+}
+
+type JsxNode = {
+ type: string
+ name?: { type: string; name?: string }
+ openingElement?: { name: { type: string; name?: string } }
+ children?: Array
+}
+
+/** Finds the first JSX element named `name` in a module (Oxc ESTree). */
+function findJsxElement(code: string, name: string): JsxNode | undefined {
+ const { program } = parseSync('module.tsx', code, { sourceType: 'module' })
+ let found: JsxNode | undefined
+ const visit = (node: unknown): void => {
+ if (found || !node || typeof node !== 'object') {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach(visit)
+ return
+ }
+ const current = node as JsxNode
+ if (
+ current.type === 'JSXElement' &&
+ current.openingElement?.name.name === name
+ ) {
+ found = current
+ return
+ }
+ Object.values(node).forEach(visit)
+ }
+ visit(program)
+ return found
+}
+
+function meaningfulChildren(element: JsxNode | undefined) {
+ return (element?.children ?? []).filter(
+ (child) => child.type !== 'JSXText' || child.value!.trim() !== '',
+ )
+}
+
+describe('Hydrate split modules: edge cases', () => {
+ test.each([
+ {
+ name: 'a map callback with a block body',
+ children: `{letters.map((letter) => { const upper = letter.toUpperCase(); return {upper} })} `,
+ rendered: '',
+ },
+ {
+ name: 'an immediately invoked function expression',
+ children: `{(function () { return letters.join('+') })()}
`,
+ rendered: 'a+b
',
+ },
+ {
+ name: 'a function-valued prop with a block body',
+ children: `- { return '[' + value + ']' }} />`,
+ rendered: '
[a] ',
+ },
+ ])(
+ 'client: split children may contain $name with return statements',
+ async ({ children, rendered }) => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const letters = ['a', 'b']
+function Item({ format }) { return {format(letters[0])} }
+export function Page() {
+ return ${children}
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(await renderChunkExport(chunks[0]!, 'H0')).toBe(rendered)
+ },
+ )
+
+ test('client: a self-closing Hydrate returned from a component stays a valid module', async () => {
+ const { parent } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import { idle } from '@tanstack/react-start/hydration'
+import { Chart } from './chart'
+export function Page() {
+ return } />
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ })
+
+ test('client: a self-closing Hydrate does not render anything next to its boundary', async () => {
+ const { parent } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import { idle } from '@tanstack/react-start/hydration'
+import { Chart } from './chart'
+export function Page() {
+ return
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ // The server renders exactly one child here; any extra sibling would
+ // break hydration of the section.
+ const section = findJsxElement(parent, 'section')
+ const children = meaningfulChildren(section)
+ expect(children).toHaveLength(1)
+ expect(children[0]!.openingElement?.name.name).toBe('Hydrate')
+ })
+
+ test('client: loop variables, destructured params and block-scoped locals are passed to the chunk under the same names', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+function Row({ v }) { return {v} }
+const items = ['x', 'y']
+export function Page({ prefix = 'p', ...rest }) {
+ const out = []
+ for (const [key, value] of Object.entries(rest)) {
+ const label = prefix + '-' + key
+ out.push(|
)
+ }
+ return {out}{items.map((item, i) => |
)}
+}
+`,
+ )
+ expect(chunks).toHaveLength(2)
+ expect(await getModuleErrors(parent)).toEqual([])
+ for (const chunk of chunks) {
+ expect(await getModuleErrors(chunk)).toEqual([])
+ }
+ const [forOf, map] = chunks.map((chunk) => getChunkParams(chunk))
+ expect(forOf).toEqual(['label', 'value'])
+ expect(map).toEqual(['i', 'item', 'prefix'])
+ for (const name of [...forOf!, ...map!]) {
+ expect(parent).toContain(`${name}={${name}}`)
+ }
+ // Module-level helpers move into the chunk with the children.
+ expect(parent).not.toMatch(/function Row\b/)
+ expect(
+ await renderChunkExport(chunks[0]!, 'H0', { label: 'p-a', value: 1 }),
+ ).toBe('p-a1 ')
+ expect(
+ await renderChunkExport(chunks[1]!, 'H1', {
+ item: 'x',
+ i: 0,
+ prefix: 'p',
+ }),
+ ).toBe('x0p ')
+ })
+
+ test('client: catch params, hoisted local functions and shadowing locals are captured', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const value = 'module'
+export function Page() {
+ const value = 'local'
+ try {
+ throw new Error('caught')
+ } catch (error) {
+ return {value + ':' + error.message + ':' + later()}
+ }
+ function later() { return 'hoisted' }
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(getChunkParams(chunks[0]!)).toEqual(['error', 'later', 'value'])
+ expect(
+ await renderChunkExport(chunks[0]!, 'H0', {
+ value: 'local',
+ error: new Error('caught'),
+ later: () => 'hoisted',
+ }),
+ ).toBe('local:caught:hoisted
')
+ })
+
+ test.each(['split="false"', 'split={false}', 'split={(false)}'])(
+ 'client: %s keeps function-as-children in place',
+ async (split) => {
+ const code = `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return {() => child
}
+}
+`
+ const parent = await compileStartModule({
+ env: 'client',
+ code,
+ compilerPlugins: [createHydrateCompilerPlugin()],
+ })
+ expect(parent).toBeNull()
+ },
+ )
+
+ test.each(['split', 'split="true"', 'split={true}'])(
+ 'client: %s splits the children',
+ async (split) => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return child
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(await renderChunkExport(chunks[0]!, 'H0')).toBe('child
')
+ },
+ )
+
+ test('server: fallback is stripped from a single-use spread object only', async () => {
+ const single = await compileHydrate(
+ 'server',
+ `import { Hydrate } from '@tanstack/react-start'
+const opts = { fallback: server-fallback
, when: true }
+export function Page() { return child
}
+`,
+ )
+ expect(single.parent).not.toContain('server-fallback')
+ expect(single.parent).toContain('when: true')
+
+ // A shared object or a computed one may be observed elsewhere: keep it.
+ for (const code of [
+ `import { Hydrate } from '@tanstack/react-start'
+const opts = { fallback: server-fallback
}
+export function Page() { return <>a
b
> }
+`,
+ `import { Hydrate } from '@tanstack/react-start'
+const makeOpts = () => ({ fallback: server-fallback
})
+const opts = makeOpts()
+export function Page() { return child
}
+`,
+ ]) {
+ const { parent } = await compileHydrate('server', code)
+ expect(parent).toContain('server-fallback')
+ expect(await getModuleErrors(parent)).toEqual([])
+ }
+ })
+
+ test.each(['client', 'server'] as const)(
+ '%s: a stale h attribute is replaced by the generated boundary id',
+ async (env) => {
+ const { parent } = await compileHydrate(
+ env,
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() { return child
}
+`,
+ )
+ expect(parent).not.toContain('stale')
+ expect(parent.match(/\bh="/g)).toHaveLength(1)
+ },
+ )
+
+ test('client: comment-only and empty boundaries load chunks that render nothing', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return {/* nothing */}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(chunks).toHaveLength(2)
+ for (const [index, chunk] of chunks.entries()) {
+ expect(await getModuleErrors(chunk)).toEqual([])
+ expect(await renderChunkExport(chunk, `H${index}`)).toBeNull()
+ }
+ })
+
+ test('client: virtual module loads are cached until the source changes', async () => {
+ const source = `import { Hydrate } from '@tanstack/react-start'
+export function Page() { return first
}
+`
+ const { parent, plugin } = await compileHydrate('client', source)
+ const id = parent.match(/import\((["'])(.+?)\1\)/)![2]!
+ const load = (moduleId: string) =>
+ plugin.loadVirtualModule?.({
+ id: moduleId,
+ root: '/test',
+ env: 'client',
+ envName: 'client',
+ })
+ const first = load(id)
+ expect(first?.code).toContain('first')
+ expect(load(id)).toBe(first)
+
+ // Unknown boundaries and ids without a split id are not Hydrate chunks.
+ expect(load(id.replace(/=0_/, '=5_'))).toBeNull()
+ expect(load('/test/src/module.tsx?other=1')).toBeNull()
+
+ // Recompiling identical code keeps the cache; new code invalidates it.
+ await compileStartModule({
+ env: 'client',
+ code: source,
+ compilerPlugins: [plugin],
+ })
+ expect(load(id)).toBe(first)
+ await compileStartModule({
+ env: 'client',
+ code: source.replace('first', 'second'),
+ compilerPlugins: [plugin],
+ })
+ expect(load(id)?.code).toContain('second')
+ })
+})
diff --git a/packages/start-plugin-core/tests/importProtection/edge-cases.test.ts b/packages/start-plugin-core/tests/importProtection/edge-cases.test.ts
new file mode 100644
index 00000000000..106d4a4c1f0
--- /dev/null
+++ b/packages/start-plugin-core/tests/importProtection/edge-cases.test.ts
@@ -0,0 +1,254 @@
+import { transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import {
+ findOriginalUnsafeUsagePosFromResult,
+ findPostCompileUsagePosFromResult,
+ getImportSourcesFromResult,
+ getImportSpecifierLocationFromResult,
+ getMockExportNamesBySource,
+ getNamedExports,
+} from '../../src/import-protection/analysis'
+import { rewriteDeniedImports } from '../../src/import-protection/rewrite'
+import {
+ ImportLocCache,
+ addTraceImportLocations,
+ createImportSpecifierLocationIndex,
+ findImportStatementLocationFromTransformed,
+ findOriginalUsageLocation,
+ findPostCompileUsageLocation,
+} from '../../src/import-protection/sourceLocation'
+import type { TransformResult } from '../../src/import-protection/sourceLocation'
+
+function result(code: string, filename?: string): TransformResult {
+ return { code, filename, map: undefined, originalCode: undefined }
+}
+
+/** 1-based line and 0-based column of the first `needle` after `from`. */
+function positionOf(code: string, needle: string, from = 0) {
+ const index = code.indexOf(needle, from)
+ const before = code.slice(0, index).split('\n')
+ return { line: before.length, column0: before.at(-1)!.length }
+}
+
+describe('import protection analysis edge cases', () => {
+ test('default and namespace specifiers collect member names and usages', () => {
+ const code = `import secret, * as ns from 'denied'
+export function f(other) { return other.notCollected }
+export const value = secret.read + ns.write
+`
+ expect(getMockExportNamesBySource(code, 'f.ts').get('denied')).toEqual([
+ 'read',
+ 'write',
+ ])
+ expect(
+ findPostCompileUsagePosFromResult(result(code, 'f.ts'), 'denied'),
+ ).toEqual(positionOf(code, 'secret.read'))
+ })
+
+ test('re-export forms are import sources with a specifier location', () => {
+ const code = `export * as ns from 'denied-a'
+export { default } from 'denied-b'
+export { default as Named, other } from 'denied-c'
+export type { OnlyType } from 'denied-d'
+`
+ const source = result(code, 'f.ts')
+ expect(getImportSourcesFromResult(source)).toEqual([
+ 'denied-a',
+ 'denied-b',
+ 'denied-c',
+ ])
+ for (const specifier of ['denied-a', 'denied-b', 'denied-c']) {
+ expect(getImportSpecifierLocationFromResult(source, specifier)).toBe(
+ code.indexOf(specifier),
+ )
+ }
+ expect(getImportSpecifierLocationFromResult(source, 'denied-d')).toBe(-1)
+ expect(getNamedExports(code, 'f.ts')).toEqual(['Named', 'other'])
+ expect(getMockExportNamesBySource(code, 'f.ts').get('denied-c')).toEqual([
+ 'other',
+ ])
+ })
+
+ test('compiler-safe boundaries follow member-call chains and naming conventions', () => {
+ const code = `import { secret } from 'denied'
+export const a = createServerFn({ method: 'GET' }).validator((d) => d).handler(async () => secret())
+export const b = myServerFn.handler(() => secret())
+export const c = authMiddleware.server(() => secret())
+export const d = createIsomorphicFn().server(() => secret()).client(() => secret())
+export const e = ns.createServerFn().handler(() => secret())
+`
+ const source = result(code, 'f.ts')
+ // In the client, the isomorphic client branch is the first unsafe usage;
+ // the namespaced factory is not a known boundary either.
+ expect(
+ findOriginalUnsafeUsagePosFromResult(source, 'denied', 'client'),
+ ).toEqual(positionOf(code, 'secret()', code.indexOf('.client(')))
+ expect(
+ findOriginalUnsafeUsagePosFromResult(
+ result(code.replace('.client(() => secret())', ''), 'f.ts'),
+ 'denied',
+ 'client',
+ ),
+ ).toEqual(positionOf(code, 'secret()', code.indexOf('export const e')))
+ // In the server, only the isomorphic client branch is a boundary, so the
+ // first usage wins; repeated lookups use the per-result cache.
+ const serverPos = positionOf(code, 'secret()')
+ expect(
+ findOriginalUnsafeUsagePosFromResult(source, 'denied', 'server'),
+ ).toEqual(serverPos)
+ expect(
+ findOriginalUnsafeUsagePosFromResult(source, 'denied', 'server'),
+ ).toEqual(serverPos)
+ expect(findPostCompileUsagePosFromResult(source, 'denied')).toEqual(
+ serverPos,
+ )
+ })
+
+ test('usage positions are UTF-16 columns after astral characters, tabs and CRLF', () => {
+ const code =
+ "import { secret } from 'denied'\r\n" +
+ '// 👨👩👧 𝒳𝒴 日本語\r\n' +
+ "\tconst label = '🎉'; const value = secret()\r\n"
+ expect(findPostCompileUsagePosFromResult(result(code), 'denied')).toEqual({
+ line: 3,
+ column0: "\tconst label = '🎉'; const value = ".length,
+ })
+ })
+})
+
+describe('import protection source locations through a real source map', () => {
+ const original = `// ünïcödé 👨👩👧 comment
+import type { Shape } from './types'
+import { getSecret } from './secret.server'
+interface Props { shape: Shape }
+export const Page = ({ shape }: Props) => {getSecret(shape)}
+`
+ const file = '/root/src/page.tsx'
+
+ async function transformed() {
+ const output = await transformWithOxc(original, file, {
+ jsx: 'preserve',
+ sourcemap: true,
+ })
+ return {
+ code: output.code,
+ map: {
+ ...output.map!,
+ file: undefined,
+ sourceRoot: '/root/',
+ sources: ['src/page.tsx'],
+ sourcesContent: [original],
+ },
+ originalCode: undefined,
+ filename: file,
+ } satisfies TransformResult
+ }
+
+ test('maps import and usage locations back to the original source', async () => {
+ const res = await transformed()
+ const provider = { getTransformResult: () => res }
+ const cache = new ImportLocCache()
+ const index = createImportSpecifierLocationIndex()
+ const find = (r: TransformResult, s: string) => index.find(r, s)
+
+ const importLine = 3
+ expect(
+ await findImportStatementLocationFromTransformed(
+ provider,
+ file,
+ './secret.server',
+ cache,
+ find,
+ ),
+ ).toMatchObject({ file, line: importLine })
+ // A second lookup is served from the cache.
+ expect(cache.has(`${file}::./secret.server`)).toBe(true)
+
+ const usage = positionOf(original, 'getSecret(shape)')
+ expect(
+ await findPostCompileUsageLocation(provider, file, './secret.server'),
+ ).toEqual({ file, line: usage.line, column: usage.column0 + 1 })
+ expect(
+ findOriginalUsageLocation(
+ provider,
+ file,
+ './secret.server',
+ 'client',
+ '/root',
+ ),
+ ).toEqual({ file, line: usage.line, column: usage.column0 + 1 })
+
+ const trace: Array<{
+ file: string
+ specifier?: string
+ line?: number
+ column?: number
+ }> = [
+ { file: '/root/src/entry.tsx' },
+ { file, specifier: './secret.server' },
+ { file, specifier: './types', line: 1, column: 1 },
+ ]
+ await addTraceImportLocations(provider, trace, cache, find)
+ expect(trace[0]).toEqual({ file: '/root/src/entry.tsx' })
+ expect(trace[1]).toMatchObject({ line: importLine })
+ expect(trace[2]).toMatchObject({ line: 1, column: 1 })
+ })
+
+ test('returns undefined for sources that are only imported as types', async () => {
+ const res = await transformed()
+ const provider = { getTransformResult: () => res }
+ const index = createImportSpecifierLocationIndex()
+ expect(
+ await findImportStatementLocationFromTransformed(
+ provider,
+ file,
+ './types',
+ new ImportLocCache(),
+ (r, s) => index.find(r, s),
+ ),
+ ).toBeUndefined()
+ })
+})
+
+describe('rewriteDeniedImports runtime semantics', () => {
+ const mockModule = `data:text/javascript,${encodeURIComponent(
+ `export default new Proxy({}, { get: (_, key) => typeof key === 'string' ? 'mock:' + key : undefined })`,
+ )}`
+
+ async function evaluate(code: string, denied: Array) {
+ const rewritten = rewriteDeniedImports(
+ code,
+ '/test/module.ts',
+ new Set(denied),
+ () => mockModule,
+ )
+ expect(rewritten).toBeDefined()
+ const { code: javascript } = await transformWithOxc(
+ rewritten!.code,
+ 'module.ts',
+ )
+ return (await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(javascript)}`
+ )) as Record
+ }
+
+ test('mixed type and value specifiers, attributes and string-literal names', async () => {
+ const exports = await evaluate(
+ `import 'denied'
+import def, { type T, value, "kebab-name" as kebab } from 'denied' with { type: 'json' }
+import * as ns from 'denied'
+export { type U, reexported, reexported as "string export", "a-b" as ab } from 'denied'
+export { default as renamedDefault } from 'denied'
+export const seen: T = [def === ns, value, kebab] as unknown as T
+`,
+ ['denied'],
+ )
+ expect({ ...exports }).toEqual({
+ seen: [true, 'mock:value', 'mock:kebab-name'],
+ reexported: 'mock:reexported',
+ 'string export': 'mock:reexported',
+ ab: 'mock:a-b',
+ renamedDefault: 'mock:default',
+ })
+ })
+})
diff --git a/packages/start-plugin-core/tests/start-compiler-diagnostics.test.ts b/packages/start-plugin-core/tests/start-compiler-diagnostics.test.ts
new file mode 100644
index 00000000000..a864161fea4
--- /dev/null
+++ b/packages/start-plugin-core/tests/start-compiler-diagnostics.test.ts
@@ -0,0 +1,52 @@
+import { expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+
+async function collectWarnings(code: string) {
+ const warnings: Array = []
+ const compiler = new StartCompiler({
+ env: 'client',
+ envName: 'client',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv('client'),
+ lookupConfigurations: getLookupConfigurationsForEnv('client', 'react'),
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (id) => id,
+ warn: (message) => warnings.push(message),
+ })
+ await compiler.compile({
+ code,
+ id: '/test/src/module.tsx',
+ detectedKinds: detectKindsInCode(code, 'client'),
+ })
+ return warnings
+}
+
+// Line terminators are \n, \r\n, a lone \r, U+2028 and U+2029. Editors report
+// locations against all of them, so diagnostics must count every one.
+test.each([
+ ['\\n', '\n'],
+ ['\\r\\n', '\r\n'],
+ ['a lone \\r', '\r'],
+])(
+ 'reports the deprecated inputValidator location with %s line endings',
+ async (_name, eol) => {
+ const code = [
+ `import { createServerFn } from '@tanstack/react-start'`,
+ `const a = 1`,
+ `const b = 2`,
+ `export const fn = createServerFn().inputValidator((x: string) => x).handler(async () => a + b)`,
+ ].join(eol)
+ expect(await collectWarnings(code)).toEqual([
+ '/test/src/module.tsx:4:19 createServerFn().inputValidator() is deprecated. Use createServerFn().validator() instead.',
+ ])
+ },
+)
diff --git a/packages/start-plugin-core/tests/start-compiler-edge-cases.test.ts b/packages/start-plugin-core/tests/start-compiler-edge-cases.test.ts
new file mode 100644
index 00000000000..4d9efd4c25a
--- /dev/null
+++ b/packages/start-plugin-core/tests/start-compiler-edge-cases.test.ts
@@ -0,0 +1,358 @@
+import { describe, expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import { getModuleErrors } from './validate-module'
+import type { ServerFn } from '../src/start-compiler/types'
+
+const moduleId = '/test/src/module.tsx'
+const providerId = `${moduleId}?tss-serverfn-split`
+
+function createCompiler(options: {
+ env: 'client' | 'server'
+ files?: Record
+ mode?: 'dev' | 'build'
+ directives?: Array
+}) {
+ const { env, files = {} } = options
+ const serverFns: Record = {}
+ const compiler: StartCompiler = new StartCompiler({
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: options.mode ?? 'build',
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ getKnownServerFns: () => ({}),
+ devServerFnModuleSpecifierEncoder: ({ extractedFilename, root }) =>
+ `/@id${extractedFilename.slice(root.length)}`,
+ serverFnProviderModuleDirectives: options.directives,
+ onServerFnsById: (fns) => Object.assign(serverFns, fns),
+ loadModule: async (id) => {
+ const code = files[id]
+ if (code !== undefined) {
+ compiler.ingestModule({ code, id })
+ }
+ },
+ resolveId: async (id) => {
+ if (id.startsWith('@tanstack/')) {
+ return id
+ }
+ const file = id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id
+ return file in files ? file : null
+ },
+ })
+ const compile = async (code: string, id = moduleId) => {
+ const result = await compiler.compile({
+ code,
+ id,
+ detectedKinds: detectKindsInCode(code, env),
+ })
+ return result?.code ?? null
+ }
+ return { compiler, compile, serverFns }
+}
+
+async function compileValid(
+ options: Parameters[0] & {
+ code: string
+ provider?: boolean
+ },
+) {
+ const { compile, serverFns } = createCompiler(options)
+ const code = await compile(
+ options.code,
+ options.provider ? providerId : moduleId,
+ )
+ expect(code).not.toBeNull()
+ expect(await getModuleErrors(code!)).toEqual([])
+ return { code: code!, serverFns }
+}
+
+function decodeDevId(id: string) {
+ return JSON.parse(Buffer.from(id, 'base64url').toString('utf8'))
+}
+
+describe('dev mode server functions', () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export const getPosts = createServerFn().handler(async () => db.posts())`
+
+ test('client callers use dev ids that encode the provider module and export', async () => {
+ const { code: output, serverFns } = await compileValid({
+ env: 'client',
+ mode: 'dev',
+ code,
+ })
+ const [functionId] = Object.keys(serverFns)
+ expect(decodeDevId(functionId!)).toEqual({
+ file: '/@id/src/module.tsx?tss-serverfn-split',
+ export: 'getPosts_createServerFn_handler',
+ })
+ expect(output).toContain(`createClientRpc(${JSON.stringify(functionId)})`)
+ expect(output).not.toContain('db.server')
+ })
+
+ test('the provider module exports the handler and accepts HMR updates', async () => {
+ const { code: output } = await compileValid({
+ env: 'server',
+ mode: 'dev',
+ provider: true,
+ code,
+ })
+ expect(output).toMatch(/export\s*\{\s*getPosts_createServerFn_handler\s*\}/)
+ expect(output).toMatch(/import\.meta\.hot\.accept\(/)
+ expect(output).toMatch(/import\.meta\.webpackHot\.accept\(/)
+ expect(output).toContain('db.posts()')
+ })
+
+ test('build mode providers do not add HMR handling', async () => {
+ const { code: output } = await compileValid({
+ env: 'server',
+ provider: true,
+ code,
+ })
+ expect(output).not.toContain('import.meta.hot')
+ })
+})
+
+describe('provider module directives', () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+export const fn = createServerFn().handler(async () => 1)`
+
+ test('adds each configured directive once, before the generated imports', async () => {
+ const { code: output } = await compileValid({
+ env: 'server',
+ provider: true,
+ directives: ['use server', '', 'use server'],
+ code: `'use strict'\n${code}`,
+ })
+ expect(output.match(/["']use server["']/g)).toHaveLength(1)
+ expect(output.indexOf('use server')).toBeLessThan(
+ output.indexOf('createServerRpc'),
+ )
+ expect(output).toMatch(/["']use strict["']/)
+ })
+
+ test('does not duplicate a directive the module already has', async () => {
+ const { code: output } = await compileValid({
+ env: 'server',
+ provider: true,
+ directives: ['use server'],
+ code: `'use server'\n${code}`,
+ })
+ expect(output.match(/["']use server["']/g)).toHaveLength(1)
+ })
+
+ test('only provider modules receive directives', async () => {
+ const { code: output } = await compileValid({
+ env: 'server',
+ directives: ['use server'],
+ code,
+ })
+ expect(output).not.toContain('use server')
+ })
+})
+
+test('redeclared var server functions get distinct versioned handler names', async () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+var fn = createServerFn().handler(async () => 1)
+var fn = createServerFn().handler(async () => 2)
+export { fn }`
+ const client = await compileValid({ env: 'client', code })
+ expect(
+ Object.values(client.serverFns)
+ .map((fn) => fn.functionName)
+ .sort(),
+ ).toEqual(['fn_createServerFn_handler', 'fn_createServerFn_handler_1'])
+ const ids = Object.keys(client.serverFns)
+ expect(new Set(ids).size).toBe(2)
+ for (const id of ids) {
+ expect(client.code).toContain(`createClientRpc(${JSON.stringify(id)})`)
+ }
+
+ const provider = await compileValid({ env: 'server', provider: true, code })
+ expect(provider.code).toMatch(
+ /export\s*\{\s*fn_createServerFn_handler,\s*fn_createServerFn_handler_1\s*\}/,
+ )
+})
+
+test('provider modules only export the extracted handlers', async () => {
+ const { code: output } = await compileValid({
+ env: 'server',
+ provider: true,
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+const a = createServerFn().handler(async () => db.a())
+const b = createServerFn().handler(async () => db.b())
+export { a, b as bee }
+export default a
+export function helper() { return a }
+export * from './other'
+export { z } from './z'`,
+ })
+ expect(output).toMatch(
+ /export\s*\{\s*a_createServerFn_handler,\s*b_createServerFn_handler\s*\}/,
+ )
+ expect(output).not.toMatch(/export\s*\{\s*a,/)
+ expect(output).not.toContain('bee')
+ expect(output).not.toContain('helper')
+ expect(output).not.toContain("'./z'")
+ // Statements that are not declarations stay untouched.
+ expect(output).toMatch(/export default a\b/)
+ expect(output).toMatch(/export \* from ['"]\.\/other['"]/)
+})
+
+describe('cross-module factory resolution', () => {
+ test('a default-exported middleware builder imported as default', async () => {
+ const { code: output } = await compileValid({
+ env: 'client',
+ files: {
+ '/test/src/mw.ts': `import { createMiddleware } from '@tanstack/react-start'
+export default createMiddleware({ type: 'function' })`,
+ },
+ code: `import mw from './mw'
+import { createMiddleware } from '@tanstack/react-start'
+import { db } from './db.server'
+export const logged = mw.server(async ({ next }) => { db.log(); return next() })
+export const plain = createMiddleware().server(async ({ next }) => next())`,
+ })
+ expect(output).toMatch(/export const logged = mw;/)
+ expect(output).not.toContain('db.server')
+ })
+
+ test('a default-exported server function builder declared by identifier', async () => {
+ const { code: output } = await compileValid({
+ env: 'client',
+ files: {
+ '/test/src/builders.ts': `import { createServerFn } from '@tanstack/react-start'
+const authed = createServerFn({ method: 'POST' })
+export default authed`,
+ },
+ code: `import authed from './builders'
+import { db } from './db.server'
+export const fn = authed.handler(async () => db.x())`,
+ })
+ expect(output).toMatch(/authed\.handler\(createClientRpc\(/)
+ expect(output).not.toContain('db.server')
+ })
+
+ test('a namespace import of a local module re-exporting the factories', async () => {
+ const files = {
+ '/test/src/factories.ts': `export { createServerFn, createServerOnlyFn, createIsomorphicFn } from '@tanstack/react-start'`,
+ }
+ const code = `import * as F from './factories'
+import { db } from './db.server'
+export const fn = F.createServerFn().handler(async () => db.x())
+export const serverOnly = F.createServerOnlyFn(() => db.z())
+export const iso = F.createIsomorphicFn().server(() => db.w()).client(() => 'client')`
+ const client = await compileValid({ env: 'client', files, code })
+ expect(Object.keys(client.serverFns)).toHaveLength(1)
+ expect(client.code).toMatch(
+ /F\.createServerFn\(\)\.handler\(createClientRpc\(/,
+ )
+ expect(client.code).toMatch(/export const iso = \(\) => ["']client["']/)
+ expect(client.code).not.toContain('db.server')
+
+ const ssr = await compileValid({ env: 'server', files, code })
+ expect(ssr.code).toMatch(/export const serverOnly = \(\) => db\.z\(\)/)
+ expect(ssr.code).toMatch(/export const iso = \(\) => db\.w\(\)/)
+ })
+
+ test('renamed re-exports next to a type-only export record', async () => {
+ const { code: output } = await compileValid({
+ env: 'client',
+ files: {
+ '/test/src/f.ts': `export { createServerFn as csf, createServerOnlyFn as so } from '@tanstack/react-start'
+export type { Register } from '@tanstack/react-start'`,
+ },
+ code: `import { csf, so } from './f'
+import { db } from './db.server'
+export const fn = csf().handler(async () => db.x())
+export const s = so(() => db.y())`,
+ })
+ expect(output).toMatch(/csf\(\)\.handler\(createClientRpc\(/)
+ expect(output).toContain(
+ 'createServerOnlyFn() functions can only be called on the server!',
+ )
+ expect(output).not.toContain('db.server')
+ })
+
+ test('a builder reused by several server functions in one module', async () => {
+ const { code: output, serverFns } = await compileValid({
+ env: 'client',
+ files: {
+ '/test/src/authed.ts': `import { createServerFn } from '@tanstack/react-start'
+const base = createServerFn()
+export const authed = base`,
+ },
+ code: `import { authed } from './authed'
+import { db } from './db.server'
+export const a = authed.handler(async () => db.a())
+export const b = authed.handler(async () => db.b())
+export const c = authed.validator((x: string) => x).handler(async () => db.c())`,
+ })
+ expect(Object.keys(serverFns)).toHaveLength(3)
+ expect(output).not.toContain('db.server')
+ // validators are removed from client callers
+ expect(output).toMatch(
+ /export const c = authed\.handler\(createClientRpc\(/,
+ )
+ })
+})
+
+test('a validator without an argument is rejected', async () => {
+ const { compile } = createCompiler({ env: 'client' })
+ await expect(
+ compile(`import { createServerFn } from '@tanstack/react-start'
+export const fn = createServerFn().validator().handler(async () => 1)`),
+ ).rejects.toThrow(
+ 'createServerFn().validator() must be called with a validator!',
+ )
+})
+
+test('invalidating a factory module re-resolves its importers', async () => {
+ const files: Record = {
+ '/test/src/b.ts': `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn()`,
+ '/test/src/c.ts': `export * from './b'`,
+ '/test/src/d.ts': `import { base } from './c'
+export const alias = base`,
+ }
+ const { compiler, compile } = createCompiler({
+ env: 'client',
+ mode: 'dev',
+ files,
+ })
+ const code = `import { base } from './c'
+import { db } from './db.server'
+export const fn = base.handler(async () => db.x())`
+ const id = '/test/src/a.tsx'
+
+ expect(await compile(code, id)).toMatch(/base\.handler\(createClientRpc\(/)
+ compiler.ingestModule({
+ code: files['/test/src/d.ts']!,
+ id: '/test/src/d.ts',
+ })
+ expect(
+ [...(await compiler.getTransitiveImporters('/test/src/b.ts?v=1'))].sort(),
+ ).toEqual(['/test/src/a.tsx', '/test/src/c.ts', '/test/src/d.ts'])
+
+ files['/test/src/b.ts'] =
+ `export const base = { handler: (fn: unknown) => fn }`
+ expect(compiler.invalidateModule('/test/src/b.ts?v=2')).toBe(true)
+ expect(await compile(code, id)).toBeNull()
+
+ files['/test/src/b.ts'] =
+ `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn({ method: 'POST' })`
+ expect([
+ ...compiler.invalidateModules(['/test/src/b.ts', '/test/src/missing.ts']),
+ ]).toEqual(['/test/src/b.ts'])
+ expect(await compile(code, id)).toMatch(/base\.handler\(createClientRpc\(/)
+})
From 966e54d01e2f3392e075217795630eebe74b914f Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 21:48:00 +0200
Subject: [PATCH 0004/1425] test(router-plugin, start-plugin-core,
react-start-rsc): pin known compiler bugs as expected failures
Reproduce 24 compiler/runtime bugs found while validating the Babel to Yuku
migration (#8504) that also break on main. Each test asserts the correct,
user-visible behaviour through public compiler entry points and is marked
`.fails`, so CI stays green and the test flips once a fix lands.
- start compiler: export * cache poisoning, server fn named `opts`,
createServerOnlyFn handlers, unresolved builder chains, generated-name
collisions, nested createServerFn
- code splitter / HMR: shared reassigned let, several createFileRoute calls,
object-form loader, duplicated destructuring initializer, duplicated side
effects, options-variable TDZ, user `hot` binding, CommonJS route HMR
- Hydrate: fallback boundary indices, key/ref captures, await, Route, namespace
import, shadowing, comment-only children with spread, H0 helper collision
- RSC CSS: comments before JSX, options after spread
- import protection: `export * as ns` from a denied module
`validate-module.ts` and `compile-start-module.ts` are copied verbatim from
#8629 (test/compiler-regression-coverage).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tests/known-bugs-rscCssTransform.test.tsx | 94 ++++
.../router-plugin/tests/known-bugs.test.ts | 413 ++++++++++++++++++
.../router-plugin/tests/validate-module.ts | 38 ++
.../tests/compile-start-module.ts | 55 +++
.../tests/importProtection/known-bugs.test.ts | 48 ++
.../tests/known-bugs-hydrate.test.ts | 340 ++++++++++++++
.../tests/known-bugs-start-compiler.test.ts | 360 +++++++++++++++
.../tests/validate-module.ts | 38 ++
8 files changed, 1386 insertions(+)
create mode 100644 packages/react-start-rsc/tests/known-bugs-rscCssTransform.test.tsx
create mode 100644 packages/router-plugin/tests/known-bugs.test.ts
create mode 100644 packages/router-plugin/tests/validate-module.ts
create mode 100644 packages/start-plugin-core/tests/compile-start-module.ts
create mode 100644 packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
create mode 100644 packages/start-plugin-core/tests/known-bugs-hydrate.test.ts
create mode 100644 packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
create mode 100644 packages/start-plugin-core/tests/validate-module.ts
diff --git a/packages/react-start-rsc/tests/known-bugs-rscCssTransform.test.tsx b/packages/react-start-rsc/tests/known-bugs-rscCssTransform.test.tsx
new file mode 100644
index 00000000000..3331b6d9c7e
--- /dev/null
+++ b/packages/react-start-rsc/tests/known-bugs-rscCssTransform.test.tsx
@@ -0,0 +1,94 @@
+/**
+ * Known RSC CSS compiler-transform bugs, pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * transform does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { transformWithOxc } from 'vite'
+import { expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../../start-plugin-core/src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../../start-plugin-core/src/start-compiler/config'
+import { createRscCssCompilerTransforms } from '../src/plugin/rscCssTransform'
+
+async function compileWithRscCssTransform(code: string) {
+ const compilerTransforms = createRscCssCompilerTransforms({
+ loadCssExpression: 'import.meta.viteRsc.loadCss()',
+ })
+ const compiler = new StartCompiler({
+ env: 'server',
+ envName: 'rsc',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'rsc',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv('server', { compilerTransforms }),
+ lookupConfigurations: getLookupConfigurationsForEnv('server', 'react', {
+ compilerTransforms,
+ }),
+ compilerTransforms,
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (id) => id,
+ })
+ const result = await compiler.compile({
+ id: '/test/src/route.tsx',
+ code,
+ detectedKinds: detectKindsInCode(code, 'server', { compilerTransforms }),
+ })
+ return result?.code ?? null
+}
+
+// Bug: when the JSX argument of `renderToReadableStream` is preceded by a
+// comment, the comment is moved inside the generated CSS fragment, where it
+// becomes JSX text. Impact: the comment is rendered into the RSC payload as
+// visible text. Remove `.fails` once fixed.
+test.fails.each([
+ {
+ name: 'a block comment',
+ argument: `/* the card */ `,
+ },
+ {
+ name: 'a line comment',
+ argument: `
+ // the card
+ ,
+`,
+ },
+])(
+ 'renderToReadableStream does not render $name before the JSX argument',
+ async ({ argument }) => {
+ const code = await compileWithRscCssTransform(`
+import { renderToReadableStream } from '@tanstack/react-start/rsc'
+export const stream = renderToReadableStream(${argument})
+`)
+ expect(code).toContain('loadCss()')
+ // Compile JSX to calls: rendered text becomes string literal children.
+ const { code: javascript } = await transformWithOxc(code!, 'route.tsx', {
+ jsx: { runtime: 'classic' },
+ })
+ expect(javascript).not.toMatch(/(["'])[^"'\n]*the card[^"'\n]*\1/)
+ },
+)
+
+// Bug: for `renderServerComponent(...args)` / `createCompositeComponent(...args)`
+// the CSS options object is appended after the spread, so its position
+// depends on how many values the spread holds: with `[element, options]` it
+// becomes an ignored third argument. Impact: the component's CSS is not
+// attached. Remove `.fails` once fixed.
+test.fails.each(['renderServerComponent', 'createCompositeComponent'])(
+ '%s does not append CSS options after a spread argument',
+ async (name) => {
+ const code = await compileWithRscCssTransform(`
+import { ${name} } from '@tanstack/react-start/rsc'
+const args = [ , { extra: true }] as const
+export const value = ${name}(...args)
+`)
+ expect(code ?? '').not.toMatch(/\.\.\.args,\s*\{/)
+ },
+)
diff --git a/packages/router-plugin/tests/known-bugs.test.ts b/packages/router-plugin/tests/known-bugs.test.ts
new file mode 100644
index 00000000000..39ca8e24dfe
--- /dev/null
+++ b/packages/router-plugin/tests/known-bugs.test.ts
@@ -0,0 +1,413 @@
+/**
+ * Known code-splitter and route-HMR bugs, pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { promisify } from 'node:util'
+import { build, parseSync } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { compileCodeSplitReferenceRoute } from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins'
+import { getHandleRouteUpdateCode } from '../src/core/hmr'
+import { tanstackRouter } from '../src/vite'
+import { getModuleErrors } from './validate-module'
+import type { ESTree } from 'vite'
+import type { CodeSplitGroupings } from '../src/core/constants'
+
+const runNode = promisify(execFile)
+
+/**
+ * Builds a small app with the real Vite plugin (code splitting enabled), then
+ * imports the built `entry.ts` in a separate Node process and returns the JSON
+ * value printed by `script`, which has the entry's exports in scope as `entry`.
+ */
+async function buildAndRun(options: {
+ files: Record
+ groupings: CodeSplitGroupings
+ script: string
+}) {
+ // Keep the temporary app inside the package so real runtime imports resolve.
+ const root = await mkdtemp(path.join(__dirname, '.known-bugs-runtime-'))
+ 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({})`,
+ )
+ for (const [file, code] of Object.entries(options.files)) {
+ await writeFile(path.join(root, file), code)
+ }
+ await build({
+ root,
+ configFile: false,
+ logLevel: 'silent',
+ plugins: [
+ tanstackRouter({
+ target: 'react',
+ routesDirectory: './routes',
+ generatedRouteTree: './routeTree.gen.ts',
+ autoCodeSplitting: true,
+ codeSplittingOptions: {
+ addHmr: false,
+ defaultBehavior: options.groupings,
+ },
+ }),
+ ],
+ build: {
+ ssr: path.join(root, 'entry.ts'),
+ outDir: 'dist',
+ minify: false,
+ rollupOptions: {
+ output: { entryFileNames: 'entry.mjs', chunkFileNames: '[name].mjs' },
+ },
+ },
+ })
+ const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href
+ const { stdout } = await runNode(process.execPath, [
+ '--input-type=module',
+ '--eval',
+ `const entry = await import(${JSON.stringify(entryUrl)})
+const result = await (async () => { ${options.script} })()
+process.stdout.write(JSON.stringify(result))`,
+ ])
+ return JSON.parse(stdout) as unknown
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+}
+
+const renderEntry = `import { createElement } from 'react'
+import { renderToString } from 'react-dom/server'
+export * from './routes/index'
+export async function render(component: any) {
+ await component.preload?.()
+ return renderToString(createElement(component))
+}
+`
+
+function compileWithReactRefresh(code: string) {
+ return compileCodeSplitReferenceRoute({
+ code,
+ filename: 'route.tsx',
+ id: 'route.tsx',
+ addHmr: true,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ compilerPlugins: getFrameworkHmrCompilerPlugins({
+ targetFramework: 'react',
+ }),
+ })
+}
+
+/** Names declared by a top-level statement (variables, functions, classes). */
+function declaredNames(statement: ESTree.Statement | ESTree.Directive) {
+ const declaration =
+ statement.type === 'ExportNamedDeclaration'
+ ? statement.declaration
+ : statement
+ if (declaration?.type === 'VariableDeclaration') {
+ return declaration.declarations.flatMap((declarator) =>
+ declarator.id.type === 'Identifier' ? [declarator.id.name] : [],
+ )
+ }
+ if (
+ (declaration?.type === 'FunctionDeclaration' ||
+ declaration?.type === 'ClassDeclaration') &&
+ declaration.id
+ ) {
+ return [declaration.id.name]
+ }
+ return []
+}
+
+describe('known code-splitter bugs', () => {
+ // Bug: a `let` written by the loader (reference module) and read by the split
+ // component is extracted to the shared module and imported by both; imports
+ // are read-only, so the loader's `count++` throws (or the build rejects the
+ // reassignment). Impact: the route breaks as soon as code splitting is on.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'a reassigned let shared between the reference module and a split chunk stays writable',
+ async () => {
+ const result = await buildAndRun({
+ groupings: [['component']],
+ files: {
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+let count = 0
+export const Route = createFileRoute('/')({
+ loader: () => { count++; return count },
+ component: () => {count}
,
+})`,
+ 'entry.ts': renderEntry,
+ },
+ script: `const loaded = [await entry.Route.options.loader({}), await entry.Route.options.loader({})]
+return [...loaded, await entry.render(entry.Route.options.component)]`,
+ })
+ expect(result).toEqual([1, 2, '2
'])
+ },
+ 30_000,
+ )
+
+ // Bug: with several `createFileRoute(...)` calls in one route file, main
+ // points every route at one split chunk (the first route renders the last
+ // route's component); the Yuku PR emits a chunk with a duplicated
+ // `component` export. Impact: wrong component rendered / broken build.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'several createFileRoute calls in one file keep their own split components',
+ async () => {
+ const result = await buildAndRun({
+ groupings: defaultCodeSplitGroupings,
+ files: {
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+function Home() { return home
}
+function Other() { return other
}
+export const Route = createFileRoute('/')({ component: Home })
+export const OtherRoute = createFileRoute('/')({ component: Other })`,
+ 'entry.ts': renderEntry,
+ },
+ script: `return [
+ await entry.render(entry.Route.options.component),
+ await entry.render(entry.OtherRoute.options.component),
+]`,
+ })
+ expect(result).toEqual(['home
', 'other
'])
+ },
+ 30_000,
+ )
+
+ // Bug: the object form `loader: { handler }` cannot be split. Main throws
+ // "Unexpected splitNode type ☝️: ObjectExpression"; the Yuku PR moves the
+ // object into a chunk and wraps it with `lazyFn`, which then calls the
+ // object as a function. Impact: the route's data never loads.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'a split loader in object form still loads route data',
+ async () => {
+ const result = await buildAndRun({
+ groupings: [['loader'], ['component']],
+ files: {
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({
+ loader: { handler: () => 'loaded' },
+ component: () => index
,
+})`,
+ 'entry.ts': `import { createMemoryHistory, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+export async function load() {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+ return router.state.matches.map((match) => match.loaderData ?? null)
+}`,
+ },
+ script: `return await entry.load()`,
+ })
+ expect(result).toEqual([null, 'loaded'])
+ },
+ 30_000,
+ )
+
+ // Bug: for a partially exported destructuring
+ // (`const { a, b } = init(); export { a }`) the whole declaration is copied
+ // into the reference module and the split chunk instead of being shared.
+ // Impact: `init()` runs twice, so module state is duplicated.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'a partially exported destructuring runs its initializer once',
+ async () => {
+ const result = await buildAndRun({
+ groupings: defaultCodeSplitGroupings,
+ files: {
+ 'init.ts': `export function init() {
+ ;(globalThis as any).initCalls = ((globalThis as any).initCalls ?? 0) + 1
+ return { a: 'a', b: 'b' }
+}`,
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+import { init } from '../init'
+const { a, b } = init()
+export { a }
+export const Route = createFileRoute('/')({
+ component: () => {a + b}
,
+})`,
+ 'entry.ts': renderEntry,
+ },
+ script: `const html = await entry.render(entry.Route.options.component)
+return [html, globalThis.initCalls]`,
+ })
+ expect(result).toEqual(['ab
', 1])
+ },
+ 30_000,
+ )
+
+ // Bug: top-level side effects that nothing references (an `if` block, or a
+ // declaration such as `const unsubscribe = store.subscribe(...)` whose
+ // binding is unused) are copied into every split chunk, so they run once
+ // more for each chunk that loads, even for a chunk that only holds the
+ // errorComponent. Impact: duplicated subscriptions/logging/initialization.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'an if statement',
+ effect: `if (typeof globalThis === 'object') {
+ ;(globalThis as any).effects = ((globalThis as any).effects ?? 0) + 1
+}`,
+ },
+ {
+ name: 'an unused subscription',
+ effect: `const subscribe = (listener: () => void) => {
+ ;(globalThis as any).effects = ((globalThis as any).effects ?? 0) + 1
+ return listener
+}
+const unsubscribe = subscribe(() => {})`,
+ },
+ ])(
+ 'an unreferenced top-level side effect ($name) runs once when split chunks load',
+ async ({ effect }) => {
+ const result = await buildAndRun({
+ groupings: defaultCodeSplitGroupings,
+ files: {
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+${effect}
+export const Route = createFileRoute('/')({
+ component: () => index
,
+ errorComponent: () => error
,
+})`,
+ 'entry.ts': renderEntry,
+ },
+ script: `await entry.Route.options.component.preload?.()
+await entry.Route.options.errorComponent.preload?.()
+return globalThis.effects`,
+ })
+ expect(result).toBe(1)
+ },
+ 30_000,
+ )
+})
+
+describe('known React Refresh / route HMR bugs', () => {
+ // Bug: when route options are passed through a variable, the HMR transforms
+ // replace `component` in the options object with a generated binding
+ // (`TSRComponent` / `TSRSplitComponent`) that is declared AFTER the object
+ // literal reading it. Impact: the route module throws a TDZ ReferenceError in
+ // development. Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'an unsplittable root route',
+ code: `import { createRootRoute, Outlet } from '@tanstack/react-router'
+const options = {
+ component: () => ,
+}
+export const Route = createRootRoute(options)`,
+ },
+ {
+ name: 'a split file route',
+ code: `import { createFileRoute } from '@tanstack/react-router'
+const options = {
+ component: () => home
,
+}
+export const Route = createFileRoute('/')(options)`,
+ },
+ ])(
+ 'route options in a variable read a component declared before them ($name)',
+ ({ code }) => {
+ const result = compileWithReactRefresh(code)
+ expect(result).toBeTruthy()
+ const { program } = parseSync('route.tsx', result!.code, {
+ sourceType: 'module',
+ })
+ const statements = program.body
+ const optionsIndex = statements.findIndex((statement) =>
+ declaredNames(statement).includes('options'),
+ )
+ expect(optionsIndex).toBeGreaterThanOrEqual(0)
+ const declaration = statements[optionsIndex] as ESTree.VariableDeclaration
+ const init = declaration.declarations[0]!.init
+ expect(init?.type).toBe('ObjectExpression')
+ const component = (init as ESTree.ObjectExpression).properties.find(
+ (property): property is ESTree.ObjectProperty =>
+ property.type === 'Property' &&
+ property.key.type === 'Identifier' &&
+ property.key.name === 'component',
+ )
+ expect(component).toBeDefined()
+ if (component!.value.type === 'Identifier') {
+ const name = component!.value.name
+ const declarationIndex = statements.findIndex((statement) =>
+ declaredNames(statement).includes(name),
+ )
+ // The referenced binding must be initialized before `options` reads it.
+ expect(declarationIndex).toBeGreaterThanOrEqual(0)
+ expect(declarationIndex).toBeLessThan(optionsIndex)
+ }
+ },
+ )
+
+ // Bug: the React Refresh plugin injects a top-level `const hot =
+ // import.meta.hot`, which collides with a user's top-level `hot` binding.
+ // Main throws `Duplicate declaration "hot"` at compile time; the Yuku PR
+ // emits a module that redeclares `hot`. Impact: the route cannot be served
+ // in development. Remove `.fails` once fixed.
+ test.fails(
+ 'a user binding named hot does not collide with the injected HMR code',
+ async () => {
+ const result =
+ compileWithReactRefresh(`import { createFileRoute } from '@tanstack/react-router'
+export const hot = 'hot'
+export const Route = createFileRoute('/')({
+ component: () => {hot}
,
+})`)
+ expect(result).toBeTruthy()
+ expect(await getModuleErrors(result!.code)).toEqual([])
+ expect(result!.code).toMatch(/export const hot = ['"]hot['"]/)
+ },
+ )
+})
+
+describe('known router-core CommonJS bugs', () => {
+ // Bug: in the router-core CommonJS build, `index.cjs` requires
+ // `load-client.cjs`, which requires `router.cjs`, which reads
+ // `require_load_client.replaceRouteChunk` while `load-client.cjs` is still
+ // initializing. `RouterCore.prototype._replaceRouteChunk` stays undefined
+ // and Node warns "Accessing non-existent property 'replaceRouteChunk' of
+ // module exports inside circular dependency". Impact: for CommonJS
+ // consumers (`require('@tanstack/react-router')`), the generated route HMR
+ // handler throws `router._replaceRouteChunk is not a function` on every
+ // route update. Remove `.fails` once fixed.
+ test.fails(
+ 'route HMR works with the CommonJS build of @tanstack/react-router',
+ async () => {
+ const script = `globalThis.window = globalThis
+const { createMemoryHistory, createRootRoute, createRoute, createRouter } = require('@tanstack/react-router')
+const rootRoute = createRootRoute({})
+const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', loader: () => 'old' })
+window.__TSR_ROUTER__ = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory(),
+})
+const handleRouteUpdate = new Function(${JSON.stringify(`return ${getHandleRouteUpdateCode([])}`)})()
+const newRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', loader: () => 'new' })
+handleRouteUpdate(indexRoute.id, newRoute)
+process.stdout.write(indexRoute.options.loader())`
+ const { stdout, stderr } = await runNode(
+ process.execPath,
+ ['--eval', script],
+ { cwd: __dirname, env: { ...process.env, NODE_ENV: 'development' } },
+ ).catch((error: { stdout: string; stderr: string }) => error)
+ const error = stderr.match(/\w*Error: .*/)?.[0] ?? null
+ expect({ stdout, error }).toEqual({ stdout: 'new', error: null })
+ },
+ 30_000,
+ )
+})
diff --git a/packages/router-plugin/tests/validate-module.ts b/packages/router-plugin/tests/validate-module.ts
new file mode 100644
index 00000000000..4e0884a4368
--- /dev/null
+++ b/packages/router-plugin/tests/validate-module.ts
@@ -0,0 +1,38 @@
+import { stripVTControlCharacters } from 'node:util'
+import { parseSync, transformWithOxc } from 'vite'
+
+/**
+ * Validates generated code the way a browser or bundler sees it, without using
+ * the compiler's own parser: erase TypeScript (keeping every value import) and
+ * parse the result as a JavaScript module with semantic checks. This rejects
+ * syntax errors, nameless function/class declarations, duplicate bindings
+ * (including an import redeclared by a local declaration) and exports of
+ * undeclared bindings.
+ *
+ * @returns the error messages; an empty array means the module is valid.
+ */
+export async function getModuleErrors(
+ code: string,
+ filename = 'module.tsx',
+): Promise> {
+ let javascript: string
+ try {
+ const result = await transformWithOxc(code, filename, {
+ jsx: 'preserve',
+ typescript: { onlyRemoveTypeImports: true },
+ })
+ javascript = result.code
+ } catch (error) {
+ return [stripVTControlCharacters((error as Error).message)]
+ }
+ const { errors } = parseSync('module.jsx', javascript, {
+ sourceType: 'module',
+ showSemanticErrors: true,
+ })
+ return errors.map((error) => error.message)
+}
+
+/** Matches a declaration of `name` in generated code, however it is printed. */
+export function declarationOf(name: string): RegExp {
+ return new RegExp(String.raw`\b(?:const|let|var|function|class)\s+${name}\b`)
+}
diff --git a/packages/start-plugin-core/tests/compile-start-module.ts b/packages/start-plugin-core/tests/compile-start-module.ts
new file mode 100644
index 00000000000..dd490e97057
--- /dev/null
+++ b/packages/start-plugin-core/tests/compile-start-module.ts
@@ -0,0 +1,55 @@
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import type { StartCompilerPlugin } from '../src/types'
+
+/**
+ * Compiles one module through `StartCompiler` the way the bundler plugins do:
+ * React lookups for the environment, kinds pre-detected from the source, and
+ * `@tanstack/*` imports resolved as packages.
+ */
+export async function compileStartModule(options: {
+ env: 'client' | 'server'
+ code: string
+ /** Compile the server-function provider module (`?tss-serverfn-split`). */
+ provider?: boolean
+ /** Extra project modules by absolute id; `./name` resolves to `/test/src/name.ts`. */
+ files?: Record
+ compilerPlugins?: Array
+}) {
+ const { env, files = {} } = options
+ const compiler: StartCompiler = new StartCompiler({
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ getKnownServerFns: () => ({}),
+ loadModule: async (id) => {
+ const code = files[id]
+ if (code !== undefined) {
+ compiler.ingestModule({ code, id })
+ }
+ },
+ resolveId: async (id) => {
+ if (id.startsWith('@tanstack/')) {
+ return id
+ }
+ const file = id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id
+ return file in files ? file : null
+ },
+ compilerPlugins: options.compilerPlugins,
+ })
+ const result = await compiler.compile({
+ code: options.code,
+ id: `/test/src/module.tsx${options.provider ? '?tss-serverfn-split' : ''}`,
+ detectedKinds: detectKindsInCode(options.code, env),
+ })
+ return result?.code ?? null
+}
diff --git a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
new file mode 100644
index 00000000000..c7bd842ac00
--- /dev/null
+++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
@@ -0,0 +1,48 @@
+/**
+ * Known import-protection bugs, pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * rewrite does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { transformWithOxc } from 'vite'
+import { expect, test } from 'vitest'
+import { rewriteDeniedImports } from '../../src/import-protection/rewrite'
+
+const mockModule = `data:text/javascript,${encodeURIComponent(
+ `export default new Proxy({}, { get: (_, key) => typeof key === 'string' ? 'mock:' + key : undefined })`,
+)}`
+
+async function evaluateRewritten(code: string, denied: Array) {
+ const rewritten = rewriteDeniedImports(
+ code,
+ '/test/module.ts',
+ new Set(denied),
+ () => mockModule,
+ )
+ expect(rewritten).toBeDefined()
+ const { code: javascript } = await transformWithOxc(
+ rewritten!.code,
+ 'module.ts',
+ )
+ return (await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(javascript)}`
+ )) as Record
+}
+
+// Bug: rewriting a denied `export * as ns from 'denied'` drops the `ns`
+// export (main imports the mock without re-exporting it; the Yuku PR removes
+// the statement). Impact: importers of `ns` get `undefined` (or a missing
+// export error) instead of the mock, unlike every other denied import form.
+// Remove `.fails` once fixed.
+test.fails('a denied namespace re-export keeps its exported name', async () => {
+ const exports = await evaluateRewritten(
+ `export * as ns from 'denied'
+export const keep = 1`,
+ ['denied'],
+ )
+ expect(exports.keep).toBe(1)
+ expect(Object.keys(exports)).toContain('ns')
+ expect(exports.ns).toBeDefined()
+})
diff --git a/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts
new file mode 100644
index 00000000000..5bf39a9cb27
--- /dev/null
+++ b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts
@@ -0,0 +1,340 @@
+/**
+ * Known `` split-transform bugs, pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { parseSync, transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform'
+import { compileStartModule } from './compile-start-module'
+import { getModuleErrors } from './validate-module'
+
+/** Compiles a module with `` and loads every split chunk it imports. */
+async function compileHydrate(env: 'client' | 'server', code: string) {
+ const plugin = createHydrateCompilerPlugin()
+ const parent = await compileStartModule({
+ env,
+ code,
+ compilerPlugins: [plugin],
+ })
+ const loadChunk = (id: string) => {
+ const chunk = plugin.loadVirtualModule?.({
+ id,
+ root: '/test',
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ })
+ if (!chunk) {
+ throw new Error(`expected virtual module ${id} to load`)
+ }
+ return chunk.code
+ }
+ const chunkIds = [...(parent ?? '').matchAll(/import\((["'])(.+?)\1\)/g)].map(
+ ([, , id]) => id!,
+ )
+ return { parent, chunkIds, loadChunk }
+}
+
+/**
+ * Evaluates a self-contained chunk (no imports) and renders its only exported
+ * component to a string: JSX becomes plain calls, intrinsic elements tags.
+ */
+async function renderChunk(chunk: string, props: Record = {}) {
+ const { code } = await transformWithOxc(chunk, 'chunk.tsx', {
+ jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' },
+ })
+ const runtime = `const Fragment = Symbol('Fragment')
+const h = (type, props, ...children) => {
+ const text = children.flat(Infinity).filter((c) => c != null && c !== false).join('')
+ if (type === Fragment) return text
+ if (typeof type === 'function') return type({ ...props, children: text })
+ return '<' + type + '>' + text + '' + type + '>'
+}
+`
+ const module: Record string> = await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(runtime + code)}`
+ )
+ const components = Object.values(module)
+ expect(components).toHaveLength(1)
+ return components[0]!(props)
+}
+
+type JsxNode = {
+ type: string
+ name?: { type: string; name?: string }
+ openingElement?: {
+ name: { type: string; name?: string }
+ attributes: Array<{
+ type: string
+ name?: { name: string }
+ value?: { type: string; expression?: { type: string; name?: string } }
+ }>
+ }
+ expression?: JsxNode
+ children?: Array
+}
+
+/** Finds the JSX elements named `name` in a module (Oxc ESTree), in order. */
+function findJsxElements(code: string, name: string): Array {
+ const { program } = parseSync('module.tsx', code, { sourceType: 'module' })
+ const found: Array = []
+ const visit = (node: unknown): void => {
+ if (!node || typeof node !== 'object') {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach(visit)
+ return
+ }
+ const current = node as JsxNode
+ if (
+ current.type === 'JSXElement' &&
+ current.openingElement?.name.name === name
+ ) {
+ found.push(current)
+ }
+ Object.values(node).forEach(visit)
+ }
+ visit(program)
+ return found
+}
+
+/** Children that render something: no whitespace text, no `{}` or comments. */
+function renderedChildren(element: JsxNode) {
+ return (element.children ?? []).filter(
+ (child) =>
+ !(child.type === 'JSXText' && child.value!.trim() === '') &&
+ !(
+ child.type === 'JSXExpressionContainer' &&
+ child.expression?.type === 'JSXEmptyExpression'
+ ),
+ )
+}
+
+describe('known Hydrate split bugs', () => {
+ // Bug: the server strips `fallback` (and any Hydrate inside it) before
+ // numbering boundaries, the client numbers boundaries without visiting the
+ // fallback, but the virtual-module loader walk counts the Hydrate inside the
+ // fallback. The ids stay aligned between server and client, but the chunk
+ // loaded for the next boundary contains the fallback's children. Impact: a
+ // later boundary hydrates with the wrong content. Remove `.fails` once fixed.
+ test.fails(
+ 'a Hydrate inside a fallback does not shift the chunks of later boundaries',
+ async () => {
+ const code = `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return
+
inner
}>
outer
+
second
+
+}`
+ const server = await compileHydrate('server', code)
+ const client = await compileHydrate('client', code)
+ const boundaryIds = (output: string) =>
+ findJsxElements(output, 'Hydrate').flatMap((element) =>
+ element.openingElement!.attributes.flatMap((attribute) =>
+ attribute.name?.name === 'h' && attribute.value?.type === 'Literal'
+ ? [(attribute.value as unknown as { value: string }).value]
+ : [],
+ ),
+ )
+ const serverIds = boundaryIds(server.parent!)
+ expect(serverIds).toHaveLength(2)
+ // The client renders the same two boundaries with the same ids.
+ expect(boundaryIds(client.parent!)).toEqual(
+ expect.arrayContaining(serverIds),
+ )
+ const rendered: Array = []
+ for (const id of serverIds) {
+ const chunkId = client.chunkIds.find((chunk) =>
+ chunk.endsWith(`=${id}`),
+ )
+ expect(chunkId).toBeDefined()
+ rendered.push(await renderChunk(client.loadChunk(chunkId!)))
+ }
+ expect(rendered).toEqual(['outer
', 'second
'])
+ },
+ )
+
+ // Bug: captured locals are passed to the lazy chunk component under their
+ // own names, so locals named `key` or `ref` become `key={key}` /
+ // `ref={ref}`, which React consumes instead of passing them as props.
+ // Impact: the split children render without those values.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'captured locals named key and ref reach the split children',
+ async () => {
+ const { parent, chunkIds, loadChunk } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ const key = 'k'
+ const ref = 'r'
+ return {key}{ref}
+}`,
+ )
+ expect(chunkIds).toHaveLength(1)
+ const [hydrate] = findJsxElements(parent!, 'Hydrate')
+ const [container] = renderedChildren(hydrate!)
+ const lazyElement =
+ container?.type === 'JSXExpressionContainer'
+ ? container.expression
+ : container
+ expect(lazyElement?.type).toBe('JSXElement')
+ const locals: Record = { key: 'k', ref: 'r' }
+ const props: Record = {}
+ for (const attribute of lazyElement!.openingElement!.attributes) {
+ const name = attribute.name!.name
+ // React keeps `key` and `ref` for itself.
+ if (name === 'key' || name === 'ref') {
+ continue
+ }
+ props[name] = locals[attribute.value!.expression!.name!]
+ }
+ expect(await renderChunk(loadChunk(chunkIds[0]!), props)).toBe(
+ 'kr
',
+ )
+ },
+ )
+
+ // Bug: split children containing `await` (async server components) are
+ // moved into a synchronous chunk component. Impact: the chunk is not a
+ // valid module and the build fails. A clear compile-time error is an
+ // acceptable fix too. Remove `.fails` once fixed.
+ test.fails(
+ 'await in split children never produces a broken chunk',
+ async () => {
+ let compiled: Awaited>
+ try {
+ compiled = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import { load } from './data'
+export async function Page() {
+ return {await load()}
+}`,
+ )
+ } catch {
+ return
+ }
+ for (const id of compiled.chunkIds) {
+ expect(await getModuleErrors(compiled.loadChunk(id))).toEqual([])
+ }
+ },
+ )
+
+ // Bug: children that read the module's `Route` are moved into a chunk that
+ // neither imports, declares nor receives `Route`. Impact: rendering the
+ // boundary throws `ReferenceError: Route is not defined`. A clear
+ // compile-time error is an acceptable fix too. Remove `.fails` once fixed.
+ test.fails(
+ 'Route referenced in split children is bound in the chunk',
+ async () => {
+ let compiled: Awaited>
+ try {
+ compiled = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({ component: Page })
+function Page() {
+ return {Route.id}
+}`,
+ )
+ } catch {
+ return
+ }
+ expect(compiled.chunkIds).toHaveLength(1)
+ const chunk = compiled.loadChunk(compiled.chunkIds[0]!)
+ expect(chunk).toMatch(
+ /\bimport\b[^;]*\bRoute\b[^;]*\bfrom\b|\b(?:const|let|var|function|class)\s+Route\b|export function \w+\(\{[^}]*\bRoute\b/,
+ )
+ },
+ )
+
+ // Bug: only named `Hydrate` imports are recognized; `` from
+ // `import * as Start from '@tanstack/react-start'` is left untransformed.
+ // Impact: the boundary gets no id and its children are not split.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'a namespace-imported is transformed',
+ async () => {
+ const { parent, chunkIds } = await compileHydrate(
+ 'client',
+ `import * as Start from '@tanstack/react-start'
+export function Page() {
+ return child
+}`,
+ )
+ expect(parent).not.toBeNull()
+ expect(parent).toMatch(/\bh="/)
+ expect(chunkIds).toHaveLength(1)
+ },
+ )
+
+ // Bug: JSX elements are matched by name only, so a local component that
+ // shadows the imported `Hydrate` is split and given a boundary id too.
+ // Impact: the local component's children are replaced by a lazy chunk.
+ // Remove `.fails` once fixed.
+ test.fails('a local binding shadowing Hydrate is left alone', async () => {
+ const { parent, chunkIds } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return real
+}
+export function Local() {
+ const Hydrate = (props: { children: unknown }) => props.children
+ return local
+}`,
+ )
+ expect(chunkIds).toHaveLength(1)
+ expect(parent).toContain('local
')
+ })
+
+ // Bug: a boundary whose only children are comments is still split, and the
+ // injected lazy child overrides the `children` passed through a spread.
+ // Impact: the spread children are never rendered.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'comment-only children keep the children passed through a spread',
+ async () => {
+ const { parent } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page(props: { children: unknown }) {
+ return {/* filled by props */}
+}`,
+ )
+ const output =
+ parent ?? `{/* filled by props */} `
+ const [hydrate] = findJsxElements(output, 'Hydrate')
+ expect(renderedChildren(hydrate!)).toEqual([])
+ },
+ )
+
+ // Bug: module-level helpers used only by the split children move into the
+ // chunk, where a helper named like the generated export (`H0`) collides
+ // with it. Impact: the chunk is not a valid module and the build fails.
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'a helper named like the generated chunk export does not collide',
+ async () => {
+ const { chunkIds, loadChunk } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+function H0() { return helper }
+export function Page() {
+ return
+}`,
+ )
+ expect(chunkIds).toHaveLength(1)
+ const chunk = loadChunk(chunkIds[0]!)
+ expect(await getModuleErrors(chunk)).toEqual([])
+ expect(await renderChunk(chunk)).toBe('helper ')
+ },
+ )
+})
diff --git a/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts b/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
new file mode 100644
index 00000000000..e0ffc038ed0
--- /dev/null
+++ b/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
@@ -0,0 +1,360 @@
+/**
+ * Known Start compiler bugs, pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import { compileStartModule } from './compile-start-module'
+import { declarationOf, getModuleErrors } from './validate-module'
+
+/**
+ * One build-mode client compiler shared by several modules, like a production
+ * build compiles every importer with the same compiler instance.
+ */
+function createBuildCompiler(files: Record) {
+ const compiler: StartCompiler = new StartCompiler({
+ env: 'client',
+ envName: 'client',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv('client'),
+ lookupConfigurations: getLookupConfigurationsForEnv('client', 'react'),
+ getKnownServerFns: () => ({}),
+ loadModule: async (id) => {
+ const code = files[id]
+ if (code !== undefined) {
+ compiler.ingestModule({ code, id })
+ }
+ },
+ resolveId: async (id) => {
+ if (id.startsWith('@tanstack/')) {
+ return id
+ }
+ const file = id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id
+ return file in files ? file : null
+ },
+ })
+ return async (id: string) => {
+ const code = files[id]!
+ const result = await compiler.compile({
+ code,
+ id,
+ detectedKinds: detectKindsInCode(code, 'client'),
+ })
+ // An untransformed module ships its source as is.
+ return result?.code ?? code
+ }
+}
+
+/** Server-only code that must never reach the client bundle. */
+const serverOnlyImport = './db.server'
+
+describe('known Start compiler bugs: server code in the client bundle', () => {
+ // Bug: `findExportInModule` caches export lookups per module in build mode,
+ // but the `visitedModules` set is shared between the parallel `export *`
+ // branches. In a diamond (`a` re-exports `b` and `c`, both re-export `d`) or
+ // a cycle, the branch that reaches an already-visited module caches "not
+ // found" for its own module. A later importer of that module is then left
+ // untransformed. Impact: its server handler and server-only imports ship to
+ // the client bundle. Remove `.fails` once fixed.
+ test.fails(
+ 'export * diamonds do not poison the build-mode export cache',
+ async () => {
+ const compile = createBuildCompiler({
+ '/test/src/d.ts': `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn()`,
+ '/test/src/b.ts': `export * from './d'`,
+ '/test/src/c.ts': `export * from './d'`,
+ '/test/src/a.ts': `export * from './b'
+export * from './c'`,
+ '/test/src/via-a.tsx': `import { base } from './a'
+import { db } from './db.server'
+export const viaA = base.handler(async () => db.a())`,
+ '/test/src/via-b.tsx': `import { base } from './b'
+import { db } from './db.server'
+export const viaB = base.handler(async () => db.b())`,
+ '/test/src/via-c.tsx': `import { base } from './c'
+import { db } from './db.server'
+export const viaC = base.handler(async () => db.c())`,
+ })
+ const leaked: Array = []
+ for (const id of [
+ '/test/src/via-a.tsx',
+ '/test/src/via-b.tsx',
+ '/test/src/via-c.tsx',
+ ]) {
+ if ((await compile(id)).includes(serverOnlyImport)) {
+ leaked.push(id)
+ }
+ }
+ expect(leaked).toEqual([])
+ },
+ )
+
+ test.fails(
+ 'export * cycles do not poison the build-mode export cache',
+ async () => {
+ const compile = createBuildCompiler({
+ '/test/src/d.ts': `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn()`,
+ '/test/src/a.ts': `export * from './b'
+export * from './d'`,
+ '/test/src/b.ts': `export * from './a'`,
+ '/test/src/via-a.tsx': `import { base } from './a'
+import { db } from './db.server'
+export const viaA = base.handler(async () => db.a())`,
+ '/test/src/via-b.tsx': `import { base } from './b'
+import { db } from './db.server'
+export const viaB = base.handler(async () => db.b())`,
+ })
+ const leaked: Array = []
+ for (const id of ['/test/src/via-a.tsx', '/test/src/via-b.tsx']) {
+ if ((await compile(id)).includes(serverOnlyImport)) {
+ leaked.push(id)
+ }
+ }
+ expect(leaked).toEqual([])
+ },
+ )
+
+ // Bug: chains whose builder is reached through a namespace member, a
+ // namespace re-export of the Start package, or a middleware/isomorphic
+ // builder finished in a file without the detection text are not resolved.
+ // The module is left untransformed. Impact: server handlers and server-only
+ // imports ship to the client bundle. Remove `.fails` once fixed.
+ test.fails.each<{
+ name: string
+ files: Record
+ code: string
+ }>([
+ {
+ name: 'a namespace member builder (F.authed.handler)',
+ files: {
+ '/test/src/fns.ts': `import { createServerFn } from '@tanstack/react-start'
+export const authed = createServerFn({ method: 'POST' })`,
+ },
+ code: `import * as F from './fns'
+import { db } from './db.server'
+export const fn = F.authed.handler(async () => db.x())`,
+ },
+ {
+ name: 'a namespace re-export of the Start package (Start.createServerFn)',
+ files: {
+ '/test/src/start.ts': `export * as Start from '@tanstack/react-start'`,
+ },
+ code: `import { Start } from './start'
+import { db } from './db.server'
+export const fn = Start.createServerFn().handler(async () => db.x())`,
+ },
+ {
+ name: 'a middleware builder finished in another file (mw.server)',
+ files: {
+ '/test/src/mw.ts': `import { createMiddleware } from '@tanstack/react-start'
+export const mw = createMiddleware({ type: 'function' })`,
+ },
+ code: `import { mw } from './mw'
+import { db } from './db.server'
+export const logged = mw.server(async ({ next }) => { db.log(); return next() })`,
+ },
+ {
+ name: 'an isomorphic builder finished in another file (iso.server)',
+ files: {
+ '/test/src/iso.ts': `import { createIsomorphicFn } from '@tanstack/react-start'
+export const iso = createIsomorphicFn()`,
+ },
+ code: `import { iso } from './iso'
+import { db } from './db.server'
+export const fn = iso.server(() => db.x()).client(() => 'client')`,
+ },
+ ])(
+ 'client builds do not ship the server code of $name',
+ async ({ files, code }) => {
+ const output =
+ (await compileStartModule({ env: 'client', files, code })) ?? code
+ expect(output).not.toContain(serverOnlyImport)
+ },
+ )
+
+ // Bug: a `createServerFn` that is not declared at the module top level
+ // (function body, switch case, block) is left untransformed. Impact: its
+ // server handler and server-only imports ship to the client bundle. The
+ // fix may also reject such declarations with a compile error.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'a function body',
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export function make() {
+ const inner = createServerFn().handler(async () => db.inner())
+ return inner
+}`,
+ },
+ {
+ name: 'a function body next to a top-level server fn',
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export const top = createServerFn().handler(async () => db.top())
+export function make() {
+ const inner = createServerFn().handler(async () => db.inner())
+ return inner
+}`,
+ },
+ {
+ name: 'a switch case',
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export function pick(kind: string) {
+ switch (kind) {
+ case 'a':
+ const inner = createServerFn().handler(async () => db.inner())
+ return inner
+ }
+}`,
+ },
+ {
+ name: 'a top-level block',
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export const registry: Array = []
+{
+ const inner = createServerFn().handler(async () => db.inner())
+ registry.push(inner)
+}`,
+ },
+ ])(
+ 'a createServerFn nested in $name does not ship its handler to the client',
+ async ({ code }) => {
+ let output: string
+ try {
+ output = (await compileStartModule({ env: 'client', code })) ?? code
+ } catch {
+ // Rejecting the declaration at compile time also keeps it out of the client.
+ return
+ }
+ expect(output).not.toContain('db.inner()')
+ },
+ )
+})
+
+describe('known Start compiler bugs: server function handlers', () => {
+ /**
+ * Evaluates a provider module with minimal runtime stubs and returns its
+ * only export: the function the server-function router calls for a request.
+ */
+ async function loadProviderHandler(provider: string) {
+ const stub = (code: string) =>
+ `data:text/javascript,${encodeURIComponent(code)}`
+ const runtime: Record = {
+ '@tanstack/react-start/server-rpc': stub(
+ `export const createServerRpc = (meta, fn) => fn`,
+ ),
+ '@tanstack/react-start': stub(
+ `export const createServerFn = () => ({ handler: (_rpc, impl) => ({ __executeServer: (opts) => impl(opts) }) })`,
+ ),
+ }
+ const { code } = await transformWithOxc(provider, 'provider.ts')
+ const linked = code.replace(
+ /from\s*(["'])([^"']+)\1/g,
+ (match, _quote: string, source: string) =>
+ source in runtime ? `from ${JSON.stringify(runtime[source])}` : match,
+ )
+ const module: Record unknown> = await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(linked)}`
+ )
+ const handlers = Object.values(module)
+ expect(handlers).toHaveLength(1)
+ return handlers[0]!
+ }
+
+ // Bug: the provider module wraps each handler as
+ // `createServerRpc(meta, (opts) => .__executeServer(opts))`. For a
+ // server fn named `opts`, the parameter shadows it and the declaration of
+ // `opts` is removed as unused. Impact: every call of that server fn fails
+ // on the server. Remove `.fails` once fixed.
+ test.fails(
+ 'a server fn named opts keeps working in the provider',
+ async () => {
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code: `import { createServerFn } from '@tanstack/react-start'
+export const opts = createServerFn().handler(async () => 'from the server')`,
+ })
+ expect(provider).not.toBeNull()
+ expect(provider).toMatch(declarationOf('opts'))
+ const handler = await loadProviderHandler(provider!)
+ expect(await handler({ data: undefined })).toBe('from the server')
+ },
+ )
+
+ // Bug: when the handler passed to `.handler()` is itself a Start call
+ // (`createServerOnlyFn(fn)`), the inner call is compiled first. Main then
+ // silently miscompiles the server fn: the client caller gets the
+ // server-only "can only be called on the server" stub instead of an RPC,
+ // and the SSR caller gets a bare id string. The Yuku PR throws "Cannot
+ // replace a detached output node". Impact: the server fn cannot be called.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ { env: 'client' as const, rpc: 'createClientRpc' },
+ { env: 'server' as const, rpc: 'createSsrRpc' },
+ ])(
+ 'a createServerOnlyFn handler still produces a $rpc caller',
+ async ({ env, rpc }) => {
+ const output = await compileStartModule({
+ env,
+ code: `import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export const fn = createServerFn().handler(createServerOnlyFn(async () => db.x()))`,
+ })
+ expect(output).not.toBeNull()
+ expect(output).toMatch(new RegExp(String.raw`\.handler\(${rpc}\(`))
+ expect(output).not.toContain('can only be called on the server')
+ expect(await getModuleErrors(output!)).toEqual([])
+ },
+ )
+
+ // Bug: generated names are not checked against user bindings. A user binding
+ // named like a generated import (`createClientRpc`, `createSsrRpc`,
+ // `createServerRpc`) or like a generated handler
+ // (`fn_createServerFn_handler`) collides with it. Main throws
+ // `Duplicate declaration` at compile time; the Yuku PR emits a module that
+ // redeclares the name. Impact: the module cannot be built.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ { name: 'createClientRpc', env: 'client' as const, provider: false },
+ { name: 'createSsrRpc', env: 'server' as const, provider: false },
+ { name: 'createServerRpc', env: 'server' as const, provider: true },
+ {
+ name: 'fn_createServerFn_handler',
+ env: 'server' as const,
+ provider: true,
+ },
+ ])(
+ 'a user binding named $name does not collide with generated code',
+ async ({ name, env, provider }) => {
+ const output = await compileStartModule({
+ env,
+ provider,
+ code: `import { createServerFn } from '@tanstack/react-start'
+const ${name} = (value: unknown) => value
+export const used = ${name}(1)
+export const fn = createServerFn().handler(async () => 1)`,
+ })
+ expect(output).not.toBeNull()
+ expect(await getModuleErrors(output!)).toEqual([])
+ },
+ )
+})
diff --git a/packages/start-plugin-core/tests/validate-module.ts b/packages/start-plugin-core/tests/validate-module.ts
new file mode 100644
index 00000000000..4e0884a4368
--- /dev/null
+++ b/packages/start-plugin-core/tests/validate-module.ts
@@ -0,0 +1,38 @@
+import { stripVTControlCharacters } from 'node:util'
+import { parseSync, transformWithOxc } from 'vite'
+
+/**
+ * Validates generated code the way a browser or bundler sees it, without using
+ * the compiler's own parser: erase TypeScript (keeping every value import) and
+ * parse the result as a JavaScript module with semantic checks. This rejects
+ * syntax errors, nameless function/class declarations, duplicate bindings
+ * (including an import redeclared by a local declaration) and exports of
+ * undeclared bindings.
+ *
+ * @returns the error messages; an empty array means the module is valid.
+ */
+export async function getModuleErrors(
+ code: string,
+ filename = 'module.tsx',
+): Promise> {
+ let javascript: string
+ try {
+ const result = await transformWithOxc(code, filename, {
+ jsx: 'preserve',
+ typescript: { onlyRemoveTypeImports: true },
+ })
+ javascript = result.code
+ } catch (error) {
+ return [stripVTControlCharacters((error as Error).message)]
+ }
+ const { errors } = parseSync('module.jsx', javascript, {
+ sourceType: 'module',
+ showSemanticErrors: true,
+ })
+ return errors.map((error) => error.message)
+}
+
+/** Matches a declaration of `name` in generated code, however it is printed. */
+export function declarationOf(name: string): RegExp {
+ return new RegExp(String.raw`\b(?:const|let|var|function|class)\s+${name}\b`)
+}
From 4c4912ff5a69f1ac8fbada686ad9a85e2badf28a Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:18:13 +0200
Subject: [PATCH 0005/1425] test(start-plugin-core): port Next.js server
actions fixtures to createServerFn
Translate the scenarios of Next.js's server-actions transform fixtures
(crates/next-custom-transforms/tests/fixture/server-actions) to the
createServerFn client, SSR caller and provider outputs: captured module
bindings, shadowing, destructuring, handler forms, directives, export
forms, non-ASCII names and client/server-only code side by side.
The destructuring default and computed-key cases pass on main and fail
with the Yuku compiler (#8504), which keeps every sibling of a live
destructuring and with it a server-only import in the client output.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../ported-nextjs-server-actions.test.ts | 775 ++++++++++++++++++
1 file changed, 775 insertions(+)
create mode 100644 packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts
diff --git a/packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts b/packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts
new file mode 100644
index 00000000000..6d0dc61f123
--- /dev/null
+++ b/packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts
@@ -0,0 +1,775 @@
+/**
+ * Edge cases ported from the Next.js server actions transform fixtures
+ * (vercel/next.js, MIT): crates/next-custom-transforms/tests/fixture/server-actions.
+ * Each test names the fixture directories (`server-graph/N`, `client-graph/N`)
+ * whose scenario it translates to `createServerFn`.
+ */
+import { transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../../src/start-compiler/config'
+import { declarationOf, getModuleErrors } from '../validate-module'
+import type { ServerFn } from '../../src/start-compiler/types'
+
+type Output = 'client' | 'ssr' | 'provider'
+const outputs: Array = ['client', 'ssr', 'provider']
+
+async function compileFor(
+ output: Output,
+ code: string,
+ mode: 'build' | 'dev' = 'build',
+) {
+ const env = output === 'client' ? 'client' : 'server'
+ const serverFns: Record = {}
+ const compiler = new StartCompiler({
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode,
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ getKnownServerFns: () => ({}),
+ devServerFnModuleSpecifierEncoder: ({ extractedFilename, root }) =>
+ `/@id${extractedFilename.slice(root.length)}`,
+ onServerFnsById: (fns) => Object.assign(serverFns, fns),
+ loadModule: async () => {},
+ resolveId: async (id) => (id.startsWith('@tanstack/') ? id : null),
+ })
+ const id = '/test/src/module.tsx'
+ const result = await compiler.compile({
+ code,
+ id: output === 'provider' ? `${id}?tss-serverfn-split` : id,
+ detectedKinds: detectKindsInCode(code, env),
+ })
+ return { code: result?.code ?? null, serverFns }
+}
+
+/** Compiles the client, SSR caller and provider outputs and validates each. */
+async function compileAll(code: string) {
+ const compiled = {} as Record
+ const errors = {} as Record>
+ let serverFns: Record = {}
+ for (const output of outputs) {
+ const result = await compileFor(output, code)
+ expect(result.code, output).not.toBeNull()
+ compiled[output] = result.code!
+ errors[output] = await getModuleErrors(result.code!)
+ if (output === 'client') {
+ serverFns = result.serverFns
+ }
+ }
+ expect(errors).toEqual({ client: [], ssr: [], provider: [] })
+ const idOf = (functionName: string) =>
+ Object.values(serverFns).find((fn) => fn.functionName === functionName)
+ ?.functionId
+ return { ...compiled, serverFns, idOf }
+}
+
+/** Matches the source of an import or re-export statement. */
+const moduleSource =
+ /^(\s*import\s*|\s*(?:import|export)\b[^;'"]*?\bfrom\s*)(["'])([^"']+)\2/gm
+
+/** Project-local import sources of a module, sorted. */
+function importSources(code: string) {
+ return [...code.matchAll(moduleSource)]
+ .map((match) => match[3]!)
+ .filter((source) => !source.startsWith('@tanstack/'))
+ .sort()
+}
+
+const dataUrl = (code: string) =>
+ `data:text/javascript,${encodeURIComponent(code)}`
+
+/**
+ * Minimal Start runtime: callers keep the RPC they were given, providers run
+ * the original handler through `__executeServer`.
+ */
+const startRuntime: Record = {
+ '@tanstack/react-start': `
+const builder = () => {
+ const self = {
+ middleware: () => self,
+ validator: () => self,
+ inputValidator: () => self,
+ handler: (rpc, impl) =>
+ impl
+ ? Object.assign((data) => impl({ data }), {
+ __executeServer: (opts) => impl(opts),
+ })
+ : { rpc },
+ }
+ return self
+}
+export const createServerFn = builder`,
+ '@tanstack/react-start/server-rpc': `export const createServerRpc = (meta, fn) => Object.assign(fn, { meta })`,
+ '@tanstack/react-start/client-rpc': `export const createClientRpc = (id) => ({ client: id })`,
+ '@tanstack/react-start/ssr-rpc': `export const createSsrRpc = (id) => ({ ssr: id })`,
+}
+
+/** Evaluates a compiled module, resolving every import to the given stubs. */
+async function importModule(
+ code: string,
+ modules: Record = {},
+): Promise> {
+ const sources = { ...startRuntime, ...modules }
+ const { code: javascript } = await transformWithOxc(code, 'module.ts')
+ const linked = javascript.replace(
+ moduleSource,
+ (_match, prefix: string, _quote: string, source: string) => {
+ const stub = sources[source]
+ if (stub === undefined) {
+ throw new Error(`No stub for import ${source}`)
+ }
+ return `${prefix}${JSON.stringify(dataUrl(stub))}`
+ },
+ )
+ return import(/* @vite-ignore */ dataUrl(linked))
+}
+
+/** Runs a provider's extracted handler the way the server-fn router does. */
+async function callProvider(
+ provider: string,
+ name: string,
+ modules: Record = {},
+ data?: unknown,
+) {
+ const module = await importModule(provider, modules)
+ const handler = module[`${name}_createServerFn_handler`]
+ expect(handler, name).toBeTypeOf('function')
+ return handler({ data })
+}
+
+const dbServer = `export const db = {
+ x: () => 'x', y: () => 'y', z: () => 'z', w: () => 'w',
+ a: () => 'a', b: () => 'b', rand: () => 1,
+}`
+
+describe('ported Next.js server actions fixtures', () => {
+ // server-graph/5, server-graph/6, server-graph/16
+ test('a handler keeps every kind of module binding it closes over', async () => {
+ const { client, ssr, provider } = await compileAll(`
+import { createServerFn } from '@tanstack/react-start'
+import ANYTHING from './anything'
+import f, { f1, f2 as f2alias } from './foo'
+import * as ns from './ns'
+const f3 = 1
+var f4 = 4
+let f5 = 5
+const [f6, [f7, ...f8], { f9 }, { f10, f11: [f12], f13: f14, f15: { f16 }, ...f17 }, ...f18] = ANYTHING
+function helper() {
+ return f3
+}
+class Repo {
+ static get() {
+ return f4
+ }
+}
+enum Role {
+ Admin = 'admin',
+}
+if (true) {
+ const g19 = 1
+}
+export function bump() {
+ f5++
+}
+export const fn = createServerFn().handler(async () => {
+ const f3 = 'shadow'
+ return [f, f1, f2alias, ns.x, f3, f4, f5, f6, f7, f8, f9, f10, f12, f14, f16, f17, f18, helper(), Repo.get(), Role.Admin, typeof g19]
+})`)
+ for (const caller of [client, ssr]) {
+ expect(importSources(caller)).toEqual([])
+ expect(caller).not.toContain('shadow')
+ // still used by the exported `bump`
+ expect(caller).toMatch(declarationOf('f5'))
+ }
+ expect(
+ await callProvider(provider, 'fn', {
+ './anything': `export default [6, [7, 8, 8.5], { f9: 9 }, { f10: 10, f11: [12], f13: 14, f15: { f16: 16 }, extra: 17 }, 18, 18.5]`,
+ './foo': `export default 'f'; export const f1 = 'f1'; export const f2 = 'f2'`,
+ './ns': `export const x = 'x'`,
+ }),
+ ).toEqual([
+ 'f',
+ 'f1',
+ 'f2',
+ 'x',
+ 'shadow',
+ 4,
+ 5,
+ 6,
+ 7,
+ [8, 8.5],
+ 9,
+ 10,
+ 12,
+ 14,
+ 16,
+ { extra: 17 },
+ [18, 18.5],
+ 1,
+ 4,
+ 'admin',
+ 'undefined',
+ ])
+ })
+
+ test.each<{
+ name: string
+ code: string
+ expected: unknown
+ read?: (value: any) => Promise
+ }>([
+ {
+ // server-graph/7, server-graph/44
+ name: 'a named function expression',
+ code: `export const fn = createServerFn().handler(async function deleteItem() {
+ return deleteItem.name + db.x()
+})`,
+ expected: 'deleteItemx',
+ },
+ {
+ // server-graph/59
+ name: 'a function using this, arguments and new.target',
+ code: `export const fn = createServerFn().handler(async function (this: unknown) {
+ return [typeof this, arguments.length, new.target === undefined, db.x()]
+})`,
+ expected: ['undefined', 1, true, 'x'],
+ },
+ {
+ // server-graph/14 (nested functions), streaming handlers
+ name: 'an async generator',
+ code: `export const fn = createServerFn().handler(async function* () {
+ yield db.x()
+ yield 'done'
+})`,
+ read: async (iterator: AsyncIterable) => {
+ const values: Array = []
+ for await (const value of iterator) {
+ values.push(value)
+ }
+ return values
+ },
+ expected: ['x', 'done'],
+ },
+ {
+ // server-graph/31
+ name: 'an object literal method',
+ code: `export const fn = createServerFn().handler({
+ async f() {
+ return (() => db.a())()
+ },
+}.f)`,
+ expected: 'a',
+ },
+ {
+ // server-graph/31
+ name: 'a class expression method',
+ code: `export const fn = createServerFn().handler(new (class X {
+ async f() {
+ return db.b()
+ }
+})().f)`,
+ expected: 'b',
+ },
+ {
+ // server-graph/21, server-graph/26
+ name: 'a wrapper call',
+ code: `import { withAuth } from './auth.server'
+export const fn = createServerFn().handler(withAuth(async () => db.x()))`,
+ expected: 'auth:x',
+ },
+ {
+ // server-graph/25
+ name: 'a function declared after the server fn',
+ code: `export const fn = createServerFn().handler(impl)
+async function impl() {
+ return inner()
+ async function inner() {
+ return db.x()
+ }
+}`,
+ expected: 'x',
+ },
+ {
+ // server-graph/14, server-graph/28, server-graph/58
+ name: 'a handler with nested and hoisted functions',
+ code: `function make(start: number) {
+ return () => start + db.rand()
+}
+export const fn = createServerFn().handler(async () => {
+ const g = make(1)
+ return [g(), await bar(), (function baz() { return baz.name })()]
+ async function bar() {
+ return db.x()
+ }
+})`,
+ expected: [2, 'x', 'baz'],
+ },
+ {
+ // server-graph/28, server-graph/30
+ name: 'a handler calling a server fn declared later',
+ code: `export const fn = createServerFn().handler(async () => (await later()) + db.x())
+export const later = createServerFn().handler(async () => 'later:')`,
+ expected: 'later:x',
+ },
+ {
+ name: 'a parenthesized non-null initializer',
+ code: `export const fn = (createServerFn().handler(async () => db.x()))!`,
+ expected: 'x',
+ },
+ ])(
+ 'the handler can be $name',
+ async ({ code, expected, read = async (value) => value }) => {
+ const compiled =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+${code}`)
+ for (const caller of [compiled.client, compiled.ssr]) {
+ expect(importSources(caller)).toEqual([])
+ expect(caller).not.toMatch(/\bdb\.|withAuth/)
+ }
+ const value = await callProvider(compiled.provider, 'fn', {
+ './db.server': dbServer,
+ './auth.server': `export const withAuth = (fn) => async (opts) => 'auth:' + (await fn(opts))`,
+ })
+ expect(await read(value)).toEqual(expected)
+ },
+ )
+
+ // server-graph/6 (shadowed bindings in nested scopes)
+ test.each([
+ {
+ name: 'a handler local shadows an import the client uses',
+ code: `import { db } from './db.server'
+import { format } from './format'
+export const label = format('x')
+export const fn = createServerFn().handler(async () => {
+ const format = (value: unknown) => \`local:\${value}\`
+ return format(db.x())
+})`,
+ client: ['./format'],
+ provider: ['./db.server'],
+ expected: 'local:x',
+ },
+ {
+ name: 'a client local shadows an import the handler uses',
+ code: `import { db } from './db.server'
+export function view() {
+ const db = 'local'
+ return db
+}
+export const fn = createServerFn().handler(async () => db.x())`,
+ client: [],
+ provider: ['./db.server'],
+ expected: 'x',
+ },
+ {
+ name: 'a handler parameter shadows an import the client uses',
+ code: `import { db } from './db.server'
+export const fn = createServerFn().handler(async ({ data: db }: { data: string }) => db)
+export const leaked = db`,
+ client: ['./db.server'],
+ provider: [],
+ expected: 'param',
+ },
+ ])('$name', async ({ code, client, provider, expected }) => {
+ const compiled = await compileAll(
+ `import { createServerFn } from '@tanstack/react-start'\n${code}`,
+ )
+ expect(importSources(compiled.client)).toEqual(client)
+ expect(importSources(compiled.provider)).toEqual(provider)
+ expect(
+ await callProvider(
+ compiled.provider,
+ 'fn',
+ { './db.server': dbServer },
+ 'param',
+ ),
+ ).toBe(expected)
+ })
+
+ // server-graph/6 (destructuring patterns), client-graph/3
+ test.each<{
+ name: string
+ code: string
+ modules: Record
+ clientValue: unknown
+ handlerValue: unknown
+ }>([
+ {
+ name: 'an array pattern',
+ code: `import { values } from './values'
+const [serverOnly, shared] = values
+export const clientValue = shared
+export const fn = createServerFn().handler(async () => serverOnly)`,
+ modules: { './values': `export const values = ['server', 'shared']` },
+ clientValue: 'shared',
+ handlerValue: 'server',
+ },
+ {
+ name: 'an object pattern whose rest the client uses',
+ code: `import { config } from './config'
+const { secretKey, ...publicConfig } = config
+export const clientValue = publicConfig
+export const fn = createServerFn().handler(async () => secretKey)`,
+ modules: {
+ './config': `export const config = { secretKey: 'key', theme: 'dark' }`,
+ },
+ clientValue: { theme: 'dark' },
+ handlerValue: 'key',
+ },
+ {
+ name: 'an object pattern whose rest the handler uses',
+ code: `import { config } from './config'
+const { publicKey, ...serverConfig } = config
+export const clientValue = publicKey
+export const fn = createServerFn().handler(async () => serverConfig)`,
+ modules: {
+ './config': `export const config = { publicKey: 'public', token: 'token' }`,
+ },
+ clientValue: 'public',
+ handlerValue: { token: 'token' },
+ },
+ ])(
+ 'a destructuring shared by the client and $name keeps its values',
+ async ({ code, modules, clientValue, handlerValue }) => {
+ const compiled = await compileAll(
+ `import { createServerFn } from '@tanstack/react-start'\n${code}`,
+ )
+ const client = await importModule(compiled.client, modules)
+ expect(client.clientValue).toEqual(clientValue)
+ expect(await callProvider(compiled.provider, 'fn', modules)).toEqual(
+ handlerValue,
+ )
+ },
+ )
+
+ // Regression in the Yuku compiler (#8504): when one binding of a
+ // destructuring is live, every sibling is kept. A default value or computed
+ // key of a handler-only binding then keeps its server-only import in the
+ // client output (main prunes that element). Impact: the server module ships
+ // in the client bundle, or import protection fails the build.
+ // server-graph/6 (destructuring patterns), server-graph/43
+ test.each([
+ {
+ name: 'an object pattern default',
+ code: `import { readSecret } from './secrets.server'
+const { apiKey = readSecret(), theme } = config
+export const fn = createServerFn().handler(async () => apiKey)`,
+ },
+ {
+ name: 'an array pattern default',
+ code: `import { readSecret } from './secrets.server'
+const [apiKey = readSecret(), theme] = config.list
+export const fn = createServerFn().handler(async () => apiKey)`,
+ },
+ {
+ name: 'a computed key',
+ code: `import { secretKeyName } from './secrets.server'
+const { [secretKeyName]: apiKey, theme } = config
+export const fn = createServerFn().handler(async () => apiKey)`,
+ },
+ {
+ name: 'a nested pattern default',
+ code: `import { readSecret } from './secrets.server'
+const { db: { password = readSecret() } = {}, theme } = config
+export const fn = createServerFn().handler(async () => password)`,
+ },
+ {
+ name: 'a function default',
+ code: `import { db } from './secrets.server'
+const { query = () => db.query(), theme } = config
+export const fn = createServerFn().handler(async () => query())`,
+ },
+ ])(
+ 'the client does not keep a server import read by $name of a handler-only binding',
+ async ({ code }) => {
+ const compiled =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+import { config } from './config'
+${code}
+export const currentTheme = theme`)
+ expect(importSources(compiled.client)).toEqual(['./config'])
+ const client = await importModule(compiled.client, {
+ './config': `export const config = { theme: 'dark', list: [undefined, 'dark'] }`,
+ })
+ expect(client.currentTheme).toBe('dark')
+ },
+ )
+
+ // server-graph/8, server-graph/3, server-graph/4
+ describe('directives', () => {
+ test('handler directives stay in the provider and leave the callers', async () => {
+ const { client, ssr, provider } =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+export const fn = createServerFn().handler(async function () {
+ // comment
+ 'use strict'
+ 'use server'
+ return 1
+})`)
+ for (const caller of [client, ssr]) {
+ expect(caller).not.toMatch(/use (?:strict|server)/)
+ }
+ expect(provider).toMatch(
+ /async function\s*\(\)\s*\{\s*\/\/ comment\s*['"]use strict['"];?\s*['"]use server['"]/,
+ )
+ expect(await callProvider(provider, 'fn')).toBe(1)
+ })
+
+ test.each([
+ { directive: 'use client', code: `'use client'` },
+ { directive: 'use server', code: `// app/send.ts\n'use server'` },
+ ])(
+ 'a module $directive directive stays first in the callers',
+ async ({ directive, code }) => {
+ const { client, ssr } = await compileAll(`${code}
+import { createServerFn } from '@tanstack/react-start'
+export const fn = createServerFn().handler(async () => 1)`)
+ for (const caller of [client, ssr]) {
+ expect(caller).toMatch(
+ new RegExp(String.raw`^(?:\s*//[^\n]*\n)*\s*['"]${directive}['"]`),
+ )
+ }
+ },
+ )
+ })
+
+ // server-graph/9, server-graph/12, server-graph/13, server-graph/17,
+ // server-graph/29, server-graph/66, server-graph/69, client-graph/4,
+ // client-graph/15
+ test('server fns keep every export form in the callers and only the handlers in the provider', async () => {
+ const compiled =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+const a = createServerFn().handler(async () => 'a')
+const b = createServerFn().handler(async () => 'b')
+const c = createServerFn().handler(async () => 'c')
+const d = createServerFn().handler(async () => 'd')
+export const e = createServerFn().handler(async () => 'e'),
+ f = createServerFn().handler(async () => 'f'),
+ notAServerFn = 1
+export let g = createServerFn().handler(async () => 'g')
+export var h = createServerFn().handler(async () => 'h')
+const async = createServerFn().handler(async () => 'async')
+const from = createServerFn().handler(async () => 'from')
+export { a, b as renamed, c as '📙', d as default, async, from as as, a as alsoA }`)
+ const exported = {
+ a: 'a',
+ alsoA: 'a',
+ renamed: 'b',
+ '📙': 'c',
+ default: 'd',
+ e: 'e',
+ f: 'f',
+ g: 'g',
+ h: 'h',
+ async: 'async',
+ as: 'from',
+ }
+ for (const [output, rpc] of [
+ ['client', 'client'],
+ ['ssr', 'ssr'],
+ ] as const) {
+ const module = await importModule(compiled[output])
+ expect(Object.keys(module).sort()).toEqual(
+ [...Object.keys(exported), 'notAServerFn'].sort(),
+ )
+ for (const [name, local] of Object.entries(exported)) {
+ expect(module[name], `${output} ${name}`).toEqual({
+ rpc: { [rpc]: compiled.idOf(`${local}_createServerFn_handler`) },
+ })
+ }
+ }
+ const locals = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'async', 'from']
+ const provider = await importModule(compiled.provider)
+ expect(Object.keys(provider).sort()).toEqual(
+ locals.map((local) => `${local}_createServerFn_handler`).sort(),
+ )
+ for (const local of locals) {
+ const handler = provider[`${local}_createServerFn_handler`]
+ expect(handler.meta).toEqual({
+ id: compiled.idOf(`${local}_createServerFn_handler`),
+ name: local,
+ filename: 'src/module.tsx',
+ })
+ expect(await handler({})).toBe(local)
+ }
+ })
+
+ // server-graph/66, client-graph/15 (non-ASCII names)
+ test('non-ASCII and symbol-like server fn names', async () => {
+ const names = ['ñandú', '𝑓', 'ᾩ', '$', '_', '$fn']
+ const code = `import { createServerFn } from '@tanstack/react-start'
+${names.map((name) => `export const ${name} = createServerFn().handler(async () => '${name}')`).join('\n')}`
+ const compiled = await compileAll(code)
+ expect(
+ Object.values(compiled.serverFns)
+ .map((fn) => fn.functionName)
+ .sort(),
+ ).toEqual(names.map((name) => `${name}_createServerFn_handler`).sort())
+ const provider = await importModule(compiled.provider)
+ for (const name of names) {
+ const handler = provider[`${name}_createServerFn_handler`]
+ expect(handler.meta.name).toBe(name)
+ expect(await handler({})).toBe(name)
+ }
+
+ const dev = await compileFor('client', code, 'dev')
+ expect(
+ Object.keys(dev.serverFns)
+ .map(
+ (id) =>
+ JSON.parse(Buffer.from(id, 'base64url').toString('utf8')).export,
+ )
+ .sort(),
+ ).toEqual(names.map((name) => `${name}_createServerFn_handler`).sort())
+ })
+
+ // client-graph vs server-graph split, server-graph/47, server-graph/58
+ test('client-only and server-only code next to a server fn reach only their side', async () => {
+ const compiled = await compileAll(`import {
+ createClientOnlyFn,
+ createIsomorphicFn,
+ createMiddleware,
+ createServerFn,
+ createServerOnlyFn,
+} from '@tanstack/react-start'
+import { useState } from 'react'
+import { chart } from './chart.client'
+import { db } from './db.server'
+import { schema } from './schema.server'
+export const draw = createClientOnlyFn(() => chart())
+export const now = createIsomorphicFn()
+ .server(() => db.now())
+ .client(() => chart())
+const readSecret = createServerOnlyFn(() => db.secret())
+const auth = createMiddleware({ type: 'function' })
+ .client(async ({ next }) => next())
+ .server(async ({ next }) => next({ context: { user: await db.user() } }))
+export const fn = createServerFn()
+ .middleware([auth])
+ .validator((data: unknown) => schema.parse(data))
+ .handler(async () => readSecret())
+export function Page() {
+ const [count] = useState(0)
+ return draw()}>{count}
+}`)
+ expect(importSources(compiled.client)).toEqual(['./chart.client', 'react'])
+ expect(importSources(compiled.ssr)).toEqual([
+ './db.server',
+ './schema.server',
+ 'react',
+ ])
+ expect(importSources(compiled.provider)).toEqual([
+ './db.server',
+ './schema.server',
+ ])
+ expect(compiled.provider).not.toContain('Page')
+ })
+
+ // server-graph/60, server-graph/62, server-graph/68, server-graph/72
+ test('TypeScript-only exports next to a server fn', async () => {
+ const { provider } =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+import type { Stuff } from './stuff'
+export type X = string
+export { type A } from './a'
+export type { B } from './b'
+export type * from './c'
+export type { Foo }
+type Foo = string
+export interface I {}
+export enum E {
+ A = 'a',
+}
+export declare const declared: number
+export default interface D {}
+export const fn = createServerFn().handler(async (): Promise => E.A)`)
+ expect(provider).toMatch(/export\s*\{\s*fn_createServerFn_handler\s*\}/)
+ expect(await callProvider(provider, 'fn')).toBe('a')
+ })
+
+ // client-graph/2
+ test('side-effect imports and top-level statements stay in every output', async () => {
+ const compiled =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+import './polyfill'
+console.log('side effect')
+const foo = createServerFn().handler(async () => 'function body')
+export { foo }`)
+ for (const output of outputs) {
+ expect(compiled[output]).toMatch(/import\s*['"]\.\/polyfill['"]/)
+ expect(compiled[output]).toContain(`console.log('side effect')`)
+ }
+ })
+
+ // client-graph/1, server-graph/10, server-graph/11, server-graph/15
+ test.each([
+ `export default async function () {\n return fn()\n}`,
+ `export default async () => fn()`,
+ `export default class {\n call() {\n return fn()\n }\n}`,
+ ])('an anonymous default export next to a server fn: %s', async (code) => {
+ const { provider } =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+export const fn = createServerFn().handler(async () => 1)
+${code}`)
+ expect(await callProvider(provider, 'fn')).toBe(1)
+ })
+
+ // server-graph/43
+ test('values only the handler reads stay out of the callers', async () => {
+ const compiled =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+const secret = 'my password is qwerty123'
+const derived = secret.toUpperCase()
+const url = process.env.DATABASE_URL
+export const fn = createServerFn().handler(async () => [secret, derived, typeof url])`)
+ for (const caller of [compiled.client, compiled.ssr]) {
+ expect(caller).not.toMatch(/qwerty123|DATABASE_URL/)
+ }
+ expect(await callProvider(compiled.provider, 'fn')).toEqual([
+ 'my password is qwerty123',
+ 'MY PASSWORD IS QWERTY123',
+ process.env.DATABASE_URL === undefined ? 'undefined' : 'string',
+ ])
+ })
+
+ // server-graph/27
+ test('exported helpers the handler uses stay declared in the provider', async () => {
+ const { provider } =
+ await compileAll(`import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export async function load() {
+ return db.x()
+}
+export default function helper() {
+ return db.y()
+}
+export class Repo {
+ static z() {
+ return db.z()
+ }
+}
+const local = () => db.w()
+export { local as alias }
+export const fn = createServerFn().handler(async () => [await load(), helper(), Repo.z(), local()])`)
+ for (const name of ['load', 'helper', 'Repo', 'local']) {
+ expect(provider).toMatch(declarationOf(name))
+ }
+ const module = await importModule(provider, { './db.server': dbServer })
+ expect(Object.keys(module)).toEqual(['fn_createServerFn_handler'])
+ expect(await module.fn_createServerFn_handler({})).toEqual([
+ 'x',
+ 'y',
+ 'z',
+ 'w',
+ ])
+ })
+})
From 0b91de6c3904f5970e29a6de620ea48f890aff14 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:19:00 +0200
Subject: [PATCH 0006/1425] test(start-plugin-core): pin a 'use client'
provider bug found via Next.js fixtures
Ported from the module-level directive scenarios of Next.js's
server-actions transform fixtures. The server-function provider module
keeps its source module's 'use client' directive, so an RSC provider
environment would turn its handlers into client references.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../ported-nextjs-server-actions.test.ts | 40 +++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts
diff --git a/packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts b/packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts
new file mode 100644
index 00000000000..7701ab260e4
--- /dev/null
+++ b/packages/start-plugin-core/tests/createServerFn/ported-nextjs-server-actions.test.ts
@@ -0,0 +1,40 @@
+/**
+ * Known createServerFn bugs found by porting the Next.js server actions
+ * transform fixtures (vercel/next.js, MIT):
+ * crates/next-custom-transforms/tests/fixture/server-actions.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { describe, expect, test } from 'vitest'
+import { compileStartModule } from '../compile-start-module'
+import { getModuleErrors } from '../validate-module'
+
+describe('known createServerFn bugs ported from Next.js server actions', () => {
+ // server-graph/3, server-graph/4 (module-level directives)
+ // Bug: the provider module (`?tss-serverfn-split`) keeps the `'use client'`
+ // directive of its source module, although it only holds server code. In an
+ // RSC build the provider environment turns every export of a `'use client'`
+ // module into a client reference that throws when called on the server.
+ // Impact: a server fn declared in a `'use client'` file cannot run in RSC
+ // apps. Remove `.fails` once fixed.
+ test.fails(
+ "the provider of a 'use client' module is not a client module",
+ async () => {
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code: `'use client'
+import { createServerFn } from '@tanstack/react-start'
+import { db } from './db.server'
+export const fn = createServerFn().handler(async () => db.x())`,
+ })
+ expect(provider).not.toBeNull()
+ expect(await getModuleErrors(provider!)).toEqual([])
+ expect(provider).toMatch(/export\s*\{\s*fn_createServerFn_handler\s*\}/)
+ expect(provider).not.toMatch(/['"]use client['"]/)
+ },
+ )
+})
From faa5868f9756fa2daaabbc885f868ccd59a60cd8 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:22:46 +0200
Subject: [PATCH 0007/1425] test(router-plugin): port Next.js SSG and React
Router route-chunk splitting scenarios
Translate edge cases from Next.js's SSG transform fixtures and React Router's
route-chunks / remove-exports tests into code-splitter coverage: imports used
in JSX positions, shadowed names, transitive pruning of every declaration
kind, destructurings and multi-declarator statements split across groups,
mutually recursive shared helpers, nested-scope dependency analysis, helpers
shared with the loader, top-level calls on imports and split chunk source
maps. All scenarios already behave correctly; these tests pin them.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tests/ported-nextjs-ssg.test.ts | 470 +++++++++++++++
.../ported-react-router-route-chunks.test.ts | 570 ++++++++++++++++++
2 files changed, 1040 insertions(+)
create mode 100644 packages/router-plugin/tests/ported-nextjs-ssg.test.ts
create mode 100644 packages/router-plugin/tests/ported-react-router-route-chunks.test.ts
diff --git a/packages/router-plugin/tests/ported-nextjs-ssg.test.ts b/packages/router-plugin/tests/ported-nextjs-ssg.test.ts
new file mode 100644
index 00000000000..f5f81892beb
--- /dev/null
+++ b/packages/router-plugin/tests/ported-nextjs-ssg.test.ts
@@ -0,0 +1,470 @@
+/**
+ * Scenarios ported from Next.js's SSG transform fixtures
+ * (vercel/next.js `crates/next-custom-transforms/tests/fixture/ssg`, MIT).
+ * Next.js removes `getStaticProps`/`getServerSideProps` and prunes everything
+ * only they used; here a split route option plays the removed export and the
+ * reference module plays the page that keeps the rest.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { declarationOf, getModuleErrors } from './validate-module'
+
+const filename = 'route.tsx'
+const head = `import { createFileRoute } from '@tanstack/react-router'\n`
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(code: string) {
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })
+ const modules: Record = {
+ reference: reference?.code ?? code,
+ }
+ for (const targets of defaultCodeSplitGroupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return { modules, sharedBindings: [...sharedBindings].sort() }
+}
+
+async function getErrorsByModule(modules: Record) {
+ const errors: Record> = {}
+ for (const [name, code] of Object.entries(modules)) {
+ errors[name] = await getModuleErrors(code)
+ }
+ return errors
+}
+
+function noErrors(modules: Record) {
+ return Object.fromEntries(Object.keys(modules).map((name) => [name, []]))
+}
+
+describe('ported Next.js SSG fixtures: references', () => {
+ // Source: ssg/getStaticProps/should-not-remove-import-used-in-render
+ it('keeps imports used in JSX positions with the split component', async () => {
+ const { modules } =
+ compileRouteModules(`${head}import { useState, useEffect } from 'react'
+import {
+ Root,
+ Children,
+ JSXMemberExpression,
+ AttributeValue,
+ AttributeJSX,
+ ValueInRender,
+ ValueInEffect,
+ UnusedInRender,
+} from './ui'
+function Test() {
+ const [x, setX] = useState(ValueInRender.value)
+ useEffect(() => {
+ setX(ValueInEffect.value)
+ }, [])
+ return (
+
+ } />
+
+
+ )
+}
+export const Route = createFileRoute('/')({
+ loader: () => [Root, Children, JSXMemberExpression, AttributeValue, AttributeJSX, ValueInRender, ValueInEffect, UnusedInRender],
+ component: Test,
+})
+`)
+ const component = modules['virtual component']!
+ for (const name of [
+ 'Root',
+ 'Children',
+ 'JSXMemberExpression',
+ 'AttributeValue',
+ 'AttributeJSX',
+ 'ValueInRender',
+ 'ValueInEffect',
+ 'useState',
+ 'useEffect',
+ ]) {
+ expect(component).toMatch(
+ new RegExp(String.raw`import \{[^}]*\b${name}\b`),
+ )
+ }
+ expect(component).not.toContain('UnusedInRender')
+ expect(modules.reference).toContain('UnusedInRender')
+ expect(modules.reference).not.toMatch(/from ['"]react['"]/)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: ssg/getStaticProps/should-not-remove-import-used-in-render
+ it('does not treat JSX tag or attribute names as references to module bindings', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { Comp } from './comp'
+import { load } from './load'
+const a = load()
+const div = 'div'
+const label = load()
+export const Route = createFileRoute('/')({
+ loader: () => [a, div, label],
+ component: () => (
+
+ ),
+})
+`)
+ expect(sharedBindings).toEqual([])
+ expect(modules['virtual component']).not.toContain('./load')
+ expect(modules['virtual component']).toMatch(
+ /import \{ Comp \} from ['"]\.\/comp['"]/,
+ )
+ expect(modules.reference).toMatch(declarationOf('a'))
+ expect(modules.reference).toMatch(declarationOf('label'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: ssg/getStaticProps/should-not-mix-up-bindings and
+ // ssg/getServerSideProps/query-usage
+ it.each([
+ {
+ name: 'locals and function names in the split component',
+ code: `${head}import { fetchLabel } from './api'
+const label = fetchLabel()
+function bug() {
+ return 1
+}
+export const Route = createFileRoute('/')({
+ loader: () => [label, bug()],
+ component: () => {
+ const label = 'local'
+ const obj = { a: function bug(x: number) { return x } }
+ function inner() {
+ var bug = 2
+ return bug
+ }
+ return {label}{obj.a(inner())}
+ },
+})
+`,
+ chunkExcludes: ['./api', 'fetchLabel'],
+ referenceIncludes: ['fetchLabel()'],
+ },
+ {
+ name: 'a local in the loader',
+ code: `${head}import { fetchLabel } from './api'
+const label = fetchLabel()
+export const Route = createFileRoute('/')({
+ loader: ({ params }: any) => {
+ const label = params.id
+ return label
+ },
+ component: () => {label}
,
+})
+`,
+ chunkExcludes: [],
+ referenceExcludes: ['./api', 'fetchLabel'],
+ },
+ {
+ name: 'destructured parameters',
+ code: `${head}import { load } from './load'
+const query = load()
+const prop = load()
+export const Route = createFileRoute('/')({
+ loader: ({ query }: any) => [query.prop, prop],
+ component: ({ prop }: any) => {prop}{String(query)}
,
+})
+`,
+ chunkExcludes: ['const prop'],
+ referenceExcludes: ['const query'],
+ },
+ {
+ name: 'a catch parameter and a named function expression',
+ code: `${head}import { load } from './load'
+const error = load()
+const Page = function Page() {
+ try {
+ throw new Error('x')
+ } catch (error) {
+ return {String(error)}{typeof Page}
+ }
+}
+export const Route = createFileRoute('/')({
+ loader: () => error,
+ component: Page,
+})
+`,
+ chunkExcludes: ['./load'],
+ },
+ {
+ name: 'a hoisted var in the split component',
+ code: `${head}import { load } from './load'
+const value = load()
+export const Route = createFileRoute('/')({
+ loader: () => value,
+ component: () => {
+ const before = value
+ var value = 'local'
+ return {String(before)}{value}
+ },
+})
+`,
+ chunkExcludes: ['./load'],
+ },
+ ])(
+ 'resolves names shadowed by $name',
+ async ({ code, chunkExcludes, referenceIncludes, referenceExcludes }) => {
+ const { modules, sharedBindings } = compileRouteModules(code)
+ expect(sharedBindings).toEqual([])
+ for (const text of chunkExcludes) {
+ expect(modules['virtual component']).not.toContain(text)
+ }
+ for (const text of referenceIncludes ?? []) {
+ expect(modules.reference).toContain(text)
+ }
+ for (const text of referenceExcludes ?? []) {
+ expect(modules.reference).not.toContain(text)
+ }
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+})
+
+describe('ported Next.js SSG fixtures: pruning', () => {
+ // Source: ssg/getStaticProps/should-remove-re-exported-function-declarations-dependents-variables-functions-imports
+ it('prunes every kind of declaration only the split component uses', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import keep_me from 'hello'
+import { keep_me2 } from 'hello2'
+import * as keep_me3 from 'hello3'
+import drop_me from 'bla'
+import { drop_me2 } from 'foo'
+import { drop_me3, but_not_me } from 'bar'
+import * as remove_mua from 'hehe'
+var leave_me_alone = 1
+function dont_bug_me_either() {}
+const inceptionVar = 'hahaa'
+var var1 = 1
+let var2 = 2
+const var3 = inceptionVar + remove_mua
+function inception1() {
+ var2
+ drop_me2
+}
+function abc() {}
+const b = function () {
+ var3
+ drop_me3
+}
+const b2 = function apples() {}
+const bla = () => {
+ inception1
+}
+function Page() {
+ abc()
+ drop_me
+ b
+ b2
+ bla()
+ return {var1}
+}
+export const Route = createFileRoute('/')({
+ loader: () => [keep_me, keep_me2, keep_me3],
+ component: Page,
+})
+`)
+ expect(sharedBindings).toEqual([])
+ const { reference } = modules
+ for (const text of [
+ "'bla'",
+ "'foo'",
+ "'hehe'",
+ 'drop_me3',
+ 'inception1',
+ 'inceptionVar',
+ 'apples',
+ 'var1',
+ 'var2',
+ 'var3',
+ ]) {
+ expect(reference).not.toContain(text)
+ }
+ // Declarations and imports nothing used before splitting stay in place
+ expect(reference).toMatch(/import \{ but_not_me \} from ['"]bar['"]/)
+ expect(reference).toMatch(declarationOf('leave_me_alone'))
+ expect(reference).toMatch(declarationOf('dont_bug_me_either'))
+ const component = modules['virtual component']!
+ for (const text of ["'hello'", "'hello2'", "'hello3'"]) {
+ expect(component).not.toContain(text)
+ }
+ for (const name of ['inception1', 'abc', 'b', 'b2', 'bla', 'var3']) {
+ expect(component).toMatch(declarationOf(name))
+ }
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: ssg/getStaticProps/destructuring-assignment-array and
+ // ssg/getStaticProps/destructuring-assignment-object
+ it.each([
+ {
+ name: 'array',
+ declarations: `const [a, b, ...rest] = fs.promises
+const [foo, bar] = other`,
+ loader: 'foo',
+ component: '{String(a)}{String(b)}{rest.length}{String(bar)}',
+ shared: ['bar', 'foo'],
+ },
+ {
+ name: 'object',
+ declarations: `const { readFile, readdir, access: foo } = fs.promises
+const { a, b, cat: bar, ...rem } = other`,
+ loader: '[a, bar]',
+ component:
+ '{String(readFile)}{String(readdir)}{String(foo)}{String(b)}{Object.keys(rem).join()}',
+ shared: ['a', 'b', 'bar', 'rem'],
+ },
+ ])(
+ 'shares one $name destructuring whose bindings the loader and the component split',
+ async ({ declarations, loader, component, shared }) => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import fs from 'fs'
+import other from 'other'
+${declarations}
+export const Route = createFileRoute('/')({
+ loader: () => ${loader},
+ component: () => ${component}
,
+})
+`)
+ expect(sharedBindings).toEqual(shared)
+ expect(modules.shared).toMatch(/\} = other;|\] = other;/)
+ expect(modules.shared).not.toContain('fs.promises')
+ expect(modules.reference).not.toContain('other')
+ expect(modules.reference).not.toMatch(/from ['"]fs['"]/)
+ expect(modules['virtual component']).toContain('= fs.promises')
+ expect(modules['virtual component']).not.toContain('= other')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+
+ // Source: ssg/getStaticProps/issue-30091
+ it('keeps a dynamic import inside the split component out of the reference module', async () => {
+ const { modules } =
+ compileRouteModules(`${head}export const Route = createFileRoute('/')({
+ loader: async () => (await import('./server-data')).load(),
+ component: () => {
+ import('./analytics').then((m) => m.track())
+ return home
+ },
+})
+`)
+ expect(modules.reference).not.toContain('./analytics')
+ expect(modules.reference).toContain('./server-data')
+ expect(modules['virtual component']).toContain('./analytics')
+ expect(modules['virtual component']).not.toContain('./server-data')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: ssg/getStaticProps/issue-31855 and
+ // ssg/getStaticProps/multi-declarator-export
+ it('imports exported declarators the split component reads from the reference module', async () => {
+ const { modules } =
+ compileRouteModules(`${head}export const revalidateInSeconds = 5 * 60
+export const a = 1,
+ b = 2
+export const Route = createFileRoute('/')({
+ component: () => {revalidateInSeconds + a + b}
,
+})
+`)
+ expect(modules.reference).toMatch(/export const revalidateInSeconds\b/)
+ expect(modules.reference).toMatch(/export const a = 1,\s*b = 2/)
+ const component = modules['virtual component']!
+ for (const name of ['revalidateInSeconds', 'a', 'b']) {
+ expect(component).toMatch(
+ new RegExp(
+ String.raw`import \{[^}]*\b${name}\b[^}]*\} from ['"]route\.tsx['"]`,
+ ),
+ )
+ expect(component).not.toMatch(declarationOf(name))
+ }
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: ssg/getStaticProps/should-remove-re-exported-variable-declarations-safe
+ it('moves one declarator of a multi-declarator statement into the split chunk', async () => {
+ const { modules } =
+ compileRouteModules(`${head}import { chunkMessage, mainMessage } from './messages'
+const Page = () => {chunkMessage}
,
+ a = mainMessage
+export const Route = createFileRoute('/')({
+ loader: () => a,
+ component: Page,
+})
+`)
+ expect(modules.reference).toMatch(/const a = mainMessage;/)
+ expect(modules.reference).not.toMatch(declarationOf('Page'))
+ expect(modules.reference).not.toContain('chunkMessage')
+ expect(modules['virtual component']).toMatch(declarationOf('Page'))
+ expect(modules['virtual component']).not.toContain('mainMessage')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: ssg/getStaticProps/should-support-babel-style-memoized-function
+ it.each([
+ { name: 'the component', loader: `'data'`, shared: [] },
+ {
+ name: 'the loader and the component',
+ loader: 'fetchData()',
+ shared: ['fetchData'],
+ },
+ ])(
+ 'keeps a self-reassigning function used by $name valid',
+ async ({ loader, shared }) => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}function fetchData() {
+ fetchData = function () {
+ return 'memoized'
+ }
+ return fetchData.apply(this, arguments as any)
+}
+export const Route = createFileRoute('/')({
+ loader: () => ${loader},
+ component: () => {fetchData()}
,
+})
+`)
+ expect(sharedBindings).toEqual(shared)
+ const owner = shared.length
+ ? modules.shared!
+ : modules['virtual component']!
+ expect(owner).toMatch(declarationOf('fetchData'))
+ expect(owner).toContain('fetchData = function')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+})
diff --git a/packages/router-plugin/tests/ported-react-router-route-chunks.test.ts b/packages/router-plugin/tests/ported-react-router-route-chunks.test.ts
new file mode 100644
index 00000000000..8a37a515f23
--- /dev/null
+++ b/packages/router-plugin/tests/ported-react-router-route-chunks.test.ts
@@ -0,0 +1,570 @@
+/**
+ * Scenarios ported from React Router's route-chunk and export-removal tests
+ * (remix-run/react-router `packages/react-router-dev/vite/route-chunks-test.ts`
+ * and `remove-exports-test.ts`, MIT). React Router moves `clientLoader` and
+ * friends into their own chunks; here route options moved into split chunks
+ * play the chunked exports and the reference module plays the main chunk.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { declarationOf, getModuleErrors } from './validate-module'
+
+const filename = 'route.tsx'
+const head = `import { createFileRoute } from '@tanstack/react-router'\n`
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(code: string) {
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })
+ const modules: Record = {
+ reference: reference?.code ?? code,
+ }
+ for (const targets of defaultCodeSplitGroupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return { modules, sharedBindings: [...sharedBindings].sort() }
+}
+
+async function getErrorsByModule(modules: Record) {
+ const errors: Record> = {}
+ for (const [name, code] of Object.entries(modules)) {
+ errors[name] = await getModuleErrors(code)
+ }
+ return errors
+}
+
+function noErrors(modules: Record) {
+ return Object.fromEntries(Object.keys(modules).map((name) => [name, []]))
+}
+
+/** Decodes source map mappings into `[column, source, line, column, name]` segments. */
+function decodeMappings(mappings: string) {
+ const alphabet =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+ const state = [0, 0, 0, 0, 0]
+ return mappings.split(';').map((line) => {
+ state[0] = 0
+ return line
+ .split(',')
+ .filter(Boolean)
+ .map((segment) => {
+ let value = 0
+ let shift = 0
+ let index = 0
+ for (const char of segment) {
+ const digit = alphabet.indexOf(char)
+ value += (digit & 31) << shift
+ if (digit & 32) {
+ shift += 5
+ continue
+ }
+ state[index] =
+ state[index]! + (value & 1 ? -(value >>> 1) : value >>> 1)
+ index++
+ value = 0
+ shift = 0
+ }
+ return state.slice(0, index)
+ })
+ })
+}
+
+describe('ported React Router route chunks: imports', () => {
+ // Source: route-chunks-test.ts "functions referencing their own identifiers"
+ it('gives each chunk only the default, named or namespace import it uses', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import defaultMessage, {
+ targetMessage1,
+ otherMessage1,
+} from './messages'
+import * as messages from './messages'
+const getDefaultMessage = () => defaultMessage
+const getTargetMessage1 = () => targetMessage1
+const getOtherMessage1 = () => otherMessage1
+function getNamespacedMessage() {
+ return messages.namespacedMessage
+}
+export const Route = createFileRoute('/')({
+ loader: () => getOtherMessage1(),
+ component: () => {getDefaultMessage()}
,
+ errorComponent: () => {getNamespacedMessage()}
,
+ notFoundComponent: () => {getTargetMessage1()}
,
+})
+`)
+ expect(sharedBindings).toEqual([])
+ const imports = (code: string) =>
+ code.match(/^import .* from ['"]\.\/messages['"];?$/gm)
+ expect(imports(modules.reference!)).toEqual([
+ expect.stringMatching(/^import \{ otherMessage1 \} from/),
+ ])
+ expect(imports(modules['virtual component']!)).toEqual([
+ expect.stringMatching(/^import defaultMessage from/),
+ ])
+ expect(imports(modules['virtual errorComponent']!)).toEqual([
+ expect.stringMatching(/^import \* as messages from/),
+ ])
+ expect(imports(modules['virtual notFoundComponent']!)).toEqual([
+ expect.stringMatching(/^import \{ targetMessage1 \} from/),
+ ])
+ expect(modules['virtual component']).toMatch(
+ declarationOf('getDefaultMessage'),
+ )
+ expect(modules['virtual errorComponent']).toMatch(
+ declarationOf('getNamespacedMessage'),
+ )
+ expect(modules['virtual notFoundComponent']).toMatch(
+ declarationOf('getTargetMessage1'),
+ )
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: route-chunks-test.ts "shared imports across chunks but not main chunk"
+ it('keeps an import that only split chunks use out of the reference module', async () => {
+ const { modules } =
+ compileRouteModules(`${head}import { shared } from './shared'
+export const Route = createFileRoute('/')({
+ loader: () => 'main',
+ component: () => {shared('component')}
,
+ errorComponent: () => {shared('error')}
,
+})
+`)
+ expect(modules.reference).not.toContain('./shared')
+ expect(modules['virtual component']).toContain('./shared')
+ expect(modules['virtual errorComponent']).toContain('./shared')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: route-chunks-test.ts "import with side effect usage" and "shared
+ // imports across chunks but not main chunk with shared side effect usage"
+ it.each([
+ {
+ name: 'an import only the side effect uses',
+ code: `${head}import { sideEffect } from './side-effect'
+sideEffect()
+export const Route = createFileRoute('/')({
+ component: () => chunk
,
+})
+`,
+ call: 'sideEffect()',
+ },
+ {
+ name: 'an import the split chunks use too',
+ code: `${head}import { shared } from './shared'
+shared('side-effect')
+export const Route = createFileRoute('/')({
+ component: () => {shared('component')}
,
+ errorComponent: () => {shared('error')}
,
+})
+`,
+ call: `shared('side-effect')`,
+ },
+ ])(
+ 'runs a top-level call on $name only in the reference module',
+ async ({ code, call }) => {
+ const { modules } = compileRouteModules(code)
+ expect(modules.reference).toContain(call)
+ expect(modules['virtual component']).not.toContain(call)
+ expect(modules['virtual errorComponent']).not.toContain(call)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+})
+
+describe('ported React Router route chunks: declarations', () => {
+ // Source: route-chunks-test.ts "isolated exported ... variable declarations
+ // sharing an export statement" (plain, destructured and nested spread)
+ it.each([
+ {
+ name: 'plain declarators',
+ declarations: `const Page = () => {chunkMessage}
,
+ ErrorView = () => error
,
+ main = mainMessage`,
+ component: 'Page',
+ errorComponent: 'ErrorView',
+ },
+ {
+ name: 'destructured declarators',
+ declarations: `const { Page } = { Page: () => {chunkMessage}
},
+ { ErrorView } = { ErrorView: () => error
},
+ { main } = { main: mainMessage }`,
+ component: 'Page',
+ errorComponent: 'ErrorView',
+ },
+ {
+ name: 'nested rest declarators',
+ declarations: `const [, { nested: { ...chunk } }] = [null, { nested: { ...chunkMessage } }],
+ [, { nested: { ...main } }] = [null, { nested: { ...mainMessage } }]`,
+ component: '() => {Object.keys(chunk).join()}
',
+ errorComponent: '() => error
',
+ },
+ ])(
+ 'moves $name sharing a statement into their own modules',
+ async ({ declarations, component, errorComponent }) => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { chunkMessage, mainMessage } from './messages'
+${declarations}
+export const Route = createFileRoute('/')({
+ loader: () => main,
+ component: ${component},
+ errorComponent: ${errorComponent},
+})
+`)
+ expect(sharedBindings).toEqual([])
+ expect(modules.reference).toContain('mainMessage')
+ expect(modules.reference).not.toContain('chunkMessage')
+ expect(modules['virtual component']).toContain('chunkMessage')
+ expect(modules['virtual component']).not.toContain('mainMessage')
+ expect(modules['virtual errorComponent']).not.toContain('Message')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+
+ // Source: route-chunks-test.ts "exported variable declarations sharing an
+ // export statement"
+ it('shares a declarator that a sibling declarator reads', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { sharedMessage } from './messages'
+const Page = () => {sharedMessage}
,
+ Preview = Page
+export const Route = createFileRoute('/')({
+ loader: () => Preview.name,
+ component: Page,
+})
+`)
+ expect(sharedBindings).toEqual(['Page'])
+ expect(modules.shared).toMatch(declarationOf('Page'))
+ expect(modules.reference).toMatch(/const Preview = Page;/)
+ expect(modules.reference).not.toMatch(declarationOf('Page'))
+ expect(modules['virtual component']).not.toMatch(declarationOf('Page'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: route-chunks-test.ts "exported destructured object (spread)
+ // variable declarations sharing an assignment"
+ it.each([
+ {
+ name: 'an object destructuring',
+ declaration: `const { Page, main } = { Page: () => {chunkMessage}
, main: mainMessage }`,
+ component: 'Page',
+ shared: ['Page', 'main'],
+ },
+ {
+ name: 'a nested object rest destructuring',
+ declaration: `const {
+ chunkMessage: { ...chunk },
+ mainMessage: { ...main },
+} = {
+ chunkMessage: { ...chunkMessage },
+ mainMessage: { ...mainMessage },
+}`,
+ component: '() => {Object.keys(chunk).join()}
',
+ shared: ['chunk', 'main'],
+ },
+ ])(
+ 'initializes $name whose bindings go to different groups once',
+ async ({ declaration, component, shared }) => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { chunkMessage, mainMessage } from './messages'
+${declaration}
+export const Route = createFileRoute('/')({
+ loader: () => main,
+ component: ${component},
+})
+`)
+ expect(sharedBindings).toEqual(shared)
+ expect(modules.shared).toContain('mainMessage')
+ for (const name of ['reference', 'virtual component']) {
+ expect(modules[name]).not.toContain('./messages')
+ expect(modules[name]).toContain('tsr-shared=1')
+ }
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+
+ // Source: route-chunks-test.ts "circular dependencies between exports"
+ it('shares mutually recursive helpers that the loader and the component call', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}const getChunkMessage = (recurse = true): string => {
+ return 'chunk ' + (recurse ? getMainMessage(false) : '')
+}
+const getMainMessage = (recurse = true): string => {
+ return 'main ' + (recurse ? getChunkMessage(false) : '')
+}
+export const Route = createFileRoute('/')({
+ loader: () => getMainMessage(),
+ component: () => {getChunkMessage()}
,
+})
+`)
+ expect(sharedBindings).toEqual(['getChunkMessage', 'getMainMessage'])
+ expect(modules.shared).toMatch(declarationOf('getChunkMessage'))
+ expect(modules.shared).toMatch(declarationOf('getMainMessage'))
+ expect(modules.reference).not.toMatch(declarationOf('getChunkMessage'))
+ expect(modules['virtual component']).not.toMatch(
+ declarationOf('getMainMessage'),
+ )
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+})
+
+describe('ported React Router route chunks: export dependency analysis', () => {
+ // Source: route-chunks-test.ts "if else", "try catch", "for...of with
+ // destructuring and default value", "block", "default argument",
+ // "destructured argument with default value", "generator function" and
+ // "computed object property"
+ it('attributes identifiers inside nested scopes to the split component', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { check } from './check'
+import { chunkMessage1, chunkMessage2, errorMessage, messages, defaultMessage, keyName } from './messages'
+import { mainMessage } from './main'
+let getKey = () => keyName
+let keyed = { [getKey()]: 'chunk' }
+function* chunkGenerator() {
+ yield chunkMessage1
+ yield chunkMessage2
+}
+const getChunkMessage = (message = defaultMessage) => message.toUpperCase()
+const getDestructured = ([{ defaultMessage: message }] = [{ defaultMessage }]) => message
+function Page() {
+ let out: Array = []
+ if (check()) {
+ out.push(chunkMessage1)
+ } else {
+ out.push(chunkMessage2)
+ }
+ try {
+ out.push(chunkMessage1)
+ } catch (error) {
+ out.push(errorMessage(error))
+ }
+ for (let { key, value = defaultMessage } of messages) {
+ out.push(key, value)
+ }
+ {
+ let messages = ['block']
+ out.push(...messages)
+ }
+ out.push(getChunkMessage(), getDestructured(), ...chunkGenerator(), String(keyed))
+ return {out.join()}
+}
+export const Route = createFileRoute('/')({
+ loader: () => mainMessage,
+ component: Page,
+})
+`)
+ expect(sharedBindings).toEqual([])
+ expect(modules.reference).not.toContain('./check')
+ expect(modules.reference).not.toContain('./messages')
+ expect(modules.reference).toContain('./main')
+ const component = modules['virtual component']!
+ expect(component).toMatch(/import \{ check \} from ['"]\.\/check['"]/)
+ for (const name of [
+ 'chunkMessage1',
+ 'chunkMessage2',
+ 'errorMessage',
+ 'messages',
+ 'defaultMessage',
+ 'keyName',
+ ]) {
+ expect(component).toMatch(
+ new RegExp(
+ String.raw`import \{[^}]*\b${name}\b[^}]*\} from ['"]\./messages['"]`,
+ ),
+ )
+ }
+ for (const name of [
+ 'getKey',
+ 'keyed',
+ 'getChunkMessage',
+ 'getDestructured',
+ ]) {
+ expect(component).toMatch(declarationOf(name))
+ }
+ expect(component).toContain('function* chunkGenerator')
+ expect(component).not.toContain('./main')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: route-chunks-test.ts "reassignment" and "function argument
+ // reassignment"
+ it('keeps a binding that only the split component reassigns in its chunk', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { reassignedMessage } from './messages'
+let chunkMessage = 'chunk'
+const getMessage = (message: string) => {
+ message = chunkMessage
+ return message
+}
+export const Route = createFileRoute('/')({
+ loader: () => 'main',
+ component: () => {
+ chunkMessage = reassignedMessage
+ return {getMessage('')}
+ },
+})
+`)
+ expect(sharedBindings).toEqual([])
+ expect(modules.reference).not.toContain('chunkMessage')
+ expect(modules.reference).not.toContain('./messages')
+ expect(modules['virtual component']).toMatch(
+ /let chunkMessage = ['"]chunk['"]/,
+ )
+ expect(modules['virtual component']).toMatch(declarationOf('getMessage'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Source: route-chunks-test.ts "computed object property" and "class method
+ // usage"
+ it('treats computed keys as references and property names as plain keys', async () => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { load } from './load'
+const label = load()
+const kind = 'kind'
+export const Route = createFileRoute('/')({
+ loader: () => [label, kind],
+ component: () => {
+ const obj = { label: 1, [kind]: 2 }
+ class Local {
+ label = 1
+ kind() { return 2 }
+ }
+ outer: for (const x of [1]) { break outer }
+ return {obj.label}{new Local().label}
+ },
+})
+`)
+ expect(sharedBindings).toEqual(['kind'])
+ expect(modules['virtual component']).not.toContain('./load')
+ expect(modules.reference).toMatch(declarationOf('label'))
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+})
+
+describe('ported React Router export removal', () => {
+ // Source: remove-exports-test.ts "arrow function with dependencies",
+ // "function statement with dependencies", "function call with dependencies"
+ // and "iife with dependencies"
+ it.each([
+ {
+ name: 'an arrow function',
+ helpers: `const sharedUtil = (value?: unknown) => sharedLib(value)
+const removedUtil = () => sharedUtil(removedLib(REMOVED_STRING))
+const keptUtil = () => sharedUtil(keptLib())
+const Page = () => {String(removedUtil())}
`,
+ },
+ {
+ name: 'a function declaration',
+ helpers: `function sharedUtil(value?: unknown) { return sharedLib(value) }
+function removedUtil() { return sharedUtil(removedLib(REMOVED_STRING)) }
+function keptUtil() { return sharedUtil(keptLib()) }
+function Page() { return {String(removedUtil())}
}`,
+ },
+ {
+ name: 'a call result',
+ helpers: `const sharedUtil = (value?: unknown) => sharedLib(value)
+const removedUtil = () => sharedUtil(removedLib(REMOVED_STRING))
+const keptUtil = () => sharedUtil(keptLib())
+const Page = memo(() => {String(removedUtil())}
)`,
+ },
+ {
+ name: 'an IIFE result',
+ helpers: `const sharedUtil = (value?: unknown) => sharedLib(value)
+const removedUtil = () => sharedUtil(removedLib(REMOVED_STRING))
+const keptUtil = () => sharedUtil(keptLib())
+const Page = (() => {
+ const message = removedUtil()
+ return () => {String(message)}
+})()`,
+ },
+ ])(
+ 'splits $name with its dependencies and shares the helper the loader uses too',
+ async ({ helpers }) => {
+ const { modules, sharedBindings } =
+ compileRouteModules(`${head}import { memo } from 'react'
+import { removedLib } from 'removed-lib'
+import { keptLib } from 'kept-lib'
+import { sharedLib } from 'shared-lib'
+const REMOVED_STRING = 'REMOVED_STRING'
+${helpers}
+export const Route = createFileRoute('/')({
+ loader: () => keptUtil(),
+ component: Page,
+})
+`)
+ expect(sharedBindings).toEqual(['sharedUtil'])
+ expect(modules.reference).not.toMatch(/removed/i)
+ expect(modules.reference).toContain('kept-lib')
+ expect(modules['virtual component']).not.toMatch(/kept/i)
+ expect(modules['virtual component']).toMatch(declarationOf('Page'))
+ expect(modules['virtual component']).toMatch(declarationOf('removedUtil'))
+ expect(modules.shared).toContain('shared-lib')
+ expect(modules.shared).not.toMatch(/removed|kept/i)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ },
+ )
+})
+
+describe('ported React Router route chunks: source maps', () => {
+ // Source: route-chunks-test.ts "chunked export maps back to its line in the
+ // route module"
+ it('maps the split component back to its line in the route file', () => {
+ const code = `${head}import { thing } from './thing'
+
+export const Route = createFileRoute('/')({
+ loader: () => thing,
+ component: function RouteComponent() {
+ return {thing}
+ },
+})
+`
+ const result = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=component`,
+ splitTargets: ['component'],
+ })
+ const map = result.map as unknown as {
+ sources: Array
+ sourcesContent: Array
+ mappings: string
+ }
+ expect(map.sourcesContent).toEqual([code])
+ const outputLine = result.code
+ .split('\n')
+ .findIndex((line) => line.includes('return {thing}
'))
+ expect(outputLine).toBeGreaterThanOrEqual(0)
+ const [segment] = decodeMappings(map.mappings)[outputLine]!
+ // Line 7 (index 6) of the route file holds the returned JSX
+ expect(segment?.[2]).toBe(6)
+ })
+})
From 16192655a4ae717cb0745ccfb9902d7778905f86 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:24:15 +0200
Subject: [PATCH 0008/1425] test(router-plugin): pin splitting bugs found via
Next.js and React Router fixtures
Port scenarios from Next.js's SSG transform fixtures and React Router's
route-chunks / remove-exports tests that the code splitter still gets wrong,
as expected failures:
- a destructuring with an unreferenced sibling binding is kept in the
reference module and copied into the split chunk, so its initializer runs
twice
- a top-level statement that initializes or mutates a binding only the split
component reads runs in the reference module and again in the chunk
- property assignments to a split component (displayName etc.) stay in the
reference module (ReferenceError on main, unsplit duplicate on the Yuku PR)
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tests/ported-nextjs-ssg.test.ts | 144 ++++++++++++
.../ported-react-router-route-chunks.test.ts | 206 ++++++++++++++++++
2 files changed, 350 insertions(+)
create mode 100644 packages/router-plugin/tests/ported-nextjs-ssg.test.ts
create mode 100644 packages/router-plugin/tests/ported-react-router-route-chunks.test.ts
diff --git a/packages/router-plugin/tests/ported-nextjs-ssg.test.ts b/packages/router-plugin/tests/ported-nextjs-ssg.test.ts
new file mode 100644
index 00000000000..8cdd2d29ea1
--- /dev/null
+++ b/packages/router-plugin/tests/ported-nextjs-ssg.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Known code-splitter bugs found by porting Next.js's SSG transform fixtures
+ * (vercel/next.js `crates/next-custom-transforms/tests/fixture/ssg`, MIT),
+ * pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { promisify } from 'node:util'
+import { build } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { tanstackRouter } from '../src/vite'
+import type { CodeSplitGroupings } from '../src/core/constants'
+
+const runNode = promisify(execFile)
+
+/**
+ * Builds a small app with the real Vite plugin (code splitting enabled), then
+ * imports the built `entry.ts` in a separate Node process and returns the JSON
+ * value printed by `script`, which has the entry's exports in scope as `entry`.
+ */
+async function buildAndRun(options: {
+ files: Record
+ groupings: CodeSplitGroupings
+ script: string
+}) {
+ // Keep the temporary app inside the package so real runtime imports resolve.
+ const root = await mkdtemp(path.join(__dirname, '.ported-nextjs-ssg-'))
+ 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({})`,
+ )
+ for (const [file, code] of Object.entries(options.files)) {
+ await writeFile(path.join(root, file), code)
+ }
+ await build({
+ root,
+ configFile: false,
+ logLevel: 'silent',
+ plugins: [
+ tanstackRouter({
+ target: 'react',
+ routesDirectory: './routes',
+ generatedRouteTree: './routeTree.gen.ts',
+ autoCodeSplitting: true,
+ codeSplittingOptions: {
+ addHmr: false,
+ defaultBehavior: options.groupings,
+ },
+ }),
+ ],
+ build: {
+ ssr: path.join(root, 'entry.ts'),
+ outDir: 'dist',
+ minify: false,
+ rollupOptions: {
+ output: { entryFileNames: 'entry.mjs', chunkFileNames: '[name].mjs' },
+ },
+ },
+ })
+ const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href
+ const { stdout } = await runNode(process.execPath, [
+ '--input-type=module',
+ '--eval',
+ `const entry = await import(${JSON.stringify(entryUrl)})
+const result = await (async () => { ${options.script} })()
+process.stdout.write(JSON.stringify(result))`,
+ ])
+ return JSON.parse(stdout) as unknown
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+}
+
+const renderEntry = `import { createElement } from 'react'
+import { renderToString } from 'react-dom/server'
+export * from './routes/index'
+export async function render(component: any) {
+ await component.preload?.()
+ return renderToString(createElement(component))
+}
+`
+
+describe('known code-splitter bugs ported from Next.js SSG fixtures', () => {
+ // Source: ssg/getStaticProps/destructuring-assignment-array and
+ // ssg/getStaticProps/destructuring-assignment-object.
+ // Bug: when a destructuring declares one binding that nothing references
+ // and one that only the split component reads, the whole declaration stays
+ // in the reference module (the unreferenced binding is kept) and is copied
+ // into the component chunk as well. Impact: the initializer (`init()`)
+ // runs twice, so module state (stores, subscriptions, caches) is duplicated
+ // and the component reads a different instance than the reference module.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'an array destructuring',
+ declaration: 'const [unused, value] = init()',
+ init: `['unused', 'value']`,
+ render: '{value}',
+ html: 'value
',
+ },
+ {
+ name: 'an object rest destructuring',
+ declaration: 'const { unused, ...rest } = init()',
+ init: `{ unused: 'unused', value: 'value' }`,
+ render: `{Object.keys(rest).join()}`,
+ html: 'value
',
+ },
+ ])(
+ '$name with an unreferenced sibling binding runs its initializer once',
+ async ({ declaration, init, render, html }) => {
+ const result = await buildAndRun({
+ groupings: defaultCodeSplitGroupings,
+ files: {
+ 'init.ts': `export function init(): any {
+ ;(globalThis as any).initCalls = ((globalThis as any).initCalls ?? 0) + 1
+ return ${init}
+}`,
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+import { init } from '../init'
+${declaration}
+export const Route = createFileRoute('/')({
+ component: () => ${render}
,
+})`,
+ 'entry.ts': renderEntry,
+ },
+ script: `const html = await entry.render(entry.Route.options.component)
+return [html, globalThis.initCalls]`,
+ })
+ expect(result).toEqual([html, 1])
+ },
+ 30_000,
+ )
+})
diff --git a/packages/router-plugin/tests/ported-react-router-route-chunks.test.ts b/packages/router-plugin/tests/ported-react-router-route-chunks.test.ts
new file mode 100644
index 00000000000..db7501ee64c
--- /dev/null
+++ b/packages/router-plugin/tests/ported-react-router-route-chunks.test.ts
@@ -0,0 +1,206 @@
+/**
+ * Known code-splitter bugs found by porting React Router's route-chunk and
+ * export-removal tests (remix-run/react-router
+ * `packages/react-router-dev/vite/route-chunks-test.ts` and
+ * `remove-exports-test.ts`, MIT), pinned as expected failures.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { promisify } from 'node:util'
+import { build } from 'vite'
+import { describe, expect, test } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitVirtualRoute,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { tanstackRouter } from '../src/vite'
+import { declarationOf, getModuleErrors } from './validate-module'
+import type { CodeSplitGroupings } from '../src/core/constants'
+
+const runNode = promisify(execFile)
+
+/**
+ * Builds a small app with the real Vite plugin (code splitting enabled), then
+ * imports the built `entry.ts` in a separate Node process and returns the JSON
+ * value printed by `script`, which has the entry's exports in scope as `entry`.
+ */
+async function buildAndRun(options: {
+ files: Record
+ groupings: CodeSplitGroupings
+ script: string
+}) {
+ // Keep the temporary app inside the package so real runtime imports resolve.
+ const root = await mkdtemp(path.join(__dirname, '.ported-react-router-'))
+ 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({})`,
+ )
+ for (const [file, code] of Object.entries(options.files)) {
+ await writeFile(path.join(root, file), code)
+ }
+ await build({
+ root,
+ configFile: false,
+ logLevel: 'silent',
+ plugins: [
+ tanstackRouter({
+ target: 'react',
+ routesDirectory: './routes',
+ generatedRouteTree: './routeTree.gen.ts',
+ autoCodeSplitting: true,
+ codeSplittingOptions: {
+ addHmr: false,
+ defaultBehavior: options.groupings,
+ },
+ }),
+ ],
+ build: {
+ ssr: path.join(root, 'entry.ts'),
+ outDir: 'dist',
+ minify: false,
+ rollupOptions: {
+ output: { entryFileNames: 'entry.mjs', chunkFileNames: '[name].mjs' },
+ },
+ },
+ })
+ const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href
+ const { stdout } = await runNode(process.execPath, [
+ '--input-type=module',
+ '--eval',
+ `const entry = await import(${JSON.stringify(entryUrl)})
+const result = await (async () => { ${options.script} })()
+process.stdout.write(JSON.stringify(result))`,
+ ])
+ return JSON.parse(stdout) as unknown
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+}
+
+const renderEntry = `import { createElement } from 'react'
+import { renderToString } from 'react-dom/server'
+export * from './routes/index'
+export async function render(component: any) {
+ await component.preload?.()
+ return renderToString(createElement(component))
+}
+`
+
+describe('known code-splitter bugs ported from React Router', () => {
+ // Source: route-chunks-test.ts "top level await", "object property
+ // mutation" and "class method mutation".
+ // Bug: a top-level statement that initializes or mutates a binding only the
+ // split component reads stays in the reference module, which therefore
+ // keeps its own copy of the binding, while the component chunk gets the
+ // declaration and the statement again. Impact: the setup code (`register`,
+ // `new Greeter()`) runs once in the reference module and once more when the
+ // chunk loads, duplicating registrations and module state.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'a registration call',
+ setup: `import { register } from '../registry'
+const items: Array = []
+register(items)`,
+ render: `{items.join()}`,
+ html: 'item
',
+ },
+ {
+ name: 'a method reassignment',
+ setup: `import { count } from '../registry'
+class Greeter {
+ constructor() {
+ count()
+ }
+ greet() {
+ return 'hello'
+ }
+}
+const greeter: any = new Greeter()
+greeter.greet = () => 'mutated'`,
+ render: `{greeter.greet()}`,
+ html: 'mutated
',
+ },
+ ])(
+ '$name for a component-only binding runs once',
+ async ({ setup, render, html }) => {
+ const result = await buildAndRun({
+ groupings: defaultCodeSplitGroupings,
+ files: {
+ 'registry.ts': `export function count() {
+ ;(globalThis as any).setupCalls = ((globalThis as any).setupCalls ?? 0) + 1
+}
+export function register(items: Array) {
+ count()
+ items.push('item')
+}`,
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+${setup}
+export const Route = createFileRoute('/')({
+ component: () => ${render}
,
+})`,
+ 'entry.ts': renderEntry,
+ },
+ script: `const html = await entry.render(entry.Route.options.component)
+return [html, globalThis.setupCalls]`,
+ })
+ expect(result).toEqual([html, 1])
+ },
+ 30_000,
+ )
+
+ // Source: remove-exports-test.ts "function statement with property
+ // assignment" and "arrow function with property assignment".
+ // Bug: statements that assign properties to a split component
+ // (`Page.displayName = ...`) stay in the reference module. Main keeps the
+ // statements but removes the component's declaration, so the reference
+ // module throws `ReferenceError: Page is not defined`; the Yuku PR keeps a
+ // second copy of the component in the reference module. Impact: the route
+ // crashes (main) or the component is not split out of the main bundle (PR).
+ // Remove `.fails` once fixed.
+ test.fails(
+ 'property assignments move into the chunk with the split component',
+ async () => {
+ const code = `import { createFileRoute } from '@tanstack/react-router'
+function Page() {
+ return {Page.label}
+}
+Page.label = 'page'
+Page.displayName = 'PageDisplay'
+export const Route = createFileRoute('/')({
+ loader: () => 'data',
+ component: Page,
+})
+`
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename: 'route.tsx',
+ id: 'route.tsx',
+ addHmr: false,
+ codeSplitGroupings: defaultCodeSplitGroupings,
+ targetFramework: 'react',
+ })!.code
+ const component = compileCodeSplitVirtualRoute({
+ code,
+ filename: 'route.tsx?tsr-split=component',
+ splitTargets: ['component'],
+ }).code
+ expect(reference).not.toMatch(/\bPage\b/)
+ expect(component).toMatch(declarationOf('Page'))
+ expect(component).toMatch(/Page\.displayName = ['"]PageDisplay['"]/)
+ expect(await getModuleErrors(reference)).toEqual([])
+ expect(await getModuleErrors(component)).toEqual([])
+ },
+ )
+})
From 28bfbb825b1bb32277c196b2cf700236c86b7345 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:24:32 +0200
Subject: [PATCH 0009/1425] test(start-plugin-core, router-plugin): port Qwik
optimizer extraction edge cases
Port capture and extraction scenarios from the Qwik optimizer test suite
(QwikDev/qwik, packages/optimizer/core/src/test.rs) to the Hydrate split
transform, server function extraction and route code splitting. All of
them pass with both the Babel and the Yuku compiler.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tests/ported-qwik-optimizer.test.ts | 277 +++++
.../tests/ported-qwik-optimizer.test.ts | 1026 +++++++++++++++++
2 files changed, 1303 insertions(+)
create mode 100644 packages/router-plugin/tests/ported-qwik-optimizer.test.ts
create mode 100644 packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts
diff --git a/packages/router-plugin/tests/ported-qwik-optimizer.test.ts b/packages/router-plugin/tests/ported-qwik-optimizer.test.ts
new file mode 100644
index 00000000000..4a0831bb3dd
--- /dev/null
+++ b/packages/router-plugin/tests/ported-qwik-optimizer.test.ts
@@ -0,0 +1,277 @@
+/**
+ * Edge cases ported from the Qwik optimizer test suite
+ * (QwikDev/qwik, packages/optimizer/core/src/test.rs, MIT). Qwik's `$()`
+ * extracts lexical scopes into separate modules; `` moves its
+ * children into a lazy chunk the same way, and server functions and route
+ * code splitting move module-level code between modules. Each test names the
+ * Qwik test it is ported from.
+ */
+import { transformWithOxc } from 'vite'
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { getModuleErrors } from './validate-module'
+
+const filename = 'route.tsx'
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(code: string) {
+ const groupings = defaultCodeSplitGroupings
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: groupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: groupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })
+ const modules: Record = {
+ reference: reference?.code ?? code,
+ }
+ for (const targets of groupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return { modules, sharedBindings }
+}
+
+async function getErrorsByModule(modules: Record) {
+ const errors: Record> = {}
+ for (const [name, code] of Object.entries(modules)) {
+ errors[name] = await getModuleErrors(code)
+ }
+ return errors
+}
+
+function noErrors(modules: Record) {
+ return Object.fromEntries(Object.keys(modules).map((name) => [name, []]))
+}
+
+/**
+ * How many times `pattern` occurs across the modules a bundle loads: the
+ * reference module, the split chunks it imports and the shared module.
+ */
+function countAcrossModules(modules: Record, pattern: RegExp) {
+ const reference = modules.reference!
+ return Object.entries(modules)
+ .filter(
+ ([name]) =>
+ !name.startsWith('virtual ') ||
+ reference.includes(`tsr-split=${name.slice('virtual '.length)}`),
+ )
+ .reduce(
+ (count, [, code]) =>
+ count + (code.match(new RegExp(pattern.source, 'g'))?.length ?? 0),
+ 0,
+ )
+}
+
+const stubsKey = '__portedQwikRouteStubs'
+let evaluations = 0
+
+/**
+ * Evaluates a split component chunk like a bundler would and renders its
+ * component: JSX becomes plain calls (intrinsic elements render as tags,
+ * components are called) and named imports are linked to `stubs`.
+ */
+async function renderSplitComponent(
+ chunk: string,
+ stubs: Record> = {},
+) {
+ const { code } = await transformWithOxc(chunk, 'chunk.tsx', {
+ jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' },
+ })
+ const key = `${stubsKey}${evaluations++}`
+ ;(globalThis as Record)[key] = stubs
+ // Imports are hoisted: link them before any other statement runs.
+ const imports: Array = []
+ const body = code.replace(
+ /^import\s+\{([^}]*)\}\s+from\s+(["'])(.+?)\2;?$/gm,
+ (_, named: string, __, source: string) => {
+ if (!(source in stubs)) {
+ throw new Error(`no stub for import ${source}`)
+ }
+ imports.push(
+ `const { ${named.replace(/\bas\b/g, ':')} } = globalThis.${key}[${JSON.stringify(source)}];`,
+ )
+ return ''
+ },
+ )
+ const runtime = `const Fragment = Symbol('Fragment')
+const h = (type, props, ...children) => {
+ const text = children.flat(Infinity).filter((c) => c != null && c !== false).join('')
+ if (type === Fragment) return text
+ if (typeof type === 'function') return type({ ...props, children: text })
+ if (typeof type !== 'string') throw new Error('cannot render ' + String(type))
+ return '<' + type + '>' + text + '' + type + '>'
+}
+`
+ const module: Record = await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(`${runtime}${imports.join('\n')}\n${body}`)}`
+ )
+ return (module.component as () => string)()
+}
+
+describe('route code splitting (ported from the Qwik optimizer)', () => {
+ // Qwik: should_keep_non_migrated_binding_from_shared_destructuring_declarator
+ // (+ _array_destructuring_declarator, _with_default, _with_rest)
+ it.each([
+ { name: 'object', pattern: '{ a, b }' },
+ { name: 'array', pattern: '[a, b]' },
+ { name: 'default', pattern: `{ a = 'A', b }` },
+ { name: 'rest', pattern: '{ a, ...b }' },
+ ])(
+ 'evaluates an $name destructuring split between the loader and the component once',
+ async ({ pattern }) => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import { makeConfig } from './config'
+const ${pattern} = makeConfig()
+export const Route = createFileRoute('/')({
+ loader: () => a,
+ component: () => {JSON.stringify(b)}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ expect(countAcrossModules(modules, /makeConfig\(\)/)).toBe(1)
+ },
+ )
+
+ // Qwik: should_keep_module_level_var_used_in_both_main_and_qrl
+ it('creates an exported context shared by a provider and the split component once', async () => {
+ const { modules } = compileRouteModules(`
+import { createContext, useContext } from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+export const Theme = createContext('light')
+export function ThemeProvider({ children }) {
+ return {children}
+}
+export const Route = createFileRoute('/')({
+ component: () => {useContext(Theme)}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ expect(countAcrossModules(modules, /createContext\(/)).toBe(1)
+ })
+
+ // Qwik: should_not_move_over_side_effects, example_drop_side_effects
+ it.each([
+ {
+ name: 'a compound component member assignment',
+ setup: `function Card({ children }) { return {children}
}
+Card.Title = function Title() { return t }`,
+ render: ' ',
+ rendered: '
t
',
+ },
+ {
+ name: 'a registry filled by a top-level call',
+ setup: `const registry = new Map()
+registry.set('a', 'A')`,
+ render: `{registry.get('a')}
`,
+ rendered: 'A
',
+ },
+ ])(
+ 'renders a split component that relies on $name',
+ async ({ setup, render, rendered }) => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+${setup}
+export const Route = createFileRoute('/')({
+ component: () => ${render},
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ expect(await renderSplitComponent(modules['virtual component']!)).toBe(
+ rendered,
+ )
+ },
+ )
+
+ // Qwik: should_not_auto_export_var_shadowed_in_{catch,do_while,switch,labeled_block}
+ it('keeps loader-only state out of the component chunk when the component shadows its name', async () => {
+ const { modules, sharedBindings } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+import { createDb } from './db'
+const db = createDb()
+export const Route = createFileRoute('/')({
+ loader: () => db.list(),
+ component: ({ kind }) => {
+ try { JSON.parse(kind) } catch (err) { const db = 'catch'; console.log(db) }
+ let i = 0
+ do { const db = i; i += db + 1 } while (i < 3)
+ switch (kind) { case 'a': { const db = 'case'; console.log(db) } }
+ block: { const db = 'labeled'; if (db) break block }
+ return {i}
+ },
+})
+`)
+ expect([...sharedBindings]).toEqual([])
+ expect(modules['virtual component']).not.toContain('createDb')
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ })
+
+ // Qwik: root_level_self_referential_qrl, example_self_referential_component_migration
+ it('splits self-recursive and mutually recursive components without duplicating them', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+function Tree({ depth }) { return depth ? : leaf }
+function A({ depth }) { return depth ? : a }
+function B({ depth }) { return }
+export const Route = createFileRoute('/')({
+ component: () => ,
+ errorComponent: () => ,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ expect(countAcrossModules(modules, /function Tree\b/)).toBe(1)
+ expect(countAcrossModules(modules, /function A\b/)).toBe(1)
+ expect(countAcrossModules(modules, /function B\b/)).toBe(1)
+ })
+
+ // Qwik: example_ts_enums, example_exports
+ it('moves enums, overloads and exported declarations used by the component into its chunk', async () => {
+ const { modules } = compileRouteModules(`
+import { createFileRoute } from '@tanstack/react-router'
+enum Tone { Loud = 'LOUD' }
+function label(value: string): string
+function label(value: unknown) { return 'label:' + String(value) }
+export default function DefaultFn() { return 'default' }
+const internal = 'i'
+export { internal as renamed }
+export const Route = createFileRoute('/')({
+ component: () => {[Tone.Loud, label('x'), DefaultFn(), internal].join('|')}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ // The chunk may import exported bindings from the route module itself.
+ expect(
+ await renderSplitComponent(modules['virtual component']!, {
+ [filename]: { default: () => 'default', renamed: 'i' },
+ }),
+ ).toBe('LOUD|label:x|default|i
')
+ })
+})
diff --git a/packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts b/packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts
new file mode 100644
index 00000000000..a858800d462
--- /dev/null
+++ b/packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts
@@ -0,0 +1,1026 @@
+/**
+ * Edge cases ported from the Qwik optimizer test suite
+ * (QwikDev/qwik, packages/optimizer/core/src/test.rs, MIT). Qwik's `$()`
+ * extracts lexical scopes into separate modules; `` moves its
+ * children into a lazy chunk the same way, and server functions and route
+ * code splitting move module-level code between modules. Each test names the
+ * Qwik test it is ported from.
+ */
+import { parseSync, transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import { compileStartModule } from './compile-start-module'
+import { getModuleErrors } from './validate-module'
+
+const parentId = '/test/src/module.tsx'
+type Env = 'client' | 'server'
+type HydratePlugin = ReturnType
+
+/**
+ * Compiles one module through `StartCompiler` like the bundler plugins do: the
+ * parent module, or a loaded split chunk under its virtual module id.
+ */
+async function compileModule(
+ plugin: HydratePlugin,
+ env: Env,
+ code: string,
+ id = parentId,
+) {
+ const compiler: StartCompiler = new StartCompiler({
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (source) =>
+ source.startsWith('@tanstack/') ? source : null,
+ compilerPlugins: [plugin],
+ })
+ const result = await compiler.compile({
+ code,
+ id,
+ detectedKinds: detectKindsInCode(code, env),
+ })
+ return result?.code ?? null
+}
+
+/** The Hydrate chunk ids a compiled module imports, in source order. */
+function getChunkIds(code: string) {
+ return [...code.matchAll(/import\((["'])(.+?)\1\)/g)]
+ .map(([, , id]) => id!)
+ .filter((id) => id.includes('tss-hydrate='))
+}
+
+function loadChunk(plugin: HydratePlugin, env: Env, id: string) {
+ return (
+ plugin.loadVirtualModule?.({
+ id,
+ root: '/test',
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ })?.code ?? null
+ )
+}
+
+/** Compiles a module with `` and loads the split chunks it imports. */
+async function compileHydrate(env: Env, code: string) {
+ const plugin = createHydrateCompilerPlugin()
+ const parent = await compileModule(plugin, env, code)
+ if (parent === null) {
+ throw new Error('expected the module to be transformed')
+ }
+ const chunks = getChunkIds(parent).map((id) => {
+ const chunk = loadChunk(plugin, env, id)
+ if (chunk === null) {
+ throw new Error(`expected virtual module ${id} to load`)
+ }
+ return chunk
+ })
+ // Order chunks by boundary index, however the parent declares them.
+ const index = (chunk: string) =>
+ Number(chunk.match(/export function H(\d+)\(/)?.[1] ?? -1)
+ chunks.sort((a, b) => index(a) - index(b))
+ return { parent, chunks, plugin }
+}
+
+/** The names a split chunk component receives as props. */
+function getChunkParams(chunk: string) {
+ const params = chunk.match(/export function H\d+\(([^)]*)\)/)?.[1] ?? ''
+ return [...params.matchAll(/[\w$]+/g)].map(([name]) => name).sort()
+}
+
+/** Boundary ids (`h` props) a compiled module renders, in source order. */
+function getBoundaryIds(code: string) {
+ return [...code.matchAll(/\bh=\s*["']([^"']+)["']/g)].map(([, id]) => id!)
+}
+
+const stubsKey = '__portedQwikOptimizerStubs'
+let evaluations = 0
+
+/** Renders JSX to text: intrinsic elements become tags, components are called. */
+const renderRuntime = `const Fragment = Symbol('Fragment')
+const h = (type, props, ...children) => {
+ const text = children.flat(Infinity).filter((c) => c != null && c !== false && c !== true).join('')
+ if (type === Fragment) return text
+ if (typeof type === 'function') return type({ ...props, children: text })
+ if (typeof type !== 'string') throw new Error('cannot render ' + String(type))
+ return '<' + type + '>' + text + '' + type + '>'
+}
+`
+
+/**
+ * Evaluates a compiled module like a bundler would: JSX becomes plain calls
+ * of the `runtime`, and imports are linked to `stubs`, keyed by specifier.
+ */
+async function evaluate(
+ code: string,
+ stubs: Record> = {},
+ runtime = renderRuntime,
+): Promise) => unknown>> {
+ const { code: javascript } = await transformWithOxc(code, 'module.tsx', {
+ jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' },
+ })
+ const key = `${stubsKey}${evaluations++}`
+ ;(globalThis as Record)[key] = stubs
+ // Imports are hoisted: link them before any other statement runs.
+ const imports: Array = []
+ const body = javascript.replace(
+ /^import\s+(.+?)\s+from\s+(["'])(.+?)\2;?$/gm,
+ (_, clause: string, __, source: string) => {
+ if (!(source in stubs)) {
+ throw new Error(`no stub for import ${source}`)
+ }
+ const from = `globalThis.${key}[${JSON.stringify(source)}]`
+ const named = clause.match(/\{(.*)\}/)?.[1]
+ const head = clause
+ .replace(/\{.*\}/, '')
+ .replace(/,\s*$/, '')
+ .trim()
+ if (named) {
+ imports.push(`const { ${named.replace(/\bas\b/g, ':')} } = ${from};`)
+ }
+ if (head.startsWith('* as ')) {
+ imports.push(`const ${head.slice(5)} = ${from};`)
+ } else if (head) {
+ imports.push(`const ${head} = ${from}.default;`)
+ }
+ return ''
+ },
+ )
+ const linked = `${imports.join('\n')}\n${body}`
+ return import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(runtime + linked)}`
+ )
+}
+
+/** Renders a chunk's component export with the given props. */
+async function renderChunk(
+ chunk: string,
+ props: Record = {},
+ stubs: Record> = {},
+) {
+ const module = await evaluate(chunk, stubs)
+ const name = Object.keys(module).find((key) => /^H\d+$/.test(key))
+ if (!name) {
+ throw new Error('expected the chunk to export a component')
+ }
+ return module[name]!(props)
+}
+
+const startStubs = {
+ '@tanstack/react-start': {
+ Hydrate: (props: { children: unknown }) => props.children,
+ },
+ '@tanstack/react-router': {
+ // The lazy component renders the props the parent passes to the chunk.
+ lazyRouteComponent: () => (props: Record) =>
+ `lazy(${Object.keys(props)
+ .filter((name) => name !== 'children')
+ .sort()
+ .map((name) => `${name}=${String(props[name])}`)
+ .join(',')})`,
+ },
+}
+
+/** Top-level binding names a module declares, from any declaration form. */
+function getDeclaredNames(code: string) {
+ const { program } = parseSync('module.tsx', code, { sourceType: 'module' })
+ const names = new Set()
+ const addPattern = (pattern: any): void => {
+ switch (pattern?.type) {
+ case 'Identifier':
+ names.add(pattern.name)
+ return
+ case 'ObjectPattern':
+ for (const property of pattern.properties) {
+ addPattern(
+ property.type === 'RestElement'
+ ? property.argument
+ : property.value,
+ )
+ }
+ return
+ case 'ArrayPattern':
+ pattern.elements.forEach(addPattern)
+ return
+ case 'AssignmentPattern':
+ addPattern(pattern.left)
+ return
+ case 'RestElement':
+ addPattern(pattern.argument)
+ return
+ }
+ }
+ for (const statement of program.body as Array) {
+ const declaration =
+ statement.type === 'ExportNamedDeclaration' ||
+ statement.type === 'ExportDefaultDeclaration'
+ ? statement.declaration
+ : statement
+ if (declaration?.type === 'VariableDeclaration') {
+ for (const declarator of declaration.declarations) {
+ addPattern(declarator.id)
+ }
+ } else if (declaration?.id?.name) {
+ names.add(declaration.id.name)
+ }
+ }
+ return names
+}
+
+describe('Hydrate split captures (ported from the Qwik optimizer)', () => {
+ // Qwik: jsx_tag_named_like_inlined_const, destructured_prop_named_like_jsx_tag
+ test('client: a local named like an intrinsic tag is captured as a value only', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page({ title }) {
+ const div = 'text'
+ return
{title + ':' + div}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(getChunkParams(chunks[0]!)).toEqual(['div', 'title'])
+ expect(await renderChunk(chunks[0]!, { title: 'Hello', div: 'text' })).toBe(
+ '
Hello:text ',
+ )
+ })
+
+ // Qwik: jsx_tag_names_are_not_segment_uses, jsx_lowercase_tag_outside_segments
+ test('client: module-level bindings named like intrinsic tags stay out of the chunk', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const div = sideEffect('div')
+const title = sideEffect('title')
+console.log(div, title)
+export function Page() {
+ return x
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).not.toContain('sideEffect')
+ expect(await renderChunk(chunks[0]!)).toBe('x
')
+ })
+
+ // Qwik: local_shadowing_destructured_prop, nested_destructure_rebinding_prop_name,
+ // should_not_auto_export_var_shadowed_in_{catch,do_while,switch,labeled_block}
+ test('client: names re-declared inside the children are not captured', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page({ value, kind }) {
+ const x = 'outer'
+ console.log(x)
+ return
+ { const value = e.target.value; return value }} />
+ {(() => { try { throw new Error('e') } catch (err) { const x = 'catch:' + err.message; return x } })()}
+ {(() => { let i = 0; do { const x = i; i += x + 1 } while (i < 3); return i })()}
+ {(() => { switch (kind) { case 'a': { const x = 'case'; return x } } return 'none' })()}
+ {(() => { block: { const x = 'labeled'; if (x) break block } return 'after' })()}
+
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(getChunkParams(chunks[0]!)).toEqual(['kind', 'value'])
+ expect(await renderChunk(chunks[0]!, { value: 'v', kind: 'a' })).toBe(
+ 'catch:e
3
case
after
',
+ )
+ })
+
+ // Qwik: should_transform_handlers_capturing_cross_scope_in_nested_loops
+ test('client: block-scoped locals of nested loop callbacks are captured', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Grid({ matrix }) {
+ return {matrix.map((row, i) => {
+ const rowIndex = i + 1
+ return
{row.map((cell, j) => {
+ const cellIndex = j + 1
+ return {cell + rowIndex + '-' + cellIndex}
+ })}
+ })}
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(getChunkParams(chunks[0]!)).toEqual([
+ 'cell',
+ 'cellIndex',
+ 'rowIndex',
+ ])
+ expect(
+ await renderChunk(chunks[0]!, { cell: 'c', rowIndex: 2, cellIndex: 1 }),
+ ).toBe('c2-1 ')
+ })
+
+ // Qwik: example_capturing_fn_class
+ test('client: local function and class declarations are captured', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ function hola() { return 'hola' }
+ class Thing { label = 'thing' }
+ return {hola() + ':' + new Thing().label}
+}
+`,
+ )
+ expect(getChunkParams(chunks[0]!)).toEqual(['Thing', 'hola'])
+ expect(
+ await renderChunk(chunks[0]!, {
+ hola: () => 'hola',
+ Thing: class {
+ label = 'thing'
+ },
+ }),
+ ).toBe('hola:thing
')
+ })
+
+ // Qwik: example_capturing_fn_class (class component scope)
+ test('client: locals of a class render method are captured', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Component } from 'react'
+import { Hydrate } from '@tanstack/react-start'
+export class Page extends Component {
+ render() {
+ const { label } = this.props
+ return {label}
+ }
+}
+`,
+ )
+ expect(getChunkParams(chunks[0]!)).toEqual(['label'])
+ expect(await renderChunk(chunks[0]!, { label: 'L' })).toBe('L
')
+ })
+
+ // Qwik: example_functional_component_capture_props, example_multi_capture
+ test('client: deeply destructured params with defaults and rests are captured', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page({ count, rest: [I2, { I3, v1: [I4], I5 = 'd5', ...I6 }, I7 = 'd7', ...I8] }) {
+ return {[count, I2, I3, I4, I5, JSON.stringify(I6), I7, I8.length, (({ aaa }) => aaa)({ aaa: 'A' })].join('|')}
+}
+`,
+ )
+ expect(getChunkParams(chunks[0]!)).toEqual([
+ 'I2',
+ 'I3',
+ 'I4',
+ 'I5',
+ 'I6',
+ 'I7',
+ 'I8',
+ 'count',
+ ])
+ expect(
+ await renderChunk(chunks[0]!, {
+ count: 1,
+ I2: 'i2',
+ I3: 'i3',
+ I4: 'i4',
+ I5: 'd5',
+ I6: { extra: 'x' },
+ I7: 'd7',
+ I8: [],
+ }),
+ ).toBe('1|i2|i3|i4|d5|{"extra":"x"}|d7|0|A
')
+ })
+
+ // Qwik: example_spread_jsx, should_convert_rest_props
+ test('client: spread props and rest params are captured', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+function Child(props) { return {props.a + props.extra} }
+export function Page({ id, ...rest }) {
+ const extra = '!'
+ return
+}
+`,
+ )
+ expect(getChunkParams(chunks[0]!)).toEqual(['extra', 'rest'])
+ expect(
+ await renderChunk(chunks[0]!, { rest: { a: 'A' }, extra: '!' }),
+ ).toBe('A! ')
+ })
+
+ // Qwik: should_wrap_type_asserted_variables_in_template
+ test('client: local types used by the children are not captured as values', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import type { Shape } from './types'
+function format(value: T) { return String(value) }
+export function Page({ v }: { v: unknown }) {
+ type Local = string
+ interface Box { value: Local }
+ return {format(v as Local) + (v satisfies unknown as Box['value'] as Shape)}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(getChunkParams(chunks[0]!)).toEqual(['v'])
+ expect(await renderChunk(chunks[0]!, { v: 'x' })).toBe('xx
')
+ })
+
+ // Qwik: example_qwik_conflict, import_collision_with_renaming
+ test('client: generated names do not collide with user bindings or imports', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { lazyRouteComponent } from '@tanstack/react-router'
+import { Hydrate } from '@tanstack/react-start'
+const _lazyRouteComponent = 'user'
+export const Lazy = lazyRouteComponent(() => import('./other'))
+export function Page() {
+ const _H0 = 'local'
+ const _H0_preload = 'preload'
+ return {_lazyRouteComponent + _H0 + _H0_preload}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ // The lazy chunk component must not be shadowed by the captured locals.
+ const module = await evaluate(parent, startStubs)
+ expect(module.Page!()).toBe('lazy(_H0=local,_H0_preload=preload)')
+ expect(
+ await renderChunk(chunks[0]!, { _H0: 'local', _H0_preload: 'preload' }),
+ ).toBe('userlocalpreload
')
+ })
+
+ // Qwik: example_capture_imports, example_qwik_conflict (locals shadowing imports)
+ test('client: a captured local shadowing an import or module binding wins inside the children only', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import { label } from './labels'
+import { Row } from './row'
+const value = 'module'
+function Show() { return {value + ':' + label} }
+export function Page() {
+ const label = 'local'
+ const value = 'local-value'
+ return |
{value}
+}
+`,
+ )
+ expect(getChunkParams(chunks[0]!)).toEqual(['label', 'value'])
+ expect(
+ await renderChunk(
+ chunks[0]!,
+ { label: 'local', value: 'local-value' },
+ {
+ './labels': { label: 'imported' },
+ './row': { Row: ({ text }: { text: string }) => `${text} ` },
+ },
+ ),
+ ).toBe('local module:imported local-value ')
+ })
+
+ // Qwik: example_capture_imports, jsx_member_tag_object_is_captured (imports)
+ test('client: default, namespace and aliased imports used by the children move into the chunk', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import Chart from './chart'
+import * as UI from './ui'
+import { format as fmt, default as theme } from './format'
+export function Page() {
+ return
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(
+ await renderChunk(
+ chunks[0]!,
+ {},
+ {
+ './chart': { default: () => ' ' },
+ './ui': {
+ Button: ({ label }: { label: string }) =>
+ `${label} `,
+ },
+ './format': {
+ format: (value: string) => `[${value}]`,
+ default: 'dark',
+ },
+ },
+ ),
+ ).toBe('[dark] ')
+ })
+
+ // Qwik: issue_964 (generators), example_use_server_mount (async scopes)
+ test('client: generators and async callbacks inside the children stay valid', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+async function save() {}
+export async function Page() {
+ return {[...(function* () { yield 'a'; yield 'b' })()].join('')}
{ await save() }}>s
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(await renderChunk(chunks[0]!)).toBe('ab
s ')
+ })
+
+ // Qwik: example_jsx (namespaced names inside the extracted scope)
+ test('client: namespaced JSX names in the children stay valid', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Icon() {
+ return
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(chunks[0]).toContain('xlink:href')
+ })
+
+ // Qwik: should_handle_dangerously_set_inner_html (string attributes)
+ test('client: string attributes keep their value in the chunk', async () => {
+ const element = `I'm an h1!" }} />`
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return ${element}
+}
+`,
+ )
+ const propsRuntime = `const Fragment = null
+const h = (type, props) => JSON.stringify(props)
+`
+ const expected = await evaluate(
+ `export const H0 = () => ${element}`,
+ {},
+ propsRuntime,
+ )
+ const module = await evaluate(chunks[0]!, {}, propsRuntime)
+ expect(module.H0!({})).toBe(expected.H0!())
+ })
+
+ // Qwik: example_default_export
+ test('client: an anonymous default-exported component splits its boundary', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const label = 'default'
+export default () => {label}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(await renderChunk(chunks[0]!)).toBe('default
')
+ })
+})
+
+describe('Hydrate split module-level code (ported from the Qwik optimizer)', () => {
+ // Qwik: example_invalid_references
+ test('client: module-level destructuring with defaults and rests moves into the chunk', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const obj = ['m2', { M3: 'm3', v1: ['m4'], extra: 'x' }]
+const v2 = 'd5'
+const v3 = 'd7'
+const [M2, { M3, v1: [M4], M5 = v2, ...M6 }, M7 = v3, ...M8] = obj
+export function Page() {
+ return {[M2, M3, M4, M5, JSON.stringify(M6), M7, M8.length].join('|')}
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(await renderChunk(chunks[0]!)).toBe(
+ 'm2|m3|m4|d5|{"extra":"x"}|d7|0
',
+ )
+ })
+
+ // Qwik: should_preserve_let_when_migrated_into_segment
+ test('client: a module-level let used only by the children stays mutable', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+let renders = 0
+export function Page() {
+ return {++renders}
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ const module = await evaluate(chunks[0]!)
+ expect(module.H0!({})).toBe('1
')
+ expect(module.H0!({})).toBe('2
')
+ })
+
+ // Qwik: should_keep_non_migrated_binding_from_shared_destructuring_declarator
+ // (+ _array_destructuring_declarator, _with_default, _with_rest)
+ test.each([
+ { name: 'object', declaration: `const { a, b } = { a: 'A', b: 'B' }` },
+ { name: 'array', declaration: `const [a, b] = ['A', 'B']` },
+ { name: 'default', declaration: `const { a = 'A', b } = { b: 'B' }` },
+ { name: 'rest', declaration: `const { a, ...b } = { a: 'A', c: 'B' }` },
+ ])(
+ 'client: an $name destructuring shared by the parent and the children keeps both bindings',
+ async ({ declaration }) => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+${declaration}
+export const fromRoot = () => JSON.stringify(b)
+export function Page() {
+ return {a}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(getDeclaredNames(parent).has('b')).toBe(true)
+ const module = await evaluate(parent, startStubs)
+ expect(module.fromRoot!()).toMatch(/B/)
+ expect(await renderChunk(chunks[0]!)).toBe('A
')
+ },
+ )
+
+ // Qwik: should_migrate_destructured_binding_with_imported_dependency
+ test('client: a module-level destructuring of an import moves into the chunk with the import', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+import { source } from './lib'
+const { a } = source
+export function Page() {
+ return {a}
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(
+ await renderChunk(chunks[0]!, {}, { './lib': { source: { a: 'A' } } }),
+ ).toBe('A
')
+ })
+
+ // Qwik: should_keep_root_var_used_by_export_decl_and_qrl,
+ // should_keep_root_var_used_by_exported_function_and_qrl
+ test('client: a module-level binding also used by exports stays in the parent', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const shared = { id: 'abc' }
+export const exportedValue = shared.id
+export function readShared() { return shared.id }
+export function Page() {
+ return {shared.id}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ const module = await evaluate(parent, startStubs)
+ expect(module.exportedValue).toBe('abc')
+ expect(module.readShared!()).toBe('abc')
+ expect(await renderChunk(chunks[0]!)).toBe('abc
')
+ })
+
+ // Qwik: example_segment_variable_migration,
+ // variable_migration_transitive_dep_used_by_other_segment
+ test('client: helpers shared by two boundaries and the parent stay available to each', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+const scrollState = (el) => ({ x: el.x, y: el.y })
+const saveScroll = (s) => s.x + s.y
+const bigHelper = (el) => saveScroll(scrollState(el))
+export const direct = () => saveScroll(scrollState({ x: 1, y: 1 }))
+export function App() {
+ return <>{bigHelper({ x: 1, y: 2 })}
{scrollState({ x: 3, y: 4 }).y}
>
+}
+`,
+ )
+ expect(chunks).toHaveLength(2)
+ expect(await getModuleErrors(parent)).toEqual([])
+ const module = await evaluate(parent, startStubs)
+ expect(module.direct!()).toBe(2)
+ expect(await renderChunk(chunks[0]!)).toBe('3
')
+ expect(await renderChunk(chunks[1]!)).toBe('4
')
+ })
+
+ // Qwik: example_ts_enums (TypeScript-only declaration forms)
+ test('client: an overloaded function used by the children keeps its implementation', async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+function label(value: string): string
+function label(value: number): string
+function label(value: unknown) { return 'label:' + String(value) }
+export function Page() {
+ return {label(1)}
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(await renderChunk(chunks[0]!)).toBe('label:1
')
+ })
+})
+
+describe('Hydrate boundary placement (ported from the Qwik optimizer)', () => {
+ // Qwik: nested_segment_param_does_not_collide_with_captured_props
+ test('client: a nested boundary receives the captures of its enclosing boundary', async () => {
+ const code = `import { Hydrate } from '@tanstack/react-start'
+export function Page({ isOpen, fee }) {
+ const label = 'fee'
+ return {label + ':' + (isOpen ? fee : 0)}
+}
+`
+ const server = await compileHydrate('server', code)
+ const { parent, chunks, plugin } = await compileHydrate('client', code)
+ expect(getChunkParams(chunks[0]!)).toEqual(['fee', 'isOpen', 'label'])
+ // The bundler compiles the loaded chunk like any other module.
+ const outer = await compileModule(
+ plugin,
+ 'client',
+ chunks[0]!,
+ getChunkIds(parent)[0]!,
+ )
+ expect(outer).not.toBeNull()
+ expect(await getModuleErrors(outer!)).toEqual([])
+ expect([...getBoundaryIds(parent), ...getBoundaryIds(outer!)]).toEqual(
+ getBoundaryIds(server.parent),
+ )
+ for (const name of ['fee', 'isOpen', 'label']) {
+ expect(outer).toContain(`${name}={${name}}`)
+ }
+ const inner = loadChunk(plugin, 'client', getChunkIds(outer!)[0]!)
+ expect(inner).not.toBeNull()
+ expect(getChunkParams(inner!)).toEqual(['fee', 'isOpen', 'label'])
+ expect(
+ await renderChunk(inner!, { label: 'fee', isOpen: true, fee: 5 }),
+ ).toBe('fee:5
')
+ })
+
+ // Qwik: example_mutable_children, issue_5008
+ test('client: boundaries in conditionals and callbacks load the chunk the server id names', async () => {
+ const code = `import { Hydrate } from '@tanstack/react-start'
+export function Page({ flag, items }) {
+ return
+ {flag ?
yes
:
no
}
+ {flag &&
and
}
+ {items.map(function (item, idx) { return
{'fn' + item}
})}
+ {items.map((item) =>
{'arrow' + item}
)}
+
+}
+export function Other({ early }) {
+ if (early) { return early
}
+ return late
+}
+`
+ const server = await compileHydrate('server', code)
+ const { parent, plugin } = await compileHydrate('client', code)
+ const serverIds = getBoundaryIds(server.parent)
+ expect(serverIds).toHaveLength(7)
+ expect(getBoundaryIds(parent)).toEqual(serverIds)
+ const rendered: Array = []
+ for (const id of serverIds) {
+ const chunkId = getChunkIds(parent).find((chunk) =>
+ chunk.endsWith(`=${id}`),
+ )
+ expect(chunkId).toBeDefined()
+ rendered.push(
+ await renderChunk(loadChunk(plugin, 'client', chunkId!)!, {
+ item: 'x',
+ }),
+ )
+ }
+ expect(rendered).toEqual([
+ 'yes
',
+ 'no
',
+ 'and
',
+ 'fnx
',
+ 'arrowx
',
+ 'early
',
+ 'late
',
+ ])
+ })
+})
+
+describe('Hydrate server fallback stripping (ported from the Qwik optimizer)', () => {
+ // Qwik: example_functional_component_capture_props (params used by a
+ // stripped scope)
+ test('server: params used only by the fallback keep later params in place', async () => {
+ const { parent } = await compileHydrate(
+ 'server',
+ `import { Hydrate } from '@tanstack/react-start'
+export function List({ items }) {
+ return {items.map((item, i) => {item} }>{i} )}
+}
+export function Card(label, body) {
+ return {label}}>{body}
+}
+`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ const module = await evaluate(parent, startStubs)
+ expect(module.List!({ items: ['a', 'b'] })).toBe(
+ '',
+ )
+ expect(module.Card!('L', 'B')).toBe('B
')
+ })
+
+ // Qwik: should_transform_handler_in_for_of_loop, should_not_auto_export_var_shadowed_in_catch
+ test.each([
+ {
+ name: 'a for-of loop variable',
+ code: `import { Hydrate } from '@tanstack/react-start'
+export function List({ xs }) {
+ const out = []
+ for (const x of xs) out.push({x}}>child
)
+ return out
+}
+`,
+ },
+ {
+ name: 'a catch param',
+ code: `import { Hydrate } from '@tanstack/react-start'
+export function Safe({ run }) {
+ try {
+ return run()
+ } catch (error) {
+ return {error.message}}>child
+ }
+}
+`,
+ },
+ ])(
+ 'server: $name used only by the fallback keeps the module valid',
+ async ({ code }) => {
+ const { parent } = await compileHydrate('server', code)
+ expect(await getModuleErrors(parent)).toEqual([])
+ },
+ )
+
+ // Qwik: should_keep_non_migrated_binding_from_shared_destructuring_declarator
+ test('server: a destructuring shared by the fallback and the children keeps the children binding', async () => {
+ const { parent } = await compileHydrate(
+ 'server',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page(props) {
+ const { a, b } = props
+ return {a}}>{b}
+}
+`,
+ )
+ const module = await evaluate(parent, startStubs)
+ expect(module.Page!({ a: 'A', b: 'B' })).toBe('B
')
+ })
+})
+
+/** Server function ids in a compiled module: client callers or providers. */
+function getRpcIds(
+ code: string,
+ callee: 'createClientRpc' | 'createServerRpc',
+) {
+ const pattern =
+ callee === 'createClientRpc'
+ ? /createClientRpc\(\s*["']([^"']+)["']/g
+ : /createServerRpc\(\s*\{\s*["']?id["']?\s*:\s*["']([^"']+)["']/g
+ return [...code.matchAll(pattern)].map(([, id]) => id!)
+}
+
+describe('server functions (ported from the Qwik optimizer)', () => {
+ // Qwik: example_strip_exports_used (server code referenced from an extracted scope)
+ test.each([
+ {
+ name: 'an exported server fn also called by the parent',
+ code: `import { Hydrate, createServerFn } from '@tanstack/react-start'
+export const greet = createServerFn().handler(async () => 'hi')
+export function Page() {
+ return <> greet()}>a greet()}>b >
+}
+`,
+ },
+ {
+ name: 'a private server fn only called by the children',
+ code: `import { Hydrate, createServerFn } from '@tanstack/react-start'
+const greet = createServerFn().handler(async () => 'hi')
+export function Page() {
+ return greet()}>b
+}
+`,
+ },
+ ])(
+ 'client: $name calls the provider id from the split chunk',
+ async ({ code }) => {
+ const { parent, chunks, plugin } = await compileHydrate('client', code)
+ const chunk = await compileModule(
+ plugin,
+ 'client',
+ chunks[0]!,
+ getChunkIds(parent)[0]!,
+ )
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code,
+ })
+ expect(chunk).not.toBeNull()
+ expect(await getModuleErrors(chunk!)).toEqual([])
+ // The handler itself stays on the server.
+ expect(chunk).not.toContain("'hi'")
+ const providerIds = getRpcIds(provider!, 'createServerRpc')
+ expect(providerIds).toHaveLength(1)
+ expect(getRpcIds(chunk!, 'createClientRpc')).toEqual(providerIds)
+ },
+ )
+
+ // Qwik: example_drop_side_effects,
+ // should_not_auto_export_var_shadowed_in_{catch,do_while,switch,labeled_block}
+ test('client: handler-only module state is dropped even when client code shadows its name', async () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+import { createDb } from './db.server'
+import { secret } from './secret'
+const db = createDb(secret)
+export const list = createServerFn().handler(async () => db.list())
+export function clientOnly(kind) {
+ try { return kind() } catch (err) { const db = 'catch'; return db }
+}
+export function other(kind) {
+ let i = 0
+ do { const db = i; i += db + 1 } while (i < 3)
+ switch (kind) { case 'a': { const db = 'case'; return db } }
+ block: { const db = 'labeled'; if (db) break block }
+ return i
+}
+`
+ const client = await compileStartModule({ env: 'client', code })
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code,
+ })
+ expect(await getModuleErrors(client!)).toEqual([])
+ expect(client).not.toContain('createDb')
+ expect(client).not.toContain('./secret')
+ expect(await getModuleErrors(provider!)).toEqual([])
+ expect(provider).toContain('createDb(secret)')
+ })
+
+ // Qwik: should_not_inline_exported_var_into_segment
+ test('an exported validator computed from module state stays declared once', async () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+import { wrapperFn, getEnv } from './utils'
+const flagEnabled = getEnv().PUBLIC_FEATURE
+export const FeatureSchema = wrapperFn(flagEnabled)
+export const featureAction = createServerFn({ method: 'POST' })
+ .inputValidator(FeatureSchema)
+ .handler(async () => ({ status: 'success' }))
+`
+ const client = await compileStartModule({ env: 'client', code })
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code,
+ })
+ for (const output of [client!, provider!]) {
+ expect(await getModuleErrors(output)).toEqual([])
+ }
+ expect(getDeclaredNames(client!).has('FeatureSchema')).toBe(true)
+ expect(provider!.match(/wrapperFn\(/g)).toHaveLength(1)
+ })
+
+ // Qwik: should_keep_module_level_var_used_in_both_main_and_qrl
+ test('module state shared by a handler and client code stays in both outputs', async () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+const state = { counter: 0 }
+export function bump() { return ++state.counter }
+export const read = createServerFn().handler(async () => state.counter)
+`
+ const client = await compileStartModule({ env: 'client', code })
+ const provider = await compileStartModule({
+ env: 'server',
+ provider: true,
+ code,
+ })
+ expect(await getModuleErrors(client!)).toEqual([])
+ expect(await getModuleErrors(provider!)).toEqual([])
+ expect(getDeclaredNames(client!).has('state')).toBe(true)
+ expect(getDeclaredNames(provider!).has('state')).toBe(true)
+ })
+})
From ebc44a64e67c793c02542ba092fbf9f86dce09a9 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:25:37 +0200
Subject: [PATCH 0010/1425] test(start-plugin-core, router-plugin): pin
compiler bugs found by porting Qwik optimizer tests
Pin, as expected failures, bugs found by porting capture and extraction
scenarios from the Qwik optimizer test suite (QwikDev/qwik,
packages/optimizer/core/src/test.rs). They fail with both the Babel and
the Yuku compiler:
- Hydrate copies module-level declarations into the split chunk, so a
module-level context is re-created and a parent-reassigned let is stale
- Hydrate drops top-level statements the children rely on (compound
component member assignments, Object.assign, registry calls)
- recursive components inside their own boundary get client boundary ids
the server never rendered and lazy imports of missing chunks
- `this` of a nested function in the children is rejected as a capture
- `React.useId()` in the children bypasses the hook check
- route code splitting re-creates a private context used by both the
reference module and the split component
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tests/ported-qwik-optimizer.test.ts | 120 +++++
.../tests/ported-qwik-optimizer.test.ts | 411 ++++++++++++++++++
2 files changed, 531 insertions(+)
create mode 100644 packages/router-plugin/tests/ported-qwik-optimizer.test.ts
create mode 100644 packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts
diff --git a/packages/router-plugin/tests/ported-qwik-optimizer.test.ts b/packages/router-plugin/tests/ported-qwik-optimizer.test.ts
new file mode 100644
index 00000000000..1213b58fd40
--- /dev/null
+++ b/packages/router-plugin/tests/ported-qwik-optimizer.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Known compiler bugs found by porting edge cases from the Qwik optimizer test
+ * suite (QwikDev/qwik, packages/optimizer/core/src/test.rs, MIT), pinned as
+ * expected failures. Each test names the Qwik test it is ported from.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+import { getModuleErrors } from './validate-module'
+
+const filename = 'route.tsx'
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(code: string) {
+ const groupings = defaultCodeSplitGroupings
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: groupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const reference = compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: groupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })
+ const modules: Record = {
+ reference: reference?.code ?? code,
+ }
+ for (const targets of groupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return { modules, sharedBindings }
+}
+
+async function getErrorsByModule(modules: Record) {
+ const errors: Record> = {}
+ for (const [name, code] of Object.entries(modules)) {
+ errors[name] = await getModuleErrors(code)
+ }
+ return errors
+}
+
+function noErrors(modules: Record) {
+ return Object.fromEntries(Object.keys(modules).map((name) => [name, []]))
+}
+
+/**
+ * How many times `pattern` occurs across the modules a bundle loads: the
+ * reference module, the split chunks it imports and the shared module.
+ */
+function countAcrossModules(modules: Record, pattern: RegExp) {
+ const reference = modules.reference!
+ return Object.entries(modules)
+ .filter(
+ ([name]) =>
+ !name.startsWith('virtual ') ||
+ reference.includes(`tsr-split=${name.slice('virtual '.length)}`),
+ )
+ .reduce(
+ (count, [, code]) =>
+ count + (code.match(new RegExp(pattern.source, 'g'))?.length ?? 0),
+ 0,
+ )
+}
+
+describe('known route code splitting bugs (ported from the Qwik optimizer)', () => {
+ // Qwik: should_keep_module_level_var_used_in_both_main_and_qrl
+ // Bug: a non-exported module-level binding used by both the reference module
+ // (here an exported provider) and a split component is copied into the split
+ // chunk instead of being shared, because it is never reassigned. Impact: the
+ // chunk creates its own context, so the split component ignores
+ // `` and renders the default value. Remove `.fails` once
+ // fixed.
+ it.fails(
+ 'creates a private context shared by a provider and the split component once',
+ async () => {
+ const { modules } = compileRouteModules(`
+import { createContext, useContext } from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+const Theme = createContext('light')
+export function ThemeProvider({ children }) {
+ return {children}
+}
+export const Route = createFileRoute('/')({
+ component: () => {useContext(Theme)}
,
+})
+`)
+ expect(await getErrorsByModule(modules)).toEqual(noErrors(modules))
+ expect(countAcrossModules(modules, /createContext\(/)).toBe(1)
+ },
+ )
+})
diff --git a/packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts b/packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts
new file mode 100644
index 00000000000..efb4f219ea4
--- /dev/null
+++ b/packages/start-plugin-core/tests/ported-qwik-optimizer.test.ts
@@ -0,0 +1,411 @@
+/**
+ * Known compiler bugs found by porting edge cases from the Qwik optimizer test
+ * suite (QwikDev/qwik, packages/optimizer/core/src/test.rs, MIT), pinned as
+ * expected failures. Each test names the Qwik test it is ported from.
+ *
+ * Every test asserts the CORRECT behaviour and is marked `.fails` because the
+ * compiler does not implement it yet. When a fix lands, the test starts
+ * passing, Vitest reports the `.fails` test as failed, and the `.fails`
+ * modifier must be removed.
+ */
+import { parseSync, transformWithOxc } from 'vite'
+import { describe, expect, test } from 'vitest'
+import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import { getModuleErrors } from './validate-module'
+
+const parentId = '/test/src/module.tsx'
+type Env = 'client' | 'server'
+type HydratePlugin = ReturnType
+
+/**
+ * Compiles one module through `StartCompiler` like the bundler plugins do: the
+ * parent module, or a loaded split chunk under its virtual module id.
+ */
+async function compileModule(
+ plugin: HydratePlugin,
+ env: Env,
+ code: string,
+ id = parentId,
+) {
+ const compiler: StartCompiler = new StartCompiler({
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (source) =>
+ source.startsWith('@tanstack/') ? source : null,
+ compilerPlugins: [plugin],
+ })
+ const result = await compiler.compile({
+ code,
+ id,
+ detectedKinds: detectKindsInCode(code, env),
+ })
+ return result?.code ?? null
+}
+
+/** The Hydrate chunk ids a compiled module imports, in source order. */
+function getChunkIds(code: string) {
+ return [...code.matchAll(/import\((["'])(.+?)\1\)/g)]
+ .map(([, , id]) => id!)
+ .filter((id) => id.includes('tss-hydrate='))
+}
+
+function loadChunk(plugin: HydratePlugin, env: Env, id: string) {
+ return (
+ plugin.loadVirtualModule?.({
+ id,
+ root: '/test',
+ env,
+ envName: env === 'client' ? 'client' : 'ssr',
+ })?.code ?? null
+ )
+}
+
+/** Compiles a module with `` and loads the split chunks it imports. */
+async function compileHydrate(env: Env, code: string) {
+ const plugin = createHydrateCompilerPlugin()
+ const parent = await compileModule(plugin, env, code)
+ if (parent === null) {
+ throw new Error('expected the module to be transformed')
+ }
+ const chunks = getChunkIds(parent).map((id) => {
+ const chunk = loadChunk(plugin, env, id)
+ if (chunk === null) {
+ throw new Error(`expected virtual module ${id} to load`)
+ }
+ return chunk
+ })
+ // Order chunks by boundary index, however the parent declares them.
+ const index = (chunk: string) =>
+ Number(chunk.match(/export function H(\d+)\(/)?.[1] ?? -1)
+ chunks.sort((a, b) => index(a) - index(b))
+ return { parent, chunks, plugin }
+}
+
+/** Boundary ids (`h` props) a compiled module renders, in source order. */
+function getBoundaryIds(code: string) {
+ return [...code.matchAll(/\bh=\s*["']([^"']+)["']/g)].map(([, id]) => id!)
+}
+
+const stubsKey = '__portedQwikOptimizerStubs'
+let evaluations = 0
+
+/** Renders JSX to text: intrinsic elements become tags, components are called. */
+const renderRuntime = `const Fragment = Symbol('Fragment')
+const h = (type, props, ...children) => {
+ const text = children.flat(Infinity).filter((c) => c != null && c !== false && c !== true).join('')
+ if (type === Fragment) return text
+ if (typeof type === 'function') return type({ ...props, children: text })
+ if (typeof type !== 'string') throw new Error('cannot render ' + String(type))
+ return '<' + type + '>' + text + '' + type + '>'
+}
+`
+
+/**
+ * Evaluates a compiled module like a bundler would: JSX becomes plain calls
+ * of the `runtime`, and imports are linked to `stubs`, keyed by specifier.
+ */
+async function evaluate(
+ code: string,
+ stubs: Record> = {},
+ runtime = renderRuntime,
+): Promise) => unknown>> {
+ const { code: javascript } = await transformWithOxc(code, 'module.tsx', {
+ jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' },
+ })
+ const key = `${stubsKey}${evaluations++}`
+ ;(globalThis as Record)[key] = stubs
+ // Imports are hoisted: link them before any other statement runs.
+ const imports: Array = []
+ const body = javascript.replace(
+ /^import\s+(.+?)\s+from\s+(["'])(.+?)\2;?$/gm,
+ (_, clause: string, __, source: string) => {
+ if (!(source in stubs)) {
+ throw new Error(`no stub for import ${source}`)
+ }
+ const from = `globalThis.${key}[${JSON.stringify(source)}]`
+ const named = clause.match(/\{(.*)\}/)?.[1]
+ const head = clause
+ .replace(/\{.*\}/, '')
+ .replace(/,\s*$/, '')
+ .trim()
+ if (named) {
+ imports.push(`const { ${named.replace(/\bas\b/g, ':')} } = ${from};`)
+ }
+ if (head.startsWith('* as ')) {
+ imports.push(`const ${head.slice(5)} = ${from};`)
+ } else if (head) {
+ imports.push(`const ${head} = ${from}.default;`)
+ }
+ return ''
+ },
+ )
+ const linked = `${imports.join('\n')}\n${body}`
+ return import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(runtime + linked)}`
+ )
+}
+
+/** Renders a chunk's component export with the given props. */
+async function renderChunk(
+ chunk: string,
+ props: Record = {},
+ stubs: Record> = {},
+) {
+ const module = await evaluate(chunk, stubs)
+ const name = Object.keys(module).find((key) => /^H\d+$/.test(key))
+ if (!name) {
+ throw new Error('expected the chunk to export a component')
+ }
+ return module[name]!(props)
+}
+
+/** Top-level binding names a module declares, from any declaration form. */
+function getDeclaredNames(code: string) {
+ const { program } = parseSync('module.tsx', code, { sourceType: 'module' })
+ const names = new Set()
+ const addPattern = (pattern: any): void => {
+ switch (pattern?.type) {
+ case 'Identifier':
+ names.add(pattern.name)
+ return
+ case 'ObjectPattern':
+ for (const property of pattern.properties) {
+ addPattern(
+ property.type === 'RestElement'
+ ? property.argument
+ : property.value,
+ )
+ }
+ return
+ case 'ArrayPattern':
+ pattern.elements.forEach(addPattern)
+ return
+ case 'AssignmentPattern':
+ addPattern(pattern.left)
+ return
+ case 'RestElement':
+ addPattern(pattern.argument)
+ return
+ }
+ }
+ for (const statement of program.body as Array) {
+ const declaration =
+ statement.type === 'ExportNamedDeclaration' ||
+ statement.type === 'ExportDefaultDeclaration'
+ ? statement.declaration
+ : statement
+ if (declaration?.type === 'VariableDeclaration') {
+ for (const declarator of declaration.declarations) {
+ addPattern(declarator.id)
+ }
+ } else if (declaration?.id?.name) {
+ names.add(declaration.id.name)
+ }
+ }
+ return names
+}
+
+describe('known Hydrate split bugs (ported from the Qwik optimizer)', () => {
+ // Qwik: should_keep_module_level_var_used_in_both_main_and_qrl
+ // Bug: module-level declarations used by the split children are copied into
+ // the chunk instead of being shared with the parent module, so their
+ // initializers run again. Impact: a context created in the route file is a
+ // different object in the chunk; children reading it ignore the parent's
+ // Provider and render the default value. Remove `.fails` once fixed.
+ test.fails(
+ 'client: a context created at module level is not re-created by the chunk',
+ async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { createContext, useContext } from 'react'
+import { Hydrate } from '@tanstack/react-start'
+const Theme = createContext('light')
+function Label() { return {useContext(Theme)} }
+export function Page() {
+ return
+}
+`,
+ )
+ expect(chunks).toHaveLength(1)
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(chunks[0]).not.toContain('createContext(')
+ },
+ )
+
+ // Qwik: should_auto_export_shared_let_kept_in_parent
+ // Bug: a module-level `let` that the parent reassigns is copied into the
+ // chunk with its initial value. Impact: the split children always render
+ // the initial value instead of the current one. Remove `.fails` once fixed.
+ test.fails(
+ 'client: a module-level let reassigned by the parent is not copied into the chunk',
+ async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+let clicks = 0
+export function bump() { clicks++ }
+export function Page() {
+ return {clicks}
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(getDeclaredNames(chunks[0]!).has('clicks')).toBe(false)
+ },
+ )
+
+ // Qwik: should_not_move_over_side_effects, example_drop_side_effects
+ // Bug: only declarations are moved into the chunk; top-level statements that
+ // complete them (member assignments, registrations) are dropped. Impact:
+ // compound components such as ` ` are undefined in the chunk
+ // and React throws "Element type is invalid"; registries are empty.
+ // Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'a compound component member assignment',
+ setup: `function Card({ children }) { return {children}
}
+Card.Title = function Title() { return t }`,
+ children: ' ',
+ rendered: '
t ',
+ },
+ {
+ name: 'Object.assign on a component',
+ setup: `function Tabs({ children }) { return }
+Object.assign(Tabs, { Tab: () => tab })`,
+ children: ' ',
+ rendered: '',
+ },
+ {
+ name: 'a registry filled by a top-level call',
+ setup: `const registry = new Map()
+registry.set('a', 'A')`,
+ children: `{registry.get('a')}
`,
+ rendered: 'A
',
+ },
+ ])(
+ 'client: $name that the children rely on reaches the chunk',
+ async ({ setup, children, rendered }) => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+${setup}
+export function Page() {
+ return ${children}
+}
+`,
+ )
+ expect(await getModuleErrors(chunks[0]!)).toEqual([])
+ expect(await renderChunk(chunks[0]!)).toBe(rendered)
+ },
+ )
+
+ // Qwik: root_level_self_referential_qrl, example_self_referential_component_migration
+ // Bug: a component that renders itself (or a component that renders it)
+ // inside its own boundary is copied into the chunk together with that
+ // boundary. Compiling the chunk numbers the copy after the chunk's own
+ // index, so it renders a boundary id the server never emitted, and its lazy
+ // import points at a boundary that does not exist in the source. Impact:
+ // nested boundaries of recursive components (trees, threads) do not match
+ // the server ids and their chunks fail to load. Remove `.fails` once fixed.
+ test.fails.each([
+ {
+ name: 'a self-recursive component',
+ code: `import { Hydrate } from '@tanstack/react-start'
+export function Tree({ node }) {
+ return {node.label}{node.children.map((child) => )}
+}
+`,
+ },
+ {
+ name: 'mutually recursive components',
+ code: `import { Hydrate } from '@tanstack/react-start'
+function Branch({ depth }) { return depth ? : null }
+export function Leaf({ depth }) {
+ return
+}
+`,
+ },
+ ])(
+ 'client: $name keeps boundary ids aligned with the server',
+ async ({ code }) => {
+ const server = await compileHydrate('server', code)
+ const serverIds = new Set(getBoundaryIds(server.parent))
+ const { parent, chunks, plugin } = await compileHydrate('client', code)
+ expect(chunks).toHaveLength(1)
+ // The bundler compiles the loaded chunk like any other module.
+ const chunk =
+ (await compileModule(
+ plugin,
+ 'client',
+ chunks[0]!,
+ getChunkIds(parent)[0]!,
+ )) ?? chunks[0]!
+ expect(await getModuleErrors(chunk)).toEqual([])
+ for (const id of [...getBoundaryIds(parent), ...getBoundaryIds(chunk)]) {
+ expect(serverIds).toContain(id)
+ }
+ for (const id of getChunkIds(chunk)) {
+ expect(loadChunk(plugin, 'client', id)).not.toBeNull()
+ }
+ },
+ )
+
+ // Qwik: issue_5008 (function-expression callbacks inside the extracted scope)
+ // Bug: every `this` inside the children is rejected, including `this` of a
+ // nested non-arrow function, which the chunk does not need to capture.
+ // Impact: valid code fails to build with "Hydrate cannot code-split JSX that
+ // captures this". Remove `.fails` once fixed.
+ test.fails(
+ 'client: this inside a function expression in the children is not a capture',
+ async () => {
+ const { chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate } from '@tanstack/react-start'
+export function Page({ items }) {
+ return {items.map(function (item) { return {this.prefix + item} }, { prefix: '>' })}
+}
+`,
+ )
+ expect(await renderChunk(chunks[0]!, { items: ['a', 'b'] })).toBe(
+ '',
+ )
+ },
+ )
+
+ // Qwik: example_use_optimization (use* calls inside the extracted scope)
+ // Bug: hook calls are only detected when the callee is a bare identifier, so
+ // `React.useId()` in the children is moved into the chunk component instead
+ // of being rejected. Impact: the hook runs in a different component on the
+ // client than on the server (e.g. `useId` returns a different id, so
+ // hydration mismatches). Remove `.fails` once fixed.
+ test.fails(
+ 'client: a hook called through a namespace in the children is rejected',
+ async () => {
+ await expect(
+ compileHydrate(
+ 'client',
+ `import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+export function Page() {
+ return x
+}
+`,
+ ),
+ ).rejects.toThrow(/hook/i)
+ },
+ )
+})
From a36449403a7b719df8693575d3eefeba10fc65ec Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 6 Oct 2026 22:06:30 +0200
Subject: [PATCH 0011/1425] test(router-plugin, start-plugin-core): cover
real-world compiler shapes
Adds regression coverage derived from a differential audit of in-repo
examples/e2e apps and open-source TanStack Router/Start apps:
- split chunks import exported route-file bindings instead of copying them
- declarations and imports only referenced from TypeScript types survive
(classic JSX runtime React imports, side-effecting initializers)
- TypeScript type and value exports sharing a name compile
- file-level JSX pragmas stay in split chunks and Start compiler output
- ambient declarations are not imported from the shared module
- chained comparisons in plain JavaScript route files keep their operands
- real-world route file names go through the code-splitter plugin pipeline
Co-Authored-By: Claude Opus 5.5 (1M context)
---
...code-splitter-ambient-declarations.test.ts | 138 ++++++
.../code-splitter-js-comparison-chain.test.ts | 50 ++
.../tests/code-splitter-jsx-pragmas.test.ts | 113 +++++
.../tests/code-splitter-real-world.test.ts | 434 ++++++++++++++++++
.../tests/start-compiler-jsx-pragma.test.ts | 133 ++++++
.../tests/start-compiler-real-world.test.ts | 118 +++++
6 files changed, 986 insertions(+)
create mode 100644 packages/router-plugin/tests/code-splitter-ambient-declarations.test.ts
create mode 100644 packages/router-plugin/tests/code-splitter-js-comparison-chain.test.ts
create mode 100644 packages/router-plugin/tests/code-splitter-jsx-pragmas.test.ts
create mode 100644 packages/router-plugin/tests/code-splitter-real-world.test.ts
create mode 100644 packages/start-plugin-core/tests/start-compiler-jsx-pragma.test.ts
create mode 100644 packages/start-plugin-core/tests/start-compiler-real-world.test.ts
diff --git a/packages/router-plugin/tests/code-splitter-ambient-declarations.test.ts b/packages/router-plugin/tests/code-splitter-ambient-declarations.test.ts
new file mode 100644
index 00000000000..30a130322c8
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter-ambient-declarations.test.ts
@@ -0,0 +1,138 @@
+import { parseSync, transformWithOxc } from 'vite'
+import { describe, expect, it } from 'vitest'
+import {
+ compileCodeSplitReferenceRoute,
+ compileCodeSplitSharedRoute,
+ compileCodeSplitVirtualRoute,
+ computeSharedBindings,
+} from '../src/core/code-splitter/compilers'
+import { defaultCodeSplitGroupings } from '../src/core/constants'
+
+const filename = 'route.tsx'
+
+/** Compiles a route file into every module the code splitter emits for it. */
+function compileRouteModules(code: string) {
+ const groupings = defaultCodeSplitGroupings
+ const sharedBindings = computeSharedBindings({
+ code,
+ filename,
+ codeSplitGroupings: groupings,
+ })
+ const shared = sharedBindings.size > 0 ? sharedBindings : undefined
+ const modules: Record = {
+ reference:
+ compileCodeSplitReferenceRoute({
+ code,
+ filename,
+ id: filename,
+ addHmr: false,
+ codeSplitGroupings: groupings,
+ targetFramework: 'react',
+ sharedBindings: shared,
+ })?.code ?? code,
+ }
+ for (const targets of groupings) {
+ const split = targets.join('-')
+ modules[`virtual ${split}`] = compileCodeSplitVirtualRoute({
+ code,
+ filename: `${filename}?tsr-split=${split}`,
+ splitTargets: targets,
+ sharedBindings: shared,
+ }).code
+ }
+ if (shared) {
+ modules.shared = compileCodeSplitSharedRoute({
+ code,
+ sharedBindings: shared,
+ filename: `${filename}?tsr-shared=1`,
+ }).code
+ }
+ return modules
+}
+
+/** The module record a bundler sees once TypeScript is erased. */
+async function runtimeModuleInfo(code: string) {
+ const { code: javascript } = await transformWithOxc(code, 'module.tsx', {
+ jsx: 'preserve',
+ typescript: { onlyRemoveTypeImports: true },
+ })
+ return parseSync('module.jsx', javascript, { sourceType: 'module' }).module
+}
+
+/**
+ * Every value a module imports from the shared module must be exported by the
+ * shared module at runtime; otherwise bundlers fail with a missing export and
+ * browsers refuse to link the module.
+ */
+async function missingSharedImports(modules: Record) {
+ const sharedExports = new Set()
+ if (modules.shared) {
+ for (const entry of (await runtimeModuleInfo(modules.shared))
+ .staticExports) {
+ for (const item of entry.entries) {
+ if (item.exportName.name) {
+ sharedExports.add(item.exportName.name)
+ }
+ }
+ }
+ }
+ const missing: Array = []
+ for (const [name, code] of Object.entries(modules)) {
+ if (name === 'shared') {
+ continue
+ }
+ for (const declaration of (await runtimeModuleInfo(code)).staticImports) {
+ if (!declaration.moduleRequest.value.includes('tsr-shared')) {
+ continue
+ }
+ for (const entry of declaration.entries) {
+ const imported = entry.importName.name ?? 'default'
+ if (!entry.isType && !sharedExports.has(imported)) {
+ missing.push(`${name} imports ${imported}`)
+ }
+ }
+ }
+ }
+ return missing
+}
+
+// Ambient declarations (`declare ...`) have no runtime binding: the value is
+// provided by the environment (a
+
+
+ {{ label }}
+
+`
+
+/** The option a piece file gives its route: its default export, lazily. */
+const lazyComponent = (file: string) => ({
+ kind: 'lazyRouteComponent',
+ file: `routes/${file}`,
+ name: 'default',
+})
+
+describe('Vue piece files', () => {
+ test('pieces of routes and of the root route are lazy components of their default export, and are left untouched', async () => {
+ const files = {
+ 'src/routes/__root.ts': rootRoute('vue'),
+ 'src/routes/__root.component.vue': sfc('root'),
+ 'src/routes/__root.notFoundComponent.vue': sfc('root not found'),
+ 'src/routes/__root.errorComponent.vue': sfc('root error'),
+ 'src/routes/__root.pendingComponent.vue': sfc('root pending'),
+ 'src/routes/index.ts': fileRoute('/', '{}', 'vue'),
+ 'src/routes/index.component.vue': sfc('index'),
+ 'src/routes/posts.ts': fileRoute('/posts', '{}', 'vue'),
+ 'src/routes/posts.pendingComponent.vue': sfc('posts pending'),
+ 'src/routes/posts.errorComponent.vue': sfc('posts error'),
+ 'src/routes/posts.notFoundComponent.vue': sfc('posts not found'),
+ 'src/routes/posts.$postId.ts': fileRoute('/posts/$postId', '{}', 'vue'),
+ 'src/routes/posts.$postId.component.vue': sfc('post'),
+ 'src/routes/posts.$postId.errorComponent.vue': sfc('post error'),
+ 'src/routes/settings.ts': fileRoute('/settings', '{}', 'vue'),
+ 'src/routes/settings.pendingComponent.vue': sfc('settings pending'),
+ // A route with only a piece file and no route file
+ 'src/routes/about.component.vue': sfc('about'),
+ }
+ const root = await createApp(files)
+ const { generatedRouteTree } = await generate(root, { target: 'vue' })
+
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.ts',
+ '/': 'routes/index.ts',
+ '/about': null,
+ '/posts': 'routes/posts.ts',
+ '/posts/$postId': 'routes/posts.$postId.ts',
+ '/settings': 'routes/settings.ts',
+ })
+ expect(await routeOptionsOf(generatedRouteTree)).toEqual({
+ __root__: {
+ component: lazyComponent('__root.component.vue'),
+ notFoundComponent: lazyComponent('__root.notFoundComponent.vue'),
+ errorComponent: lazyComponent('__root.errorComponent.vue'),
+ pendingComponent: lazyComponent('__root.pendingComponent.vue'),
+ },
+ '/': { component: lazyComponent('index.component.vue') },
+ '/about': { component: lazyComponent('about.component.vue') },
+ '/posts': {
+ pendingComponent: lazyComponent('posts.pendingComponent.vue'),
+ errorComponent: lazyComponent('posts.errorComponent.vue'),
+ notFoundComponent: lazyComponent('posts.notFoundComponent.vue'),
+ },
+ '/posts/$postId': {
+ component: lazyComponent('posts.$postId.component.vue'),
+ errorComponent: lazyComponent('posts.$postId.errorComponent.vue'),
+ },
+ '/settings': {
+ pendingComponent: lazyComponent('settings.pendingComponent.vue'),
+ },
+ })
+ for (const [file, content] of Object.entries(files)) {
+ expect(await read(root, file)).toBe(content)
+ }
+ })
+
+ test('with disableTypes, pieces of routes and of the root route are lazy components in a JavaScript route tree', async () => {
+ const root = await createApp({
+ 'src/routes/__root.js': rootRoute('vue'),
+ 'src/routes/__root.component.vue': sfc('root'),
+ 'src/routes/index.js': fileRoute('/', '{}', 'vue'),
+ 'src/routes/index.component.vue': sfc('index'),
+ })
+ const { generatedRouteTree } = await generate(root, {
+ target: 'vue',
+ disableTypes: true,
+ })
+
+ expect(generatedRouteTree.endsWith('.js')).toBe(true)
+ expect(await routeOptionsOf(generatedRouteTree)).toEqual({
+ __root__: { component: lazyComponent('__root.component.vue') },
+ '/': { component: lazyComponent('index.component.vue') },
+ })
+ })
+
+ // Control: the test above with pieces of other routes too.
+
+ // Bug: when only the root route has piece files, the route tree calls
+ // `lazyRouteComponent` without importing it, and fails to load with
+ // "ReferenceError: lazyRouteComponent is not defined".
+ // Source: src/generator.ts:652-680 (the runtime import is added for the
+ // pieces of non-root routes only).
+ // Impact: a Vue app whose only piece file is, e.g., `__root.component.vue`
+ // fails to start.
+ test.fails(
+ 'pieces of the root route only are lazy components of their default export',
+ async () => {
+ const root = await createApp({
+ 'src/routes/__root.ts': rootRoute('vue'),
+ 'src/routes/__root.component.vue': sfc('root'),
+ 'src/routes/index.ts': fileRoute('/', '{}', 'vue'),
+ })
+ const { generatedRouteTree } = await generate(root, { target: 'vue' })
+
+ expect(await routeOptionsOf(generatedRouteTree)).toEqual({
+ __root__: { component: lazyComponent('__root.component.vue') },
+ '/': {},
+ })
+ },
+ )
+})
+
+describe('empty files', () => {
+ test.each([
+ 'posts.component.vue',
+ 'posts.errorComponent.vue',
+ 'posts.pendingComponent.vue',
+ 'posts.notFoundComponent.vue',
+ 'posts.loader.ts',
+ ])(
+ 'an empty piece file %s is left empty and unused, and the routes still generate',
+ async (file) => {
+ const root = await createApp({
+ 'src/routes/__root.ts': rootRoute('vue'),
+ 'src/routes/posts.ts': fileRoute('/posts', '{}', 'vue'),
+ [`src/routes/${file}`]: '',
+ })
+ const { generatedRouteTree } = await generate(root, { target: 'vue' })
+
+ expect(await read(root, `src/routes/${file}`)).toBe('')
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.ts',
+ '/posts': 'routes/posts.ts',
+ })
+ expect(await routeOptionsOf(generatedRouteTree)).toEqual({
+ __root__: {},
+ '/posts': {},
+ })
+ },
+ )
+
+ test('an empty pathless layout file is scaffolded and added to the route tree', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/_auth.tsx': '',
+ })
+ const { generatedRouteTree } = await generate(root)
+
+ expect(await routeIdOf(await read(root, 'src/routes/_auth.tsx'))).toBe(
+ '/_auth',
+ )
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/_auth': 'routes/_auth.tsx',
+ })
+ })
+})
diff --git a/packages/router-generator/tests/route-tree.ts b/packages/router-generator/tests/route-tree.ts
index 19c08f30a71..ec174a381d1 100644
--- a/packages/router-generator/tests/route-tree.ts
+++ b/packages/router-generator/tests/route-tree.ts
@@ -41,23 +41,30 @@ export async function resolveImport(specifier: string, importer: string) {
return undefined
}
-/** A stub route: records what the route tree does with it. */
+/**
+ * A stub route: records what the route tree does with it (its options,
+ * loader options and lazy route module, and its children).
+ */
function stubRoute(file: string | null) {
return `({
file: ${JSON.stringify(file)},
options: {},
update(options) { Object.assign(this.options, options); return this },
- updateLoader() { return this },
- lazy() { return this },
+ updateLoader(options) { Object.assign(this.options, options); return this },
+ lazy(importer) { this.options.lazy = { importer: String(importer) }; return this },
_addFileChildren(children) { this.children = children; return this },
_addFileTypes() { return this },
})`
}
-/** A stub of the router package, for routes without a file. */
+/**
+ * A stub of the router package, for routes without a file and lazily loaded
+ * route options: `lazyRouteComponent` and `lazyFn` record the module they
+ * load and the export they take from it.
+ */
const routerModule = `export const createFileRoute = () => () => ${stubRoute(null)}
-export const lazyRouteComponent = () => 'lazyRouteComponent'
-export const lazyFn = () => 'lazyFn'`
+export const lazyRouteComponent = (importer, name) => ({ kind: 'lazyRouteComponent', importer: String(importer), name })
+export const lazyFn = (importer, name) => ({ kind: 'lazyFn', importer: String(importer), name })`
let evaluations = 0
@@ -73,17 +80,22 @@ function joinPaths(...paths: Array) {
return paths.join('/').replace(/\/{2,}/g, '/')
}
+/** A route of an evaluated route tree, with its id (as in `FileRoutesById`). */
+interface EvaluatedRoute {
+ id: string
+ /** The route file, relative to the tree (null for a route without a file). */
+ file: string | null
+ options: Record
+}
+
/**
* Evaluates the generated route tree at `treeFile`, with each route file it
- * imports replaced by a stub, and returns its routes: route id (as in
- * `FileRoutesById`) to the route file, relative to the tree (null for a route
- * without a file), with the root route as `__root__`. Like a bundler, fails on
- * an invalid module or a static or dynamic import that does not resolve to a
- * file.
+ * imports replaced by a stub, and returns its routes, the root route first
+ * (as `__root__`), and a function giving the file (relative to the tree) a
+ * recorded lazy import loads. Like a bundler, fails on an invalid module or a
+ * static or dynamic import that does not resolve to a file.
*/
-export async function routesOf(
- treeFile: string,
-): Promise> {
+async function evaluateRouteTree(treeFile: string) {
const evaluation = ++evaluations
const source = await readFile(treeFile, 'utf8')
const { code } = await transformWithOxc(source, path.basename(treeFile))
@@ -141,14 +153,80 @@ export async function routesOf(
/* @vite-ignore */ dataUrl(linked, evaluation)
)
- const routes: Record = { __root__: routeTree.file }
+ const routes: Array = [
+ { id: '__root__', file: routeTree.file, options: routeTree.options },
+ ]
const visit = (parentId: string, children: Record = {}) => {
for (const child of Object.values(children)) {
const id = joinPaths(parentId, child.options.id)
- routes[id] = child.file
+ routes.push({ id, file: child.file, options: child.options })
visit(id, child.children)
}
}
visit('', routeTree.children)
- return routes
+
+ /** The file a recorded lazy import (`() => import('…')…`) loads. */
+ const lazyFile = async (importer: string) => {
+ const specifier = /import\((['"`])(.+?)\1\)/.exec(importer)?.[2]
+ if (!specifier) {
+ throw new Error(`No import in ${importer}`)
+ }
+ const file = await resolveImport(specifier, treeFile)
+ if (!file) {
+ throw new Error(`The lazy import "${specifier}" does not resolve`)
+ }
+ return relative(file)
+ }
+ return { routes, lazyFile }
+}
+
+/**
+ * Evaluates the generated route tree at `treeFile` (see `evaluateRouteTree`)
+ * and returns its routes: route id (as in `FileRoutesById`) to the route
+ * file, relative to the tree (null for a route without a file), with the root
+ * route as `__root__`.
+ */
+export async function routesOf(
+ treeFile: string,
+): Promise> {
+ const { routes } = await evaluateRouteTree(treeFile)
+ return Object.fromEntries(routes.map(({ id, file }) => [id, file]))
+}
+
+/**
+ * Evaluates the generated route tree at `treeFile` (see `evaluateRouteTree`)
+ * and returns the options the tree gives each route, by route id (the root
+ * route as `__root__`), without the `id`, `path` and `getParentRoute` options
+ * that place it in the tree. A lazily loaded option is described by the file
+ * it loads (relative to the tree) and, for `lazyRouteComponent` / `lazyFn`,
+ * the export it takes: `{ kind, file, name }`; the lazy route module of a
+ * route as `lazy: { file }`.
+ */
+export async function routeOptionsOf(
+ treeFile: string,
+): Promise>> {
+ const { routes, lazyFile } = await evaluateRouteTree(treeFile)
+ const result: Record> = {}
+ for (const route of routes) {
+ const options: Record = {}
+ for (const [key, value] of Object.entries(route.options)) {
+ if (['id', 'path', 'getParentRoute'].includes(key)) {
+ continue
+ }
+ if (value && typeof value === 'object' && 'importer' in value) {
+ options[key] =
+ key === 'lazy'
+ ? { file: await lazyFile(value.importer) }
+ : {
+ kind: value.kind,
+ file: await lazyFile(value.importer),
+ name: value.name,
+ }
+ } else {
+ options[key] = value
+ }
+ }
+ result[route.id] = options
+ }
+ return result
}
diff --git a/packages/router-generator/tests/template.test.ts b/packages/router-generator/tests/template.test.ts
index 3f0010c9046..3ed9fac6662 100644
--- a/packages/router-generator/tests/template.test.ts
+++ b/packages/router-generator/tests/template.test.ts
@@ -1,9 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
-import { parseSync } from 'vite'
+import { parseSync, transformWithOxc } from 'vite'
import { describe, expect, it, vi } from 'vitest'
import { Generator, getConfig } from '../src'
+import { createApp, generate, read } from './app'
+import { routesOf } from './route-tree'
import type { Config } from '../src'
/** The string the route call of a route file creates its route with. */
@@ -105,3 +107,120 @@ describe('route templates', () => {
])
})
})
+
+/** A rendered element: its type and children (strings or elements). */
+interface Rendered {
+ type: unknown
+ children: Array
+}
+
+/**
+ * Evaluates a scaffolded route file for `target` and renders its route
+ * component once. The file may import only the target's router package
+ * (stubbed: route constructors, `Outlet`) and the target's framework
+ * (stubbed: Vue's `h`; Solid's JSX compiles like React's, to a stub JSX
+ * runtime); like a bundler of an app without other packages, any other import
+ * fails. Returns the route and the text and elements it renders.
+ */
+async function evaluateScaffold(code: string, target: Config['target']) {
+ const element = `const element = (type, children) => ({ type, children: [children ?? []].flat(Infinity).filter((c) => c != null && c !== false) })`
+ const stubs: Record = {
+ [`@tanstack/${target}-router`]: `export const createFileRoute = (id) => (options) => ({ id, options })
+export const createLazyFileRoute = createFileRoute
+export const createRootRoute = (options) => ({ id: '__root__', options })
+export const Outlet = 'Outlet'`,
+ ...(target === 'vue'
+ ? {
+ vue: `${element}\nexport const h = (type, props, children) => element(type, children)`,
+ }
+ : {
+ [target === 'solid' ? 'solid-js' : 'react']: `export {}`,
+ 'react/jsx-runtime': `${element}
+export const jsx = (type, props) => element(type, props.children)
+export const jsxs = jsx
+export const Fragment = 'Fragment'`,
+ }),
+ }
+ const { code: javascript } = await transformWithOxc(code, 'route.tsx')
+ const { module, errors } = parseSync('route.js', javascript)
+ expect(errors).toEqual([])
+ let linked = javascript
+ for (const statement of [...module.staticImports].sort(
+ (a, b) => b.moduleRequest.start - a.moduleRequest.start,
+ )) {
+ const specifier = statement.moduleRequest.value
+ const stub = stubs[specifier]
+ if (stub === undefined) {
+ throw new Error(`The scaffolded file imports "${specifier}"`)
+ }
+ linked =
+ linked.slice(0, statement.moduleRequest.start) +
+ JSON.stringify(`data:text/javascript,${encodeURIComponent(stub)}`) +
+ linked.slice(statement.moduleRequest.end)
+ }
+ const { Route } = await import(
+ /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(linked)}`
+ )
+ const rendered: Rendered = Route.options.component()
+ const text: Array = []
+ const types: Array = []
+ const visit = (node: Rendered | string) => {
+ if (typeof node === 'string') {
+ text.push(node)
+ } else {
+ types.push(node.type)
+ node.children.forEach(visit)
+ }
+ }
+ visit(rendered)
+ return { id: Route.id, text: text.join(''), types }
+}
+
+// A new, empty route, lazy route or root route file is scaffolded into a
+// working route for each target.
+describe.each(['react', 'solid', 'vue'] as const)(
+ 'scaffolded %s routes',
+ (target) => {
+ it('empty root, route and lazy route files become routes whose components render', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': '',
+ 'src/routes/posts.tsx': '',
+ 'src/routes/about.lazy.tsx': '',
+ 'src/routes/{-$locale}/contact.tsx': '',
+ 'src/routes/{-$locale}/help.lazy.tsx': '',
+ })
+ const { generatedRouteTree } = await generate(root, { target })
+
+ const scaffolded = async (file: string) =>
+ evaluateScaffold(await read(root, `src/routes/${file}`), target)
+ const rootFile = await scaffolded('__root.tsx')
+ expect(rootFile.id).toBe('__root__')
+ expect(rootFile.text).toContain('Hello')
+ expect(rootFile.types).toContain('Outlet')
+ expect(await scaffolded('posts.tsx')).toMatchObject({
+ id: '/posts',
+ text: 'Hello "/posts"!',
+ })
+ expect(await scaffolded('about.lazy.tsx')).toMatchObject({
+ id: '/about',
+ text: 'Hello "/about"!',
+ })
+ // The greeting names the route path, its param included.
+ expect(await scaffolded('{-$locale}/contact.tsx')).toMatchObject({
+ id: '/{-$locale}/contact',
+ text: expect.stringMatching(/\$locale\/contact/),
+ })
+ expect(await scaffolded('{-$locale}/help.lazy.tsx')).toMatchObject({
+ id: '/{-$locale}/help',
+ text: expect.stringMatching(/\$locale\/help/),
+ })
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/posts': 'routes/posts.tsx',
+ '/about': null,
+ '/{-$locale}/contact': 'routes/{-$locale}/contact.tsx',
+ '/{-$locale}/help': null,
+ })
+ })
+ },
+)
From 53c3ff00fab0c349d7b065f4f107154034ca5db5 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:30:42 +0200
Subject: [PATCH 0242/1425] test(router-generator): a generator kept across
edits, concurrent saves, file owners
Round-3 mutation gaps (ranked 28 and the dev-related medium/low tiers):
- generator-dev.test.ts (new): a long-lived generator fed watcher events.
Adding/deleting a nested route gives a fresh run's tree; a route deleted
after a no-op edit leaves the tree; a broken edit fails the run and the fix
is processed; a hand-edited or corrupted routeTree.gen.ts is regenerated
on its update event; a new generator on an up-to-date app does not
rewrite the tree; a missing root route fails naming __root.tsx; route
files in physical() mounts and virtual routes outside the routes directory
are watched; afterTransform receives the previous node (Start's
server-only toggle detection); getRoutesByFileMap maps the root file to
/__root, also after a rerun.
- concurrent-write.test.ts: a save while the generator fixes the file is
processed before run() resolves; an in-place save on a filesystem with
1 s timestamps is not overwritten; rewritten route files keep their mode
and owner (EPERM warns, other chown errors fail the run and leave the
file).
Pins (main bugs, REPORT section 6), each verified red for the stated reason:
- deleting a layout route drops its child routes in a running generator
(tree has only __root__);
- a deleted routeTree.gen.ts is not written again on its delete event (file
missing), and the next route change fails with ENOENT;
- editing the virtualRouteConfig file has no effect in a running generator
(tree keeps the old route).
Mutants verified killed (rg set): 280, 337, 298, 299, 301-305, 307-310, 312,
313, 315, 316, 771, 784, 1170-1173, 756, 758, 876, 884, 918, 925, 941, 942,
954, 343, 1190-1192, 1198-1203, 192, 194, 196, 198-200, 205, 167, 182, 838,
45, 795-797, 799-810, 812, 814, 816, 817, 819-825, 827. Pin flips checked:
639 (layout pin), 354 (deleted tree pin).
Co-Authored-By: Claude Opus 5.5
---
.../tests/concurrent-write.test.ts | 259 +++++++++-
.../tests/generator-dev.test.ts | 443 ++++++++++++++++++
2 files changed, 699 insertions(+), 3 deletions(-)
create mode 100644 packages/router-generator/tests/generator-dev.test.ts
diff --git a/packages/router-generator/tests/concurrent-write.test.ts b/packages/router-generator/tests/concurrent-write.test.ts
index 6550ad562dd..83fc3616359 100644
--- a/packages/router-generator/tests/concurrent-write.test.ts
+++ b/packages/router-generator/tests/concurrent-write.test.ts
@@ -1,9 +1,11 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { Generator, getConfig } from '../src'
+import { routeIdOf } from './route-id'
+import { routesOf } from './route-tree'
const rootRouteSource = `import { createRootRoute } from '@tanstack/react-router'
@@ -28,13 +30,21 @@ afterEach(async () => {
}
})
-async function readFileWithStat(filePath: string) {
+/**
+ * Reads a file and its modification time, like the generator's default
+ * filesystem. With `coarse`, the time is rounded down to the second, like on
+ * filesystems with a 1 s timestamp granularity.
+ */
+async function readFileWithStat(filePath: string, coarse = false) {
try {
const fileHandle = await fs.open(filePath, 'r')
const stat = await fileHandle.stat({ bigint: true })
const fileContent = (await fileHandle.readFile()).toString()
await fileHandle.close()
- return { stat, fileContent }
+ return {
+ stat: { mtimeMs: coarse ? toSeconds(stat.mtimeMs) : stat.mtimeMs },
+ fileContent,
+ }
} catch (e: any) {
if (e?.code === 'ENOENT') {
return 'file-not-existing' as const
@@ -43,6 +53,34 @@ async function readFileWithStat(filePath: string) {
}
}
+function toSeconds(mtimeMs: bigint) {
+ return (mtimeMs / 1000n) * 1000n
+}
+
+/**
+ * Creates an app in a temporary directory with a root route and
+ * `routes/posts.tsx` holding `postsSource`; returns its paths and the
+ * configuration of a generator for it.
+ */
+async function createApp(postsSource: string) {
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tsr-generator-race-'))
+ const routesDirectory = path.join(tempDir, 'routes')
+ const postsRoutePath = path.join(routesDirectory, 'posts.tsx')
+ await fs.mkdir(routesDirectory)
+ await fs.writeFile(path.join(routesDirectory, '__root.tsx'), rootRouteSource)
+ await fs.writeFile(postsRoutePath, postsSource)
+ const config = getConfig(
+ {
+ disableLogging: true,
+ routesDirectory,
+ generatedRouteTree: path.join(tempDir, 'routeTree.gen.ts'),
+ tmpDir: path.join(tempDir, 'tmp'),
+ },
+ tempDir,
+ )
+ return { root: tempDir, postsRoutePath, config }
+}
+
describe('generator with concurrent in-place saves', () => {
it.each(['route file read', 'temporary file stat'] as const)(
'does not scaffold over a route file whose in-place save completes during the %s',
@@ -146,3 +184,218 @@ describe('generator with concurrent in-place saves', () => {
},
)
})
+
+describe('a route file saved while the generator fixes it', () => {
+ it('is processed before run() resolves', async () => {
+ const { root, postsRoutePath, config } = await createApp(
+ postsRouteSource.replace("'/posts'", "'/old'"),
+ )
+ let saved = false
+ const generator = new Generator({
+ config,
+ root,
+ fs: {
+ rename: (oldPath, newPath) => fs.rename(oldPath, newPath),
+ writeFile: (filePath, content) => fs.writeFile(filePath, content),
+ readFile: async (filePath) => {
+ const result = await readFileWithStat(filePath)
+ if (!saved && path.resolve(filePath) === postsRoutePath) {
+ // The user saves the file right after the generator read it.
+ saved = true
+ await fs.writeFile(
+ postsRoutePath,
+ savedPostsRouteSource.replace("'/posts'", "'/wrong'"),
+ )
+ const savedAt = new Date(Date.now() + 10_000)
+ await fs.utimes(postsRoutePath, savedAt, savedAt)
+ }
+ return result
+ },
+ stat: async (filePath) => {
+ const stat = await fs.stat(filePath, { bigint: true })
+ return {
+ mtimeMs: stat.mtimeMs,
+ mode: Number(stat.mode),
+ uid: Number(stat.uid),
+ gid: Number(stat.gid),
+ }
+ },
+ chmod: (filePath, mode) => fs.chmod(filePath, mode),
+ chown: (filePath, uid, gid) => fs.chown(filePath, uid, gid),
+ },
+ })
+
+ await generator.run()
+
+ expect(saved).toBe(true)
+ await expect(fs.readFile(postsRoutePath, 'utf8')).resolves.toBe(
+ savedPostsRouteSource,
+ )
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/posts': 'routes/posts.tsx',
+ })
+ })
+
+ it('on a filesystem with 1 s timestamps, is not overwritten when the save completes during the read', async () => {
+ const { root, postsRoutePath, config } = await createApp(postsRouteSource)
+ let saveRacesNextRead = false
+ const generator = new Generator({
+ config,
+ root,
+ fs: {
+ rename: (oldPath, newPath) => fs.rename(oldPath, newPath),
+ writeFile: (filePath, content) => fs.writeFile(filePath, content),
+ readFile: async (filePath) => {
+ if (!saveRacesNextRead || path.resolve(filePath) !== postsRoutePath) {
+ return readFileWithStat(filePath, true)
+ }
+ saveRacesNextRead = false
+ // An in-place save truncates the file, then writes the new content
+ // within the same second.
+ await fs.truncate(postsRoutePath)
+ const result = await readFileWithStat(postsRoutePath, true)
+ await fs.writeFile(postsRoutePath, savedPostsRouteSource)
+ return result
+ },
+ stat: async (filePath) => {
+ const stat = await fs.stat(filePath, { bigint: true })
+ return {
+ mtimeMs: toSeconds(stat.mtimeMs),
+ mode: Number(stat.mode),
+ uid: Number(stat.uid),
+ gid: Number(stat.gid),
+ }
+ },
+ chmod: (filePath, mode) => fs.chmod(filePath, mode),
+ chown: (filePath, uid, gid) => fs.chown(filePath, uid, gid),
+ },
+ })
+ await generator.run()
+
+ await fs.writeFile(
+ postsRoutePath,
+ postsRouteSource.replace("'posts'", "'edited'"),
+ )
+ const editedAt = new Date(Date.now() + 10_000)
+ await fs.utimes(postsRoutePath, editedAt, editedAt)
+ saveRacesNextRead = true
+ await generator.run({ type: 'update', path: postsRoutePath })
+
+ expect(saveRacesNextRead).toBe(false)
+ await expect(fs.readFile(postsRoutePath, 'utf8')).resolves.toBe(
+ savedPostsRouteSource,
+ )
+ })
+})
+
+describe('route files the generator rewrites', () => {
+ it.skipIf(process.platform === 'win32')('keep their file mode', async () => {
+ const { root, postsRoutePath, config } = await createApp(
+ postsRouteSource.replace("'/posts'", "'/old'"),
+ )
+ await fs.chmod(postsRoutePath, 0o600)
+ await new Generator({ config, root }).run()
+
+ expect(await routeIdOf(await fs.readFile(postsRoutePath, 'utf8'))).toBe(
+ '/posts',
+ )
+ expect((await fs.stat(postsRoutePath)).mode & 0o777).toBe(0o600)
+ })
+
+ /**
+ * A filesystem whose files have owners: real files, with their owners kept
+ * in `owners` (files without an entry belong to the current user, 1000).
+ * `chown` fails with `chownError` when given.
+ */
+ function filesystemWithOwners(
+ owners: Map,
+ chownError?: string,
+ ) {
+ const currentUser = { uid: 1000, gid: 1000 }
+ const key = (filePath: string) => path.resolve(filePath)
+ return {
+ rename: async (oldPath: string, newPath: string) => {
+ await fs.rename(oldPath, newPath)
+ owners.set(key(newPath), owners.get(key(oldPath)) ?? currentUser)
+ owners.delete(key(oldPath))
+ },
+ writeFile: (filePath: string, content: string) =>
+ fs.writeFile(filePath, content),
+ readFile: (filePath: string) => readFileWithStat(filePath),
+ stat: async (filePath: string) => {
+ const stat = await fs.stat(filePath, { bigint: true })
+ const owner = owners.get(key(filePath)) ?? currentUser
+ return { mtimeMs: stat.mtimeMs, mode: Number(stat.mode), ...owner }
+ },
+ chmod: (filePath: string, mode: number) => fs.chmod(filePath, mode),
+ chown: async (filePath: string, uid: number, gid: number) => {
+ if (chownError) {
+ throw Object.assign(new Error(`chown ${chownError}`), {
+ code: chownError,
+ })
+ }
+ owners.set(key(filePath), { uid, gid })
+ },
+ }
+ }
+
+ it.each([
+ { owner: { uid: 1001, gid: 1000 } },
+ { owner: { uid: 1000, gid: 1001 } },
+ ])('keep their owner $owner', async ({ owner }) => {
+ const { root, postsRoutePath, config } = await createApp(
+ postsRouteSource.replace("'/posts'", "'/old'"),
+ )
+ const owners = new Map([[postsRoutePath, owner]])
+ await new Generator({
+ config,
+ root,
+ fs: filesystemWithOwners(owners),
+ }).run()
+
+ expect(await routeIdOf(await fs.readFile(postsRoutePath, 'utf8'))).toBe(
+ '/posts',
+ )
+ expect(owners.get(postsRoutePath)).toEqual(owner)
+ })
+
+ it('are still fixed, with a warning, when their owner cannot be given back', async () => {
+ const { root, postsRoutePath, config } = await createApp(
+ postsRouteSource.replace("'/posts'", "'/old'"),
+ )
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ try {
+ await new Generator({
+ config,
+ root,
+ fs: filesystemWithOwners(
+ new Map([[postsRoutePath, { uid: 0, gid: 0 }]]),
+ 'EPERM',
+ ),
+ }).run()
+ expect(warn.mock.calls.flat().join('\n')).toContain('chown')
+ } finally {
+ warn.mockRestore()
+ }
+ expect(await routeIdOf(await fs.readFile(postsRoutePath, 'utf8'))).toBe(
+ '/posts',
+ )
+ })
+
+ it('are left as they are when giving their owner back fails otherwise, and the run fails', async () => {
+ const source = postsRouteSource.replace("'/posts'", "'/old'")
+ const { root, postsRoutePath, config } = await createApp(source)
+ await expect(
+ new Generator({
+ config,
+ root,
+ fs: filesystemWithOwners(
+ new Map([[postsRoutePath, { uid: 0, gid: 0 }]]),
+ 'EIO',
+ ),
+ }).run(),
+ ).rejects.toThrow('chown EIO')
+ await expect(fs.readFile(postsRoutePath, 'utf8')).resolves.toBe(source)
+ })
+})
diff --git a/packages/router-generator/tests/generator-dev.test.ts b/packages/router-generator/tests/generator-dev.test.ts
new file mode 100644
index 00000000000..76199cc10a5
--- /dev/null
+++ b/packages/router-generator/tests/generator-dev.test.ts
@@ -0,0 +1,443 @@
+/**
+ * A generator kept across edits, as the bundler plugins keep one in dev and
+ * watch mode: each file event (`create`, `update`, `delete`, with the
+ * absolute path a file watcher reports) is passed to `run()`, and the route
+ * tree must be the one a fresh generator writes for the app at that point.
+ */
+import { existsSync } from 'node:fs'
+import { readFile, rm, stat, utimes } from 'node:fs/promises'
+import path from 'node:path'
+import { describe, expect, test } from 'vitest'
+import {
+ physical,
+ rootRoute as virtualRootRoute,
+ route as virtualRoute,
+} from '@tanstack/virtual-file-routes'
+import { Generator } from '../src'
+import { configOf, createApp, fileRoute, read, rootRoute, save } from './app'
+import { routeIdOf } from './route-id'
+import { routesOf } from './route-tree'
+import type { Config, GeneratorPlugin, RouteNode } from '../src'
+
+/** A generator for the app at `root`, after its first run. */
+async function startGenerator(
+ root: string,
+ inlineConfig: Partial = {},
+) {
+ const config = configOf(root, inlineConfig)
+ const generator = new Generator({ config, root })
+ await generator.run()
+ return { config, generator }
+}
+
+/**
+ * The route tree a fresh generator writes for the app at `root`, written next
+ * to the app's route tree (so its imports are the same).
+ */
+async function freshTree(root: string, config: Config) {
+ const fresh = path.join(
+ path.dirname(config.generatedRouteTree),
+ 'fresh.gen.ts',
+ )
+ await new Generator({
+ config: { ...config, generatedRouteTree: fresh },
+ root,
+ }).run()
+ return readFile(fresh, 'utf8')
+}
+
+/** Deletes an app file (path relative to the app root); returns its path. */
+async function remove(root: string, file: string) {
+ const filePath = path.join(root, file)
+ await rm(filePath)
+ return filePath
+}
+
+describe('route edits', () => {
+ test('adding a route next to nested routes gives the route tree of a fresh run', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/posts.tsx': fileRoute('/posts'),
+ 'src/routes/posts.$id.tsx': fileRoute('/posts/$id'),
+ })
+ const { config, generator } = await startGenerator(root)
+
+ const about = await save(root, 'src/routes/about.tsx', fileRoute('/about'))
+ await generator.run({ type: 'create', path: about })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/about': 'routes/about.tsx',
+ '/posts': 'routes/posts.tsx',
+ '/posts/$id': 'routes/posts.$id.tsx',
+ })
+ expect(await readFile(config.generatedRouteTree, 'utf8')).toBe(
+ await freshTree(root, config),
+ )
+ })
+
+ test('deleting a nested route gives the route tree of a fresh run', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/posts.tsx': fileRoute('/posts'),
+ 'src/routes/posts.$id.tsx': fileRoute('/posts/$id'),
+ 'src/routes/posts.new.tsx': fileRoute('/posts/new'),
+ })
+ const { config, generator } = await startGenerator(root)
+
+ const deleted = await remove(root, 'src/routes/posts.$id.tsx')
+ await generator.run({ type: 'delete', path: deleted })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/posts': 'routes/posts.tsx',
+ '/posts/new': 'routes/posts.new.tsx',
+ })
+ expect(await readFile(config.generatedRouteTree, 'utf8')).toBe(
+ await freshTree(root, config),
+ )
+ })
+
+ test('a route deleted after an edit that changed no route leaves the route tree', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/index.tsx': fileRoute('/', "{ component: () => 'index' }"),
+ 'src/routes/bar.tsx': fileRoute('/bar'),
+ })
+ const { config, generator } = await startGenerator(root)
+
+ const index = await save(
+ root,
+ 'src/routes/index.tsx',
+ fileRoute('/', "{ component: () => 'edited' }"),
+ )
+ await generator.run({ type: 'update', path: index })
+ const deleted = await remove(root, 'src/routes/bar.tsx')
+ await generator.run({ type: 'delete', path: deleted })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/': 'routes/index.tsx',
+ })
+ })
+
+ test('a route file broken by an edit fails the run, and fixing it is processed', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/posts.tsx': fileRoute('/posts'),
+ })
+ const { config, generator } = await startGenerator(root)
+
+ const posts = await save(
+ root,
+ 'src/routes/posts.tsx',
+ 'export const Route = (',
+ )
+ await expect(
+ generator.run({ type: 'update', path: posts }),
+ ).rejects.toThrow('posts.tsx')
+ await save(root, 'src/routes/posts.tsx', fileRoute('/wrong'))
+ await generator.run({ type: 'update', path: posts })
+
+ expect(await read(root, 'src/routes/posts.tsx')).toBe(fileRoute('/posts'))
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/posts': 'routes/posts.tsx',
+ })
+ })
+
+ // Control: "deleting a nested route gives the route tree of a fresh run"
+ // above (same harness, deleting a leaf route).
+
+ // Bug: after a layout route file is deleted, its child routes disappear
+ // from the route tree of a running generator, while a fresh run keeps them
+ // (under the root route).
+ // Source: src/generator.ts (route nodes reused from the cache keep the
+ // `parent` of the run that created them, so the child still points at the
+ // deleted layout).
+ // Impact: in dev, deleting `posts.tsx` while keeping `posts.$id.tsx`
+ // removes `/posts/$id` from the app until the dev server restarts.
+ test.fails(
+ 'deleting a layout route keeps its child routes in the route tree',
+ async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/posts.tsx': fileRoute('/posts'),
+ 'src/routes/posts.$id.tsx': fileRoute('/posts/$id'),
+ })
+ const { config, generator } = await startGenerator(root)
+
+ const deleted = await remove(root, 'src/routes/posts.tsx')
+ await generator.run({ type: 'delete', path: deleted })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/posts/$id': 'routes/posts.$id.tsx',
+ })
+ },
+ )
+})
+
+describe('route tree file events', () => {
+ test.each([
+ { name: 'hand-edited', content: '// edited\n' },
+ { name: 'corrupted', content: 'export const routeTree = (\n' },
+ ])(
+ 'a $name route tree is regenerated on its update event',
+ async ({ content }) => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/index.tsx': fileRoute('/'),
+ })
+ const { config, generator } = await startGenerator(root)
+ const generated = await readFile(config.generatedRouteTree, 'utf8')
+
+ await save(root, 'src/routeTree.gen.ts', content)
+ await generator.run({ type: 'update', path: config.generatedRouteTree })
+
+ expect(await readFile(config.generatedRouteTree, 'utf8')).toBe(generated)
+ },
+ )
+
+ // Control: "a hand-edited route tree is regenerated on its update event"
+ // above (same harness).
+
+ // Bug: a deleted route tree file is not written again: neither on its
+ // delete event, nor on the next route change, whose run fails with ENOENT.
+ // Source: src/generator.ts (the route tree cache still holds the deleted
+ // file's content, so the write is skipped as unchanged; the next write
+ // then fails in `safeFileWrite`).
+ // Impact: in dev, deleting `routeTree.gen.ts` (e.g. `git clean`, a branch
+ // switch) leaves the app without a route tree until the dev server
+ // restarts.
+ test.fails(
+ 'a deleted route tree is written again on its delete event',
+ async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/index.tsx': fileRoute('/'),
+ })
+ const { config, generator } = await startGenerator(root)
+ const generated = await readFile(config.generatedRouteTree, 'utf8')
+
+ await rm(config.generatedRouteTree)
+ await generator.run({ type: 'delete', path: config.generatedRouteTree })
+
+ expect(existsSync(config.generatedRouteTree)).toBe(true)
+ expect(await readFile(config.generatedRouteTree, 'utf8')).toBe(generated)
+ },
+ )
+
+ test.fails(
+ 'after the route tree is deleted, adding a route writes the route tree',
+ async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/index.tsx': fileRoute('/'),
+ })
+ const { config, generator } = await startGenerator(root)
+
+ await rm(config.generatedRouteTree)
+ await generator.run({ type: 'delete', path: config.generatedRouteTree })
+ const about = await save(
+ root,
+ 'src/routes/about.tsx',
+ fileRoute('/about'),
+ )
+ await generator.run({ type: 'create', path: about })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/': 'routes/index.tsx',
+ '/about': 'routes/about.tsx',
+ })
+ },
+ )
+})
+
+describe('first runs', () => {
+ test('a new generator on an up-to-date app does not rewrite the route tree', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/index.tsx': fileRoute('/'),
+ })
+ const { config } = await startGenerator(root)
+ const past = new Date('2020-01-01T00:00:00Z')
+ await utimes(config.generatedRouteTree, past, past)
+
+ await new Generator({ config, root }).run()
+
+ expect((await stat(config.generatedRouteTree)).mtimeMs).toBe(past.getTime())
+ })
+
+ test('a routes directory without a root route fails with the root route file to add', async () => {
+ const root = await createApp({
+ 'src/routes/index.tsx': fileRoute('/'),
+ })
+ await expect(
+ new Generator({ config: configOf(root), root }).run(),
+ ).rejects.toThrow('__root.tsx')
+ })
+})
+
+describe('route files outside the routes directory', () => {
+ test('a route file created in a physical() directory outside the routes directory is added', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/features/a/index.tsx': fileRoute('/a/'),
+ 'src/other/b/index.tsx': fileRoute('/b/'),
+ })
+ const { config, generator } = await startGenerator(root, {
+ virtualRouteConfig: virtualRootRoute('__root.tsx', [
+ physical('/a', '../features/a'),
+ physical('/b', '../other/b'),
+ ]),
+ })
+
+ const created = await save(
+ root,
+ 'src/features/a/new.tsx',
+ fileRoute('/wrong'),
+ )
+ await generator.run({ type: 'create', path: created })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/a/': 'features/a/index.tsx',
+ '/a/new': 'features/a/new.tsx',
+ '/b/': 'other/b/index.tsx',
+ })
+ expect(await routeIdOf(await read(root, 'src/features/a/new.tsx'))).toBe(
+ '/a/new',
+ )
+ })
+
+ test('a virtual route file outside the routes directory gets its id fixed when edited', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/features/posts.tsx': fileRoute('/posts'),
+ })
+ const { generator } = await startGenerator(root, {
+ virtualRouteConfig: virtualRootRoute('__root.tsx', [
+ virtualRoute('/posts', '../features/posts.tsx'),
+ ]),
+ })
+
+ const posts = await save(
+ root,
+ 'src/features/posts.tsx',
+ fileRoute('/wrong'),
+ )
+ await generator.run({ type: 'update', path: posts })
+
+ expect(await routeIdOf(await read(root, 'src/features/posts.tsx'))).toBe(
+ '/posts',
+ )
+ })
+
+ // Control: the two tests above (same harness, editing route files).
+
+ // Bug: edits to the file `virtualRouteConfig` points to are ignored by a
+ // running generator: its update event is dropped (the absolute event path
+ // is compared with the configured relative path), and the config module
+ // is loaded from jiti's cache anyway.
+ // Source: src/generator.ts:1639-1644 (isFileRelevantForRouteTreeGeneration),
+ // src/filesystem/virtual/getRouteNodes.ts (config file loading).
+ // Impact: in dev, changing the virtual route config has no effect until
+ // the dev server restarts.
+ test.fails(
+ 'editing the virtualRouteConfig file updates the route tree',
+ async () => {
+ const routes = (routePath: string) =>
+ `import { rootRoute, route } from '@tanstack/virtual-file-routes'
+
+export const routes = rootRoute('__root.tsx', [route('${routePath}', 'posts.tsx')])
+`
+ const root = await createApp({
+ 'routes.ts': routes('/posts'),
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/posts.tsx': fileRoute('/posts'),
+ })
+ const { config, generator } = await startGenerator(root, {
+ virtualRouteConfig: './routes.ts',
+ })
+
+ const configFile = await save(root, 'routes.ts', routes('/articles'))
+ await generator.run({ type: 'update', path: configFile })
+
+ expect(await routesOf(config.generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/articles': 'routes/posts.tsx',
+ })
+ },
+ )
+})
+
+describe('generator plugins', () => {
+ test('afterTransform receives the previous route node of a changed route file', async () => {
+ // Like Start's server-only route detection: a route is server-only when
+ // its only option is `server`.
+ const isServerOnly = (node: RouteNode | undefined) =>
+ node?.createFileRouteProps?.size === 1 &&
+ node.createFileRouteProps.has('server')
+ let toggled: Array = []
+ const plugin: GeneratorPlugin = {
+ name: 'server-only-toggles',
+ afterTransform({ node, prevNode }) {
+ if (isServerOnly(node) !== isServerOnly(prevNode)) {
+ toggled.push(node.routePath)
+ }
+ },
+ }
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/api.tsx': fileRoute('/api', '{ server: {} }'),
+ 'src/routes/posts.tsx': fileRoute('/posts', '{ server: {} }'),
+ })
+ const { generator } = await startGenerator(root, { plugins: [plugin] })
+ expect(toggled.sort()).toEqual(['/api', '/posts'])
+
+ toggled = []
+ const posts = await save(
+ root,
+ 'src/routes/posts.tsx',
+ fileRoute('/posts', '{ component: () => null }'),
+ )
+ await generator.run({ type: 'update', path: posts })
+ expect(toggled).toEqual(['/posts'])
+
+ toggled = []
+ await save(
+ root,
+ 'src/routes/posts.tsx',
+ fileRoute('/posts', "{ component: () => 'edited' }"),
+ )
+ await generator.run({ type: 'update', path: posts })
+ expect(toggled).toEqual([])
+ })
+
+ test('getRoutesByFileMap maps each route file, the root route file included, to its route id', async () => {
+ const root = await createApp({
+ 'src/routes/__root.tsx': rootRoute(),
+ 'src/routes/posts.tsx': fileRoute('/posts'),
+ })
+ const { generator } = await startGenerator(root)
+ const routesByFile = () =>
+ Object.fromEntries(
+ [...generator.getRoutesByFileMap()].map(([file, { routeId }]) => [
+ path.relative(root, file).replaceAll(path.sep, '/'),
+ routeId,
+ ]),
+ )
+
+ expect(routesByFile()).toEqual({
+ 'src/routes/__root.tsx': '/__root',
+ 'src/routes/posts.tsx': '/posts',
+ })
+ await generator.run({ type: 'rerun' })
+ expect(routesByFile()).toEqual({
+ 'src/routes/__root.tsx': '/__root',
+ 'src/routes/posts.tsx': '/posts',
+ })
+ })
+})
From 158664379670b066819ffdc39e96db6ee8659fbc Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:35:27 +0200
Subject: [PATCH 0243/1425] test(router-generator): route URLs, underscores,
groups, tokens, param names
Round-3 mutation gaps (medium/low generator tiers), read from the evaluated
route tree through a new `urlsOf` helper (route-tree.ts: the URL each route
matches, joining `path` options like router-core):
- route-paths.test.ts (new): escaped `[_]` segments of flat files; literal
`_` after the first segment of virtual routes and physical() mounts;
escaped underscores in a physical mount under a virtual `/_foo` route;
virtual layouts and index under it; a pathless layout under `[_]api`;
`(group)` layouts at the root and nested add no segment; a childless
pathless layout does not crash; an unformatted tree with grandchildren;
`@B` next to `B`; `g`/`y` token flags behave like unflagged ones; only
invalid brace params warn; FileRoutesByTo picks the navigable route.
Pin (main bug, REPORT section 6 #14), verified red for the stated reason
("Identifier ... has already been declared" when the tree is parsed):
route variable-name collisions (about-us/aboutUs, _layout/layout, $id/id).
Mutants verified killed (rg set): 16, 20, 2469, 2470, 2479, 2484, 2487,
2488, 2499, 72, 1009, 3039, 2660, 3166, 3181, 3182, 3184, 3186, 3188, 3189,
3191, 2896, 2911, 2913, 2916, 2517, 2519.
Co-Authored-By: Claude Opus 5.5
---
.../tests/route-paths.test.ts | 381 ++++++++++++++++++
packages/router-generator/tests/route-tree.ts | 41 +-
2 files changed, 417 insertions(+), 5 deletions(-)
create mode 100644 packages/router-generator/tests/route-paths.test.ts
diff --git a/packages/router-generator/tests/route-paths.test.ts b/packages/router-generator/tests/route-paths.test.ts
new file mode 100644
index 00000000000..baab1c88dea
--- /dev/null
+++ b/packages/router-generator/tests/route-paths.test.ts
@@ -0,0 +1,381 @@
+/**
+ * The URLs routes get from their file names and virtual route configs:
+ * escaped underscores (`[_]`), literal underscores in virtual paths, route
+ * groups, pathless layouts, and token and param validation, read from the
+ * evaluated route tree.
+ */
+import { readFile } from 'node:fs/promises'
+import { describe, expect, test, vi } from 'vitest'
+import {
+ index,
+ layout,
+ physical,
+ rootRoute as virtualRootRoute,
+ route as virtualRoute,
+} from '@tanstack/virtual-file-routes'
+import { parseSync } from 'vite'
+import { createApp, fileRoute, generate, rootRoute } from './app'
+import { routesOf, urlsOf } from './route-tree'
+import type { Config } from '../src'
+
+/** An app with a root route and a route file for each of `files`. */
+async function appWithRoutes(files: Array) {
+ const routeFiles: Record = {
+ 'src/routes/__root.tsx': rootRoute(),
+ }
+ for (const file of files) {
+ routeFiles[`src/routes/${file}`] = fileRoute('/wrong')
+ }
+ return createApp(routeFiles)
+}
+
+describe('underscores', () => {
+ test('an escaped leading underscore after the first segment of a flat route file name stays in the URL', async () => {
+ const root = await appWithRoutes([
+ 'foo.[_]bar.tsx',
+ 'posts.tsx',
+ 'posts.a.[_]b.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root)
+
+ expect(await urlsOf(generatedRouteTree)).toEqual({
+ __root__: '/',
+ '/foo/_bar': '/foo/_bar',
+ '/posts': '/posts',
+ '/posts/a/_b': '/posts/a/_b',
+ })
+ })
+
+ test('a virtual route path with an underscore after its first segment keeps it in the URL', async () => {
+ const root = await appWithRoutes([
+ 'foobar.tsx',
+ 'deep.tsx',
+ 'sub/index.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root, {
+ virtualRouteConfig: virtualRootRoute('__root.tsx', [
+ virtualRoute('/foo/_bar', 'foobar.tsx'),
+ virtualRoute('/x/y/_z', 'deep.tsx'),
+ physical('/api/_v1', 'sub'),
+ ]),
+ })
+
+ expect(await urlsOf(generatedRouteTree)).toEqual({
+ __root__: '/',
+ '/foo/_bar': '/foo/_bar',
+ '/x/y/_z': '/x/y/_z',
+ '/api/_v1/': '/api/_v1/',
+ })
+ })
+
+ test('escaped underscores in a physical directory mounted under a virtual route with a literal underscore stay in the URLs', async () => {
+ const root = await appWithRoutes([
+ 'foo.tsx',
+ 'phys/[_]hello.tsx',
+ 'phys/blog[_].tsx',
+ 'phys/[_]q/w.tsx',
+ 'phys/index.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root, {
+ virtualRouteConfig: virtualRootRoute('__root.tsx', [
+ virtualRoute('/_foo', 'foo.tsx', [physical('/_p', 'phys')]),
+ ]),
+ })
+
+ const urls = await urlsOf(generatedRouteTree)
+ const routes = await routesOf(generatedRouteTree)
+ expect(
+ Object.fromEntries(
+ Object.entries(routes).map(([id, file]) => [file, urls[id]]),
+ ),
+ ).toEqual({
+ 'routes/__root.tsx': '/',
+ 'routes/foo.tsx': '/_foo',
+ 'routes/phys/[_]hello.tsx': '/_foo/_p/_hello',
+ 'routes/phys/blog[_].tsx': '/_foo/_p/blog_',
+ 'routes/phys/[_]q/w.tsx': '/_foo/_p/_q/w',
+ 'routes/phys/index.tsx': '/_foo/_p/',
+ })
+ })
+
+ test('virtual layouts and an index under a virtual route with a literal underscore', async () => {
+ const root = await appWithRoutes([
+ 'foo.tsx',
+ 'lay.tsx',
+ 'inner.tsx',
+ 'fooindex.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root, {
+ virtualRouteConfig: virtualRootRoute('__root.tsx', [
+ virtualRoute('/_foo', 'foo.tsx', [
+ layout('lay', 'lay.tsx', [virtualRoute('/inner', 'inner.tsx')]),
+ index('fooindex.tsx'),
+ ]),
+ ]),
+ })
+
+ const urls = await urlsOf(generatedRouteTree)
+ const routes = await routesOf(generatedRouteTree)
+ expect(
+ Object.fromEntries(
+ Object.entries(routes).map(([id, file]) => [file, urls[id]]),
+ ),
+ ).toEqual({
+ 'routes/__root.tsx': '/',
+ 'routes/foo.tsx': '/_foo',
+ 'routes/lay.tsx': '/_foo',
+ 'routes/inner.tsx': '/_foo/inner',
+ 'routes/fooindex.tsx': '/_foo/',
+ })
+ })
+
+ test('a pathless layout under a route with an escaped leading underscore adds no path segment', async () => {
+ const root = await appWithRoutes([
+ '[_]api.tsx',
+ '[_]api/_auth.tsx',
+ '[_]api/_auth/users.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root)
+
+ expect(await urlsOf(generatedRouteTree)).toEqual({
+ __root__: '/',
+ '/_api': '/_api',
+ '/_api/_auth': '/_api',
+ '/_api/_auth/users': '/_api/users',
+ })
+ })
+})
+
+describe('layouts and groups', () => {
+ test('a route group layout adds no path segment', async () => {
+ const root = await appWithRoutes([
+ '(auth)/route.tsx',
+ '(auth)/login.tsx',
+ 'shop.tsx',
+ 'shop/(account)/route.tsx',
+ 'shop/(account)/orders.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root)
+
+ expect(await urlsOf(generatedRouteTree)).toEqual({
+ __root__: '/',
+ '/(auth)': '/',
+ '/(auth)/login': '/login',
+ '/shop': '/shop',
+ '/shop/(account)': '/shop',
+ '/shop/(account)/orders': '/shop/orders',
+ })
+ })
+
+ test('a pathless layout without child routes yet is added to the route tree', async () => {
+ const root = await appWithRoutes(['about.tsx', '_auth.tsx'])
+ const { generatedRouteTree } = await generate(root)
+
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/about': 'routes/about.tsx',
+ '/_auth': 'routes/_auth.tsx',
+ })
+ })
+
+ test('two sections with grandchildren give a valid route tree without formatting', async () => {
+ const root = await appWithRoutes([
+ 'a.tsx',
+ 'a.b.tsx',
+ 'a.b.c.tsx',
+ 'd.tsx',
+ 'd.e.tsx',
+ 'd.e.f.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root, {
+ enableRouteTreeFormatting: false,
+ })
+
+ expect(await urlsOf(generatedRouteTree)).toEqual({
+ __root__: '/',
+ '/a': '/a',
+ '/a/b': '/a/b',
+ '/a/b/c': '/a/b/c',
+ '/d': '/d',
+ '/d/e': '/d/e',
+ '/d/e/f': '/d/e/f',
+ })
+ })
+})
+
+describe('route names', () => {
+ test('routes whose names differ only by a leading @ get their own routes', async () => {
+ const root = await appWithRoutes(['@B.tsx', 'B.tsx'])
+ const { generatedRouteTree } = await generate(root)
+
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/@B': 'routes/@B.tsx',
+ '/B': 'routes/B.tsx',
+ })
+ })
+
+ // Control: the test above (same harness, two names the route tree tells
+ // apart).
+
+ // Bug: routes whose paths differ only in characters that are not valid in
+ // an identifier (`-`, `_`, `$`, a space) or in letter case get the same
+ // variable in the route tree, which then declares it twice and fails to
+ // parse ("Identifier has already been declared").
+ // Source: src/utils.ts (routePathToVariable / getResolvedRouteNodeVariableName:
+ // no deduplication of the generated names).
+ // Impact: an app with, e.g., `about-us.tsx` and `aboutUs.tsx` fails to
+ // build until one of the files is renamed.
+ test.fails.each([
+ { files: ['about-us.tsx', 'aboutUs.tsx'], ids: ['/about-us', '/aboutUs'] },
+ {
+ files: ['blog.tsx', 'blog/_layout.tsx', 'blog/layout.tsx'],
+ ids: ['/blog', '/blog/_layout', '/blog/layout'],
+ },
+ {
+ files: ['users.tsx', 'users/$id.tsx', 'users/id.tsx'],
+ ids: ['/users', '/users/$id', '/users/id'],
+ },
+ ])('routes $ids get their own routes', async ({ files, ids }) => {
+ const root = await appWithRoutes(files)
+ const { generatedRouteTree } = await generate(root)
+
+ expect(Object.keys(await routesOf(generatedRouteTree)).sort()).toEqual(
+ ['__root__', ...ids].sort(),
+ )
+ })
+})
+
+describe('token options', () => {
+ test.each([
+ {
+ name: 'a JSON regex with the g flag',
+ routeToken: { regex: 'layout', flags: 'g' },
+ },
+ { name: 'a RegExp with the g and y flags', routeToken: /layout/gy },
+ ] satisfies Array & { name: string }>)(
+ 'a routeToken that is $name matches every layout file',
+ async ({ name: _name, ...config }) => {
+ const files = ['a', 'b', 'c', 'd'].flatMap((dir) => [
+ `${dir}/layout.tsx`,
+ `${dir}/x.tsx`,
+ ])
+ const root = await appWithRoutes(files)
+ const { generatedRouteTree } = await generate(root, config)
+
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ ...Object.fromEntries(
+ ['a', 'b', 'c', 'd'].flatMap((dir) => [
+ [`/${dir}`, `routes/${dir}/layout.tsx`],
+ [`/${dir}/x`, `routes/${dir}/x.tsx`],
+ ]),
+ ),
+ })
+ },
+ )
+
+ test('an indexToken that is a JSON regex with the g flag matches every index file', async () => {
+ const root = await appWithRoutes(['a/home.tsx', 'b/home.tsx', 'c/home.tsx'])
+ const { generatedRouteTree } = await generate(root, {
+ indexToken: { regex: 'home', flags: 'g' },
+ })
+
+ expect(await routesOf(generatedRouteTree)).toEqual({
+ __root__: 'routes/__root.tsx',
+ '/a/': 'routes/a/home.tsx',
+ '/b/': 'routes/b/home.tsx',
+ '/c/': 'routes/c/home.tsx',
+ })
+ })
+})
+
+describe('param names', () => {
+ test('only invalid param names in braces get a warning', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const root = await appWithRoutes([
+ '{-$locale}/about.tsx',
+ 'posts/{$postId}.tsx',
+ 'files/prefix{$}.tsx',
+ 'opt/{-$}.tsx',
+ 'users/user-{$user-name}.tsx',
+ ])
+ try {
+ await generate(root, { disableLogging: false })
+ const warnings = warn.mock.calls
+ .map((call) => call.join(' '))
+ .filter((message) => message.includes('param'))
+ expect(warnings).toHaveLength(1)
+ expect(warnings[0]).toContain('user-name')
+ } finally {
+ warn.mockRestore()
+ }
+ })
+})
+
+describe('FileRoutesByTo', () => {
+ /**
+ * The route each `to` of the route tree's `FileRoutesByTo` interface
+ * navigates to, by route id (from `FileRoutesById`).
+ */
+ async function routeIdsByTo(treeFile: string) {
+ const { program } = parseSync(
+ 'routeTree.gen.ts',
+ await readFile(treeFile, 'utf8'),
+ )
+ const members = (name: string) => {
+ const body = program.body
+ .map((statement) =>
+ statement.type === 'ExportNamedDeclaration'
+ ? statement.declaration
+ : statement,
+ )
+ .find(
+ (statement: any) =>
+ statement?.type === 'TSInterfaceDeclaration' &&
+ statement.id.name === name,
+ ) as any
+ return Object.fromEntries(
+ body.body.body.map((member: any) => [
+ member.key.value ?? member.key.name,
+ member.typeAnnotation.typeAnnotation.exprName.name,
+ ]),
+ )
+ }
+ const idsByVariable = Object.fromEntries(
+ Object.entries(members('FileRoutesById')).map(([id, variable]) => [
+ variable,
+ id,
+ ]),
+ )
+ return Object.fromEntries(
+ Object.entries(members('FileRoutesByTo')).map(([to, variable]) => [
+ to,
+ idsByVariable[variable as string],
+ ]),
+ )
+ }
+
+ test('`to` of an index route is the index route, not a route under pathless layouts with the same path', async () => {
+ const root = await appWithRoutes([
+ 'posts/index.tsx',
+ '_a.tsx',
+ '_a/_b.tsx',
+ '_a/_b/posts.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root)
+
+ expect((await routeIdsByTo(generatedRouteTree))['/posts']).toBe('/posts/')
+ })
+
+ test('`to` of a route is the route, not its own pathless layout', async () => {
+ const root = await appWithRoutes([
+ 'foo.tsx',
+ 'foo/_auth.tsx',
+ 'foo/_auth/bar.tsx',
+ ])
+ const { generatedRouteTree } = await generate(root)
+
+ expect((await routeIdsByTo(generatedRouteTree))['/foo']).toBe('/foo')
+ })
+})
diff --git a/packages/router-generator/tests/route-tree.ts b/packages/router-generator/tests/route-tree.ts
index ec174a381d1..677c81df451 100644
--- a/packages/router-generator/tests/route-tree.ts
+++ b/packages/router-generator/tests/route-tree.ts
@@ -86,6 +86,12 @@ interface EvaluatedRoute {
/** The route file, relative to the tree (null for a route without a file). */
file: string | null
options: Record
+ /**
+ * The URL path the route matches: the `path` options of the route and its
+ * parents joined (a route without a `path`, such as a pathless layout,
+ * adds nothing to its parent's).
+ */
+ url: string
}
/**
@@ -154,16 +160,29 @@ async function evaluateRouteTree(treeFile: string) {
)
const routes: Array = [
- { id: '__root__', file: routeTree.file, options: routeTree.options },
+ {
+ id: '__root__',
+ file: routeTree.file,
+ options: routeTree.options,
+ url: '/',
+ },
]
- const visit = (parentId: string, children: Record = {}) => {
+ const visit = (
+ parentId: string,
+ parentUrl: string,
+ children: Record = {},
+ ) => {
for (const child of Object.values(children)) {
const id = joinPaths(parentId, child.options.id)
- routes.push({ id, file: child.file, options: child.options })
- visit(id, child.children)
+ const url =
+ child.options.path === undefined
+ ? parentUrl
+ : joinPaths(parentUrl, child.options.path)
+ routes.push({ id, file: child.file, options: child.options, url })
+ visit(id, url, child.children)
}
}
- visit('', routeTree.children)
+ visit('', '/', routeTree.children)
/** The file a recorded lazy import (`() => import('…')…`) loads. */
const lazyFile = async (importer: string) => {
@@ -230,3 +249,15 @@ export async function routeOptionsOf(
}
return result
}
+
+/**
+ * Evaluates the generated route tree at `treeFile` (see `evaluateRouteTree`)
+ * and returns the URL path each route matches, by route id (the root route
+ * as `__root__`).
+ */
+export async function urlsOf(
+ treeFile: string,
+): Promise> {
+ const { routes } = await evaluateRouteTree(treeFile)
+ return Object.fromEntries(routes.map(({ id, url }) => [id, url]))
+}
From 80ad26e34d13218d81540945afd6e831f1095728 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:39:42 +0200
Subject: [PATCH 0244/1425] test(router-generator): route file upkeep edge
cases in the transform
Round-3 mutation gaps (gen-transform.md items 1-11):
- transform.test.ts: the constructor import is fixed when the route id is
already right (no import, stale other constructor, only the other one,
lazy route called as createFileRoute); a module exporting only
`export { helper }` is not a route; `export let Route` and
`export const Route = postsRoute` are left as they are; an interpolated
template-literal id is an error; type imports stay type-only; a prepended
import keeps the file's line ending and its own line (LF, CRLF, single
line); removing a stale import leaves clean, valid code (trailing
whitespace, two imports on one line); semicolons kept; an unrelated or
multi-line import declaration stays byte for byte; a stale import next to
an aliased constructor import.
- known-bugs.test.ts: the "no Route export" control also covers
`export { helper }` at the generator level.
Mutants verified killed (rg set): 1261, 1271, 1469, 1549, 1551, 1566,
1303, 1304, 1306, 1309, 1351, 1355, 1370, 1371, 1373, 1433, 1435, 1451,
1666, 1668, 1669, 1685-1689, 1695, 1697, 1700, 1702, 1706, 1708, 1709,
1711, 1712, 1714, 1716, 1719, 1720, 1729, 1736, 1440, 1452, 1682, 1531,
1559, 1564, 1541, 1542, 1625, 1627, 1632, 1634, 1639, 1641, 1643, 1476,
1477, 1486, 1487, 1631, 1633, 1388. 1715 hangs (synchronous loop) on the
trailing-whitespace case: detected as a timeout, not a test failure.
Co-Authored-By: Claude Opus 5.5
---
.../router-generator/tests/known-bugs.test.ts | 15 +-
.../router-generator/tests/transform.test.ts | 260 ++++++++++++++++++
2 files changed, 268 insertions(+), 7 deletions(-)
diff --git a/packages/router-generator/tests/known-bugs.test.ts b/packages/router-generator/tests/known-bugs.test.ts
index ff093c59f11..e2b26423e82 100644
--- a/packages/router-generator/tests/known-bugs.test.ts
+++ b/packages/router-generator/tests/known-bugs.test.ts
@@ -140,13 +140,14 @@ export const Route = ${call}`)
describe('type-only Route exports', () => {
// Control for the pin below (same harness): a file without a Route export
// is left out of the route tree, with a warning naming it.
- test('a route file without a Route export is left out with a warning', async () => {
- const { node, warnings } = await generateMovedRoute(
- `export const helper = 1`,
- )
- expect(node).toBeUndefined()
- expect(warnings.join('\n')).toContain('moved.tsx')
- })
+ test.each(['export const helper = 1', 'const helper = 1\nexport { helper }'])(
+ 'a route file without a Route export is left out with a warning: %s',
+ async (code) => {
+ const { node, warnings } = await generateMovedRoute(code)
+ expect(node).toBeUndefined()
+ expect(warnings.join('\n')).toContain('moved.tsx')
+ },
+ )
// Bug: the generator takes a type-only export named `Route` for the route
// export: `export type { MyRoute as Route }` gets its route id rewritten,
diff --git a/packages/router-generator/tests/transform.test.ts b/packages/router-generator/tests/transform.test.ts
index 62cbc6fd71e..22e910b8adb 100644
--- a/packages/router-generator/tests/transform.test.ts
+++ b/packages/router-generator/tests/transform.test.ts
@@ -748,3 +748,263 @@ export const Route = createFileRoute('/old')({})`
})
})
})
+
+const routerPackage = "'@tanstack/react-router'"
+
+/** Transforms `source` for the route `/new` (lazy or not). */
+function transformTo(source: string, lazy = false) {
+ return transform({
+ source,
+ ctx: { target: 'react', routeId: '/new', lazy },
+ node: makeNode(),
+ })
+}
+
+/** The output of a transform that must have modified the file. */
+function modifiedOutput(result: ReturnType) {
+ if (result.result !== 'modified') {
+ throw new Error(`expected modified result, got ${result.result}`)
+ }
+ return result.output
+}
+
+describe('route constructor imports when the route id is already right', () => {
+ it.each([
+ {
+ name: 'no router import',
+ lazy: false,
+ source: `export const Route = createFileRoute('/new')({})`,
+ },
+ {
+ name: 'a stale import of the other constructor',
+ lazy: false,
+ source: `import { createFileRoute } from ${routerPackage}
+import { createLazyFileRoute } from ${routerPackage}
+export const Route = createFileRoute('/new')({})`,
+ },
+ {
+ name: 'a stale import of the other constructor next to another import',
+ lazy: false,
+ source: `import { Link, createFileRoute, createLazyFileRoute } from ${routerPackage}
+export const link = Link
+export const Route = createFileRoute('/new')({})`,
+ },
+ {
+ name: 'only the other constructor imported',
+ lazy: false,
+ source: `import { createLazyFileRoute } from ${routerPackage}
+export const Route = createFileRoute('/new')({})`,
+ },
+ {
+ name: 'the constructor of a lazy route called as createFileRoute',
+ lazy: true,
+ source: `import { createLazyFileRoute } from ${routerPackage}
+export const Route = createFileRoute('/new')({})`,
+ },
+ ])(
+ 'the file gets the right constructor import, with $name',
+ async ({ lazy, source }) => {
+ const output = modifiedOutput(transformTo(source, lazy))
+
+ const evaluated = await evaluateRouteFile(output, ['Link'])
+ expect(evaluated.Route.id).toBe('/new')
+ const [required, other] = lazy
+ ? ['createLazyFileRoute', 'createFileRoute']
+ : ['createFileRoute', 'createLazyFileRoute']
+ const imported = valueImports(output, '@tanstack/react-router')
+ expect(imported.filter((name) => name === required)).toHaveLength(1)
+ expect(imported).not.toContain(other)
+ expect(output).not.toContain(`${other}(`)
+ },
+ )
+})
+
+describe('modules that are not routes', () => {
+ it('a module exporting only a helper through an export list is not a route', () => {
+ expect(transformTo(`const helper = 1\nexport { helper }`).result).toBe(
+ 'no-route-export',
+ )
+ })
+
+ it.each([
+ {
+ name: 'without an initializer',
+ source: 'export let Route\nexport const x = 1',
+ },
+ {
+ name: 'from another binding',
+ source: `import { postsRoute } from './posts'\nexport const Route = postsRoute`,
+ },
+ ])('a Route export $name is left as it is', ({ source }) => {
+ expect(transformTo(source).result).toBe('not-modified')
+ })
+
+ it('an interpolated template literal route id is an error', () => {
+ expect(
+ transformTo(
+ 'const x = 1\nexport const Route = createFileRoute(`/${x}`)({})',
+ ).result,
+ ).toBe('error')
+ })
+})
+
+describe('import declarations the transform rewrites', () => {
+ it.each([
+ {
+ name: 'an inline type specifier in a rewritten declaration',
+ source: `import { type LinkProps, createLazyFileRoute } from ${routerPackage}
+import { createFileRoute } from ${routerPackage}
+export type Props = LinkProps
+export const Route = createFileRoute('/old')({})`,
+ },
+ {
+ name: 'a type-only declaration next to a missing constructor import',
+ source: `import type { LinkProps } from ${routerPackage}
+export type Props = LinkProps
+export const Route = createFileRoute('/old')({})`,
+ },
+ ])('type imports stay type-only: $name', async ({ source }) => {
+ const output = modifiedOutput(transformTo(source))
+
+ expect(valueImports(output, '@tanstack/react-router')).toEqual([
+ 'createFileRoute',
+ ])
+ const { module } = parseSync('route.tsx', output)
+ expect(
+ module.staticImports.flatMap((statement) =>
+ statement.entries
+ .filter((entry) => entry.isType)
+ .map((entry) => entry.localName.value),
+ ),
+ ).toEqual(['LinkProps'])
+ expect((await evaluateRouteFile(output)).Route.id).toBe('/new')
+ })
+
+ it.each([
+ {
+ name: 'LF line endings',
+ newline: '\n',
+ lines: ['// a', "export const Route = createFileRoute('/old')({})", ''],
+ },
+ {
+ name: 'CRLF line endings',
+ newline: '\r\n',
+ lines: ['// a', "export const Route = createFileRoute('/old')({})", ''],
+ },
+ {
+ name: 'a single line',
+ newline: '\n',
+ lines: ["export const Route = createFileRoute('/old')({})"],
+ },
+ ])(
+ 'a missing constructor import is added on its own line, with $name',
+ async ({ newline, lines }) => {
+ const output = modifiedOutput(transformTo(lines.join(newline)))
+
+ const outputLines = output.split(newline)
+ expect(outputLines.filter((line) => !line.startsWith('import '))).toEqual(
+ lines.map((line) => line.replace('/old', '/new')),
+ )
+ // No other line ending is mixed in.
+ expect(
+ newline === '\n' ? output.includes('\r') : /(? {
+ expect(
+ transformTo(
+ `import { createFileRoute } from ${routerPackage}
+import { createLazyFileRoute } from ${routerPackage} \t
+
+export const Route = createFileRoute('/old')({})`,
+ ),
+ ).toEqual({
+ result: 'modified',
+ output: `import { createFileRoute } from ${routerPackage}
+
+export const Route = createFileRoute('/new')({})`,
+ })
+ })
+
+ it('removing a stale import declaration on the line of another one keeps valid code', async () => {
+ const output = modifiedOutput(
+ transformTo(
+ `import { createLazyFileRoute } from ${routerPackage}; import { createFileRoute } from ${routerPackage}
+export const Route = createFileRoute('/old')({})`,
+ ),
+ )
+
+ expect((await evaluateRouteFile(output)).Route.id).toBe('/new')
+ expect(valueImports(output, '@tanstack/react-router')).toEqual([
+ 'createFileRoute',
+ ])
+ })
+
+ it('a rewritten declaration keeps its semicolon, and the other declarations stay as they are', () => {
+ const source = `import { Link, createLazyFileRoute } from ${routerPackage};
+import { createFileRoute } from ${routerPackage};
+export const Route = createFileRoute('/old')({})
+export const link = Link`
+ expect(transformTo(source)).toEqual({
+ result: 'modified',
+ output: source
+ .replace(', createLazyFileRoute', '')
+ .replace('/old', '/new'),
+ })
+ })
+
+ it('an added constructor import ends with a semicolon in a file using them', async () => {
+ const source = `import * as Router from ${routerPackage};
+export const r = Router;
+export const Route = createFileRoute('/old')({});`
+ const output = modifiedOutput(transformTo(source))
+
+ const [added, ...rest] = output.split('\n')
+ expect(added).toMatch(/^import .*createFileRoute.*;$/)
+ expect(rest.join('\n')).toBe(source.replace('/old', '/new'))
+ expect((await evaluateRouteFile(output)).Route.id).toBe('/new')
+ })
+
+ it('renaming the constructor keeps the multi-line formatting of its import declaration', () => {
+ const source = `import Router, {
+ Link as L,
+ createFileRoute,
+} from ${routerPackage}
+export const uses = [Router, L]
+export const Route = createFileRoute('/new')({})`
+ expect(transformTo(source, true)).toEqual({
+ result: 'modified',
+ output: source.replaceAll('createFileRoute', 'createLazyFileRoute'),
+ })
+ })
+
+ it('an import declaration the transform does not need to change stays byte for byte', () => {
+ const source = `import {Link} from ${routerPackage}
+import { createFileRoute, createLazyFileRoute } from ${routerPackage}
+export const link = Link
+export const Route = createFileRoute('/old')({})`
+ expect(transformTo(source)).toEqual({
+ result: 'modified',
+ output: source
+ .replace(', createLazyFileRoute', '')
+ .replace('/old', '/new'),
+ })
+ })
+
+ it('a stale import is removed next to an aliased import of the constructor', async () => {
+ const output = modifiedOutput(
+ transformTo(`import { Link, createLazyFileRoute } from ${routerPackage}
+import { createFileRoute as cfr } from ${routerPackage}
+export const uses = [Link, cfr]
+export const Route = createFileRoute('/old')({})`),
+ )
+
+ expect((await evaluateRouteFile(output, ['Link'])).Route.id).toBe('/new')
+ expect(valueImports(output, '@tanstack/react-router')).not.toContain(
+ 'createLazyFileRoute',
+ )
+ })
+})
From 0a3df66587af4f1b75cdde463306920d40e55dbb Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:42:43 +0200
Subject: [PATCH 0245/1425] test(router-generator): pin the 'a b' / 'ab' route
variable collision too
Verified red for the same reason as the other pairs ("Identifier
`AbRouteImport` has already been declared" when the tree is parsed).
Co-Authored-By: Claude Opus 5.5
---
packages/router-generator/tests/route-paths.test.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/router-generator/tests/route-paths.test.ts b/packages/router-generator/tests/route-paths.test.ts
index baab1c88dea..46fb28958e5 100644
--- a/packages/router-generator/tests/route-paths.test.ts
+++ b/packages/router-generator/tests/route-paths.test.ts
@@ -236,6 +236,7 @@ describe('route names', () => {
files: ['users.tsx', 'users/$id.tsx', 'users/id.tsx'],
ids: ['/users', '/users/$id', '/users/id'],
},
+ { files: ['a b.tsx', 'ab.tsx'], ids: ['/a b', '/ab'] },
])('routes $ids get their own routes', async ({ files, ids }) => {
const root = await appWithRoutes(files)
const { generatedRouteTree } = await generate(root)
From c86011131d4df155c71a9a6dc16d99359e7f98fe Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:42:57 +0200
Subject: [PATCH 0246/1425] test(router-generator): cite the jiti config loader
in the virtualRouteConfig pin
Co-Authored-By: Claude Opus 5.5
---
packages/router-generator/tests/generator-dev.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/router-generator/tests/generator-dev.test.ts b/packages/router-generator/tests/generator-dev.test.ts
index 76199cc10a5..df3ad701de8 100644
--- a/packages/router-generator/tests/generator-dev.test.ts
+++ b/packages/router-generator/tests/generator-dev.test.ts
@@ -342,7 +342,7 @@ describe('route files outside the routes directory', () => {
// is compared with the configured relative path), and the config module
// is loaded from jiti's cache anyway.
// Source: src/generator.ts:1639-1644 (isFileRelevantForRouteTreeGeneration),
- // src/filesystem/virtual/getRouteNodes.ts (config file loading).
+ // src/filesystem/virtual/loadConfigFile.ts (jiti import of the config).
// Impact: in dev, changing the virtual route config has no effect until
// the dev server restarts.
test.fails(
From 08611ce925eae953028ccc79469b5155c3c8f5f2 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:35:57 +0200
Subject: [PATCH 0247/1425] test(start-plugin-core): cover Hydrate expression
children, chunk imports, spread preloads and React chunks from source
- Item 1: a single {expression} child (captured prop, forwarded children)
renders through the chunk.
- Item 3: neither the client parent nor the chunk imports a module only a
server fn handler uses.
- Item 5: boundaries spreading the props param, a props member or a
reassigned object get the preload of their own chunk.
- Item 7: the chunksFromSource test runs for React and Solid.
Kills: spc:6648, spc:6649, spc:6686, spc:6239, spc:6533, spc:6534, spc:6747, spc:6749
Co-Authored-By: Claude Opus 5.5
---
.../tests/hydrate-split-modules.test.ts | 88 ++++++++++++++++---
1 file changed, 77 insertions(+), 11 deletions(-)
diff --git a/packages/start-plugin-core/tests/hydrate-split-modules.test.ts b/packages/start-plugin-core/tests/hydrate-split-modules.test.ts
index c114a496776..7f366f38315 100644
--- a/packages/start-plugin-core/tests/hydrate-split-modules.test.ts
+++ b/packages/start-plugin-core/tests/hydrate-split-modules.test.ts
@@ -13,6 +13,7 @@ import {
getChunkComponent,
getChunkIds,
hydrateParentStubs,
+ importSources,
loadChunk,
loadClientModule,
renderBoundaries,
@@ -256,6 +257,24 @@ export function Page() {
attributes: '{...{ ...props.rest }}',
props: { rest: { prefetch: 'idle' } },
},
+ {
+ name: 'a spread of the props param',
+ setup: '',
+ attributes: '{...props}',
+ props: { prefetch: 'idle' },
+ },
+ {
+ name: 'a spread of a member of the props',
+ setup: '',
+ attributes: '{...props.options}',
+ props: { options: { prefetch: 'idle' } },
+ },
+ {
+ name: 'a spread of a reassigned object',
+ setup: 'let opts = { when: idle() }\n opts = { prefetch: idle() }\n ',
+ attributes: '{...opts}',
+ props: {},
+ },
])(
'client: a boundary with $name preloads its own chunk',
async ({ setup, attributes, props }) => {
@@ -551,6 +570,49 @@ describe('the split chunk', () => {
},
)
+ // A wrapper component forwarding its children through a boundary keeps
+ // them after hydration.
+ test.each([
+ {
+ name: 'a captured prop',
+ code: `export function Page({ label }) {
+ return {label}
+}`,
+ props: { label: 'l' },
+ },
+ {
+ name: 'the children of a wrapper',
+ code: `export function Page(props) {
+ return {props.children}
+}`,
+ props: { children: 'l' },
+ },
+ ])(
+ 'client: a single expression child ($name) renders through the chunk',
+ async ({ code, props }) => {
+ const { module, lazyChunks } = await loadClientModule(`${head}${code}`)
+ expect(module.Page(props)).toBe('[l]')
+ expectRenderedThroughChunks(lazyChunks)
+ },
+ )
+
+ // Server code never reaches the client, the chunk included.
+ test('client: the chunk does not import what only a server fn handler uses', async () => {
+ const { parent, chunks } = await compileHydrate(
+ 'client',
+ `import { Hydrate, createServerFn } from '@tanstack/react-start'
+import { db } from './db'
+export const getUser = createServerFn().handler(async () => db.read())
+export function Page() {
+ return child
+}`,
+ )
+ expect(chunks).toHaveLength(1)
+ for (const output of [parent, chunks[0]!]) {
+ expect(importSources(output)).not.toContain('./db')
+ }
+ })
+
// Source: #8504 hydrate-split-modules.test.ts (click handler and IIFE)
test('client: children may contain return statements', async () => {
// Render the click handler's result next to the element.
@@ -733,19 +795,23 @@ export function Page() {
// The bundler may load a chunk with the source module's code before the
// plugin compiled that module (e.g. in a fresh build worker); the chunk,
- // and the nested boundary it splits again, use the Solid packages only (a
- // Solid module graph has no React packages).
- test('client: Solid chunks loaded from the source code alone render with the Solid packages', async () => {
- const { module, lazyChunks } = await loadClientModule(
- `import { Hydrate } from '@tanstack/solid-start'
+ // and the nested boundary it splits again, use the packages of the
+ // module's framework only (a Solid module graph has no React packages, and
+ // the other way around).
+ test.each(['react', 'solid'] as const)(
+ 'client: %s chunks loaded from the source code alone render with their own packages',
+ async (framework) => {
+ const { module, lazyChunks } = await loadClientModule(
+ `import { Hydrate } from '@tanstack/${framework}-start'
export function Page() {
- return
+ return
}`,
- { framework: 'solid', chunksFromSource: true },
- )
- expect(module.Page()).toBe('[]')
- expect(lazyChunks.map((chunk) => chunk.rendered)).toEqual([true, true])
- })
+ { framework, chunksFromSource: true },
+ )
+ expect(module.Page()).toBe(`[]`)
+ expect(lazyChunks.map((chunk) => chunk.rendered)).toEqual([true, true])
+ },
+ )
// Source: Qwik optimizer example_capture_imports,
// jsx_member_tag_object_is_captured, example_jsx
From ba4282f84b070c954f956a1f751ed540eabadb7b Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:35:57 +0200
Subject: [PATCH 0248/1425] test(start-plugin-core): cover Hydrate captures
through property values, member names and globals
- Item 2: the children read locals through a non-shorthand property value
and a computed member.
- Item 9: member names and object keys are no reads of a local declared
after the boundary.
- Item 13: a boundary spreading a global splits children rendering a
global component.
Kills: spc:6351, spc:6357, spc:6341, spc:6342, spc:6343, spc:6344, spc:6347, spc:6353, spc:6354, spc:6358, spc:6382, spc:6240
Co-Authored-By: Claude Opus 5.5
---
.../tests/hydrate-captures.test.ts | 47 +++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/packages/start-plugin-core/tests/hydrate-captures.test.ts b/packages/start-plugin-core/tests/hydrate-captures.test.ts
index 4c17d3f3ef5..2676e9673ad 100644
--- a/packages/start-plugin-core/tests/hydrate-captures.test.ts
+++ b/packages/start-plugin-core/tests/hydrate-captures.test.ts
@@ -400,6 +400,53 @@ function Widget(props: { data: { key: string; value: string } }) {
).toBe('[Ada
]')
})
+// Like `style={{ color: color }}`.
+test('client: the children read locals through a property value and a computed member alone', async () => {
+ expect(
+ await renderPage(`export function Page() {
+ const label = 'Ada'
+ const key = 'name'
+ const items = { name: 'Lovelace' }
+ return {({ text: label }).text}{items[key]}
+}`),
+ ).toBe('[AdaLovelace
]')
+})
+
+// Optional members, method keys and other property names are pinned in
+// known-bugs-hydrate.test.ts.
+test('client: member names and object keys are no reads of a local declared later', async () => {
+ expect(
+ await renderPage(
+ `export function Page({ obj }) {
+ const el = {obj.later}{({ later: '!' }).later}
+ const later = 1
+ console.log(later)
+ return el
+}`,
+ { obj: { later: 'L' } },
+ ),
+ ).toBe('[L!
]')
+})
+
+describe('globals', () => {
+ afterEach(() => {
+ delete (globalThis as { GlobalWidget?: unknown }).GlobalWidget
+ delete (globalThis as { hydrateDefaults?: unknown }).hydrateDefaults
+ })
+
+ test('client: a boundary spreading a global splits children rendering a global component', async () => {
+ Object.assign(globalThis, {
+ GlobalWidget: () => ' ',
+ hydrateDefaults: { when: 'idle' },
+ })
+ expect(
+ await renderPage(`export function Page() {
+ return
+}`),
+ ).toBe('[ ]')
+ })
+})
+
// Source: hydrateWhen.test.ts "retains captured local components and values
// across extraction"
test('client: the children render a local component and read a local value', async () => {
From c4820915c1fd1388cd8ade78c0cc9c21c6be74b9 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:35:57 +0200
Subject: [PATCH 0249/1425] test(start-plugin-core): cover Hydrate fallback
stripping, boundary-prop hooks and chunk invalidation
- Item 4: the server keeps the bindings the children read when stripping
the fallback (sibling declarator, module-level destructuring).
- Item 6: the server module does not import a client-only module the
fallback reaches through components, classes, spreads or a namespace.
- Item 8: a hook call in the boundary's own props runs in the parent
render and the children still split.
- Items 10, 11: forms rows for quoted spread keys and split={false} with a
single-use spread.
- Item 12: after the source module is invalidated, a chunk loaded the way
the Vite host loads it renders the edited children.
Kills: spc:6191, spc:6213, spc:6215, spc:6216, spc:6217, spc:6220, spc:6233, spc:6198, spc:6199, spc:6201, spc:6203, spc:6205, spc:6206, spc:6207, spc:6209, spc:6140, spc:6141, spc:6277, spc:6190, spc:6299, spc:6300, spc:6302, spc:6304, spc:6305, spc:6135, spc:6136, spc:6480, spc:6481, spc:6711, spc:6760, spc:6761
Co-Authored-By: Claude Opus 5.5
---
.../tests/hydrate-boundary-forms.test.ts | 156 +++++++++++++++++-
1 file changed, 155 insertions(+), 1 deletion(-)
diff --git a/packages/start-plugin-core/tests/hydrate-boundary-forms.test.ts b/packages/start-plugin-core/tests/hydrate-boundary-forms.test.ts
index 735521c4cb7..f35f518d3ad 100644
--- a/packages/start-plugin-core/tests/hydrate-boundary-forms.test.ts
+++ b/packages/start-plugin-core/tests/hydrate-boundary-forms.test.ts
@@ -7,7 +7,10 @@
* `@tanstack/*-start` package is a boundary.
*/
import { afterEach, describe, expect, test } from 'vitest'
-import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform'
+import {
+ MissingHydrateSourceError,
+ createHydrateCompilerPlugin,
+} from '../src/hydrate-when-transform'
import {
compileErrorMessage,
compileHydrate,
@@ -16,6 +19,7 @@ import {
expectRenderedThroughChunks,
getChunkIds,
hydrateStubs,
+ importSources,
loadClientModule,
renderBoundaries,
renderChunk,
@@ -117,6 +121,19 @@ export function Page() {
name: 'a computed fallback key',
code: `export function Page() {
return fallback-marker }}>child
+}`,
+ },
+ {
+ name: 'an inline spread with quoted keys',
+ code: `export function Page() {
+ return fallback-marker }}>child
+}`,
+ },
+ {
+ name: 'a spread used once with split={false}',
+ code: `const props = { when: 'visible', fallback: fallback-marker
}
+export function Page() {
+ return child
}`,
},
] as Array<{
@@ -181,6 +198,88 @@ export function Page() {
},
)
+ // A client-only module the fallback renders (e.g. a spinner reading browser
+ // APIs) is not loaded by the server module, however the fallback reaches
+ // it; module bindings the children read stay.
+ test.each([
+ {
+ name: 'an imported component',
+ code: `import { Spinner } from './client-spinner'
+export function Page() { return }>child
}`,
+ },
+ {
+ name: 'a const component rendering an import',
+ code: `import { Spinner } from './client-spinner'
+const Fallback = () =>
+export function Page() { return }>child
}`,
+ },
+ {
+ name: 'a function component rendering an import',
+ code: `import { Spinner } from './client-spinner'
+function Fallback() { return }
+export function Page() { return }>child
}`,
+ },
+ {
+ name: 'a class rendering an import',
+ code: `import { Spinner } from './client-spinner'
+class Fallback { render() { return } }
+export function Page() { return child
}`,
+ },
+ {
+ name: 'a binding in an inline spread',
+ code: `import { Spinner } from './client-spinner'
+const spinner =
+export function Page() { return child
}`,
+ },
+ {
+ name: 'an element in an inline spread',
+ code: `import { Spinner } from './client-spinner'
+export function Page() { return }}>child
}`,
+ },
+ {
+ name: 'a destructured namespace import',
+ code: `import * as lib from './client-spinner'
+const { Spinner } = lib
+export function Page() { return }>child
}`,
+ },
+ {
+ name: 'a declarator next to one the children read',
+ code: `import { Spinner } from './client-spinner'
+const label = 'child', spinner =
+export function Page() { return {label}
}`,
+ },
+ {
+ name: 'a spread used once with split={false}',
+ code: `import { Spinner } from './client-spinner'
+const opts = { fallback: }
+export function Page() { return child
}`,
+ },
+ ])(
+ 'server: a fallback through $name does not load its client-only module',
+ async ({ code }) => {
+ const { parent } = await compileHydrate('server', `${head}${code}`)
+ expect(await getModuleErrors(parent)).toEqual([])
+ expect(importSources(parent)).not.toContain('./client-spinner')
+ const boundaries: Array> = []
+ const module = await evaluateModule(parent, recordingStubs(boundaries))
+ expect(module.Page()).toBe('[child
]')
+ expect(boundaries.map((props) => props.fallback)).toEqual([undefined])
+ },
+ )
+
+ test('server: a module-level destructuring read by the fallback and the children keeps the children binding', async () => {
+ const { parent } = await compileHydrate(
+ 'server',
+ `${head}const { a, b } = { a: 'A', b: 'B' }
+export function Page() {
+ return {a}{b}}>{b}
+}`,
+ )
+ expect(await getModuleErrors(parent)).toEqual([])
+ const module = await evaluateModule(parent, hydrateStubs())
+ expect(module.Page()).toBe('[B
]')
+ })
+
// Whatever expression `when` is, both environments pass its value to the
// boundary (the server strips the fallback next to it).
test.each([
@@ -508,6 +607,23 @@ export function Page() {
},
)
+ // The props of the boundary itself are not its children: they render in
+ // the parent on both sides, so a hook call there does not stop the split.
+ test('a hook call in the props of the boundary runs in the parent render, and the children still split', async () => {
+ const code = `${head}import { useThing } from './hooks'
+export function Page() {
+ return child
+}`
+ const { server, client } = await renderHooks(code)
+ expect(server.calls).toEqual(['useThing@0'])
+ expect(client).toEqual(server)
+ const { module, lazyChunks } = await loadClientModule(code, {
+ stubs: { './hooks': { useThing: () => 'idle' } },
+ })
+ expect(module.Page()).toBe('[child
]')
+ expectRenderedThroughChunks(lazyChunks)
+ })
+
// Bug: hook calls are only detected when the callee is a bare identifier,
// so `React.useId()` and `useId?.()` in the children move into the chunk
// component.
@@ -871,3 +987,41 @@ test('client: a chunk loaded with the edited source before the parent is compile
expect(await renderChunk(load())).toBe('Old
')
expect(await renderChunk(load(source('New')))).toBe('New
')
})
+
+// Vite loads a chunk without the source code: when the plugin has no source
+// for it (`MissingHydrateSourceError`), the host loads the source module
+// again, then the chunk. Once the edited file is invalidated, a chunk loaded
+// before the parent is compiled again renders the new children.
+test('client: a chunk loaded after its source module was invalidated renders the edited children', async () => {
+ const plugin = createHydrateCompilerPlugin()
+ const { compiler, compile } = createStartCompiler({
+ env: 'client',
+ compilerPlugins: [plugin],
+ })
+ const source = (text: string) => `${head}export function Page() {
+ return ${text}
+}`
+ const [id] = getChunkIds((await compile(source('Old')))!)
+ const load = () =>
+ plugin.loadVirtualModule!({
+ id: id!,
+ root: '/test',
+ env: 'client',
+ envName: 'client',
+ })!.code
+ expect(await renderChunk(load())).toBe('Old
')
+ compiler.invalidateModule('/test/src/module.tsx')
+ /** Loads the chunk like the Vite host does. */
+ const loadLikeVite = async () => {
+ try {
+ return load()
+ } catch (error) {
+ if (!(error instanceof MissingHydrateSourceError)) {
+ throw error
+ }
+ }
+ await compile(source('New'))
+ return load()
+ }
+ expect(await renderChunk(await loadLikeVite())).toBe('New
')
+})
From 7e3060745f1f657dfadd3c7953bf24e0bc39ade9 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:35:57 +0200
Subject: [PATCH 0250/1425] test(start-plugin-core): pin Hydrate same-line
whitespace mismatch and split the member-tag pin
- P1 (REPORT 6 #20): spaces around a single child on one line render on
the server but are dropped by the client chunk (hydration mismatch).
Pin: both environments render the JSX text.
- P2: the member-expression tag pin is split into and
rows.
Pins flipped by: spc:6410, spc:6414, spc:6418, spc:6419 (P1); spc:6372
(capitalized row only)
Co-Authored-By: Claude Opus 5.5
---
.../tests/known-bugs-hydrate.test.ts | 61 ++++++++++++++-----
1 file changed, 47 insertions(+), 14 deletions(-)
diff --git a/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts
index 407cd98c3e6..1cc3e3737c0 100644
--- a/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts
+++ b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts
@@ -328,6 +328,29 @@ export function Page() {
},
)
+ // Bug: whitespace-only text around a single child is dropped when the
+ // children move into the chunk, although JSX keeps whitespace on the line
+ // of the child as text (the server renders it).
+ // Impact: the client renders other text than the server (a hydration
+ // mismatch).
+ // Source: hydrate-when-transform.ts getMeaningfulChildren (round3
+ // mutation analysis, hydrate P1)
+ test.fails(
+ 'client: spaces around a single child on one line render like on the server',
+ async () => {
+ const jsx = await evaluateModule(`export const text = <> x
>`)
+ const code = `${head}export function Page() {
+ return x
+}`
+ const render = (m: Record) => m.Page()
+ const server = await renderBoundaries('server', code, render)
+ expect(server.html).toBe(`[${jsx.text}]`)
+ expect((await renderBoundaries('client', code, render)).html).toBe(
+ server.html,
+ )
+ },
+ )
+
/**
* Renders `Page` of `code` compiled for `env` (on the client once its
* chunks have loaded) and records each `` it renders: its id and
@@ -509,24 +532,34 @@ describe('values captured by the children', () => {
},
)
- // Bug: the object of a member-expression tag (` `) is not taken
- // for a read of a local.
+ // Bug: the object of a member-expression tag (` `,
+ // ` `) is not taken for a read of a local.
// Impact: the boundary throws "ui is not defined" when it hydrates.
// Source: Qwik optimizer jsx_member_tag_object_is_captured,
// destructured_prop_used_as_member_tag (#8504 hydrate-captures.test.ts, red
- // on main)
- test.fails(
- 'client: the split children render member-expression tags of local objects',
- async () => {
- const { module, lazyChunks } =
- await loadClientModule(`${head}function Home() { return home }
-export function Page({ Model }) {
+ // on main); hydrate-when-transform.ts captureVisitors.JSXIdentifier
+ test.fails.each([
+ {
+ name: 'a lowercase local object',
+ code: `function Home() { return home }
+export function Page() {
const ui = { Home }
- return
-}`)
- expect(module.Page({ Model: { Item: () => 'item ' } })).toBe(
- '[home item ]',
- )
+ return
+}`,
+ html: '[home ]',
+ },
+ {
+ name: 'a capitalized destructured prop',
+ code: `export function Page({ Model }) {
+ return
+}`,
+ html: '[item ]',
+ },
+ ])(
+ 'client: the split children render member-expression tags of $name',
+ async ({ code, html }) => {
+ const { module, lazyChunks } = await loadClientModule(`${head}${code}`)
+ expect(module.Page({ Model: { Item: () => 'item ' } })).toBe(html)
expectRenderedThroughChunks(lazyChunks)
},
)
From 44d69d9bc378ae0d62a85e67972cb6e0e4c7026f Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:34:20 +0200
Subject: [PATCH 0251/1425] test(start-plugin-core): cover import discovery
edge cases
ip-analysis P1-P4: import discovery survives array holes; a string
argument of an ordinary call and a computed `import(name)` are not import
sources; `export {} from '...'` still imports its source.
Kills: spc:445, spc:447, spc:448, spc:449, spc:409, spc:411, spc:120, spc:124
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/utils.test.ts | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/packages/start-plugin-core/tests/importProtection/utils.test.ts b/packages/start-plugin-core/tests/importProtection/utils.test.ts
index dfef591aca9..2963d188927 100644
--- a/packages/start-plugin-core/tests/importProtection/utils.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/utils.test.ts
@@ -338,6 +338,39 @@ describe('getImportSources', () => {
const code = `import data from './data.json' with { type: 'json' }`
expect(getImportSources(code)).toEqual(['./data.json'])
})
+
+ // Every module the dev server or the Rsbuild loader checks goes through
+ // import discovery, so a module with array holes must not break it.
+ test('finds imports in a module with array holes', () => {
+ const code = [
+ `import { a } from 'x'`,
+ `const [, b] = a`,
+ `export const c = [1, , b]`,
+ ].join('\n')
+ expect(getImportSources(code)).toEqual(['x'])
+ })
+
+ test('a string argument of an ordinary call is not an import source', () => {
+ const code = [
+ `import { t } from 'i18n'`,
+ `export const label = t('@tanstack/react-start/server')`,
+ ].join('\n')
+ expect(getImportSources(code)).toEqual(['i18n'])
+ })
+
+ test('a dynamic import with a computed specifier is not an import source', () => {
+ const code = [
+ `import { a } from 'static'`,
+ `export const load = (name: string) => import(name)`,
+ ].join('\n')
+ expect(getImportSources(code)).toEqual(['static'])
+ })
+
+ test('an empty re-export still imports its source', () => {
+ expect(getImportSources(`export {} from './setup.server'`)).toEqual([
+ './setup.server',
+ ])
+ })
})
describe('escapeRegExp', () => {
From a25ad67238f3247834cef2a935d1ed7e6cf9b1f4 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:34:34 +0200
Subject: [PATCH 0252/1425] test(start-plugin-core): cover mock export names
and split the ns pin
ip-analysis P5-P7: a denied file exporting an enum still mocks its other
exports; mock modules expose non-identifier names ('~lead', 'a~b', 'a:b',
'|x', 'a^b'); an array-rest export of a denied file is mocked.
ip-analysis pin P3: split the combined "namespace re-export, enum and
namespace" pin so `export * as ns from` in a denied file is pinned on its
own (bug: the self-contained/dev mock of the file has no `ns`;
analysis.ts:270-275 only collects ExportSpecifier names). The enum and
namespace part stays a separate pin.
Kills: spc:377, spc:191, spc:193, spc:199, spc:229, spc:231, spc:237,
spc:245, spc:247, spc:158, spc:163, spc:164, spc:165, spc:385 (pin flips)
Co-Authored-By: Claude Opus 5.5
---
.../importProtection/analysis.exports.test.ts | 43 ++++++++++++++++---
1 file changed, 36 insertions(+), 7 deletions(-)
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 33dbe4cb205..71b72e87bac 100644
--- a/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts
@@ -21,6 +21,11 @@ describe('mock modules expose every importable name', () => {
'foo-bar',
'foo.bar',
'foo bar',
+ '~lead',
+ 'a~b',
+ 'a:b',
+ '|x',
+ 'a^b',
'default',
]
@@ -234,6 +239,10 @@ export type { Foo } from 'mod-a'`,
},
{ name: 'object destructuring', code: `export const { a, b } = obj` },
{ name: 'array destructuring', code: `export const [x, y] = arr` },
+ {
+ name: 'an array destructuring rest element',
+ code: `export const [first, ...others] = arr`,
+ },
{
name: 'renamed destructuring',
code: `export const { a: renamed } = obj`,
@@ -269,20 +278,40 @@ export { local as "foo-bar" }`,
expect(exported.filter((name) => !isMock(mock[name]))).toEqual([])
})
+ // The exports the mock misses (pinned below) must not cost the others.
+ test('an enum next to other exports', async () => {
+ const mock = await selfMock(`export enum Color { Red }
+export const secret = 1`)
+ expect(isMock(mock.secret)).toBe(true)
+ })
+
// Bug: the export names of a denied file skip namespace re-exports
- // (`export * as ns from`), enums and namespaces.
+ // (`export * as ns from`).
+ // Source: src/import-protection/analysis.ts:270-275 (only the names of
+ // `ExportSpecifier`s are collected).
+ // Impact: the mock served in place of the file has no `ns`, so an importer
+ // of `ns` fails to link (build: missing export error; dev: the page fails
+ // to load).
+ test.fails('a namespace re-export', async () => {
+ const code = `export * as ns from './foo'`
+ const exported = Object.keys(await evaluateOriginal(code))
+ const mock = await selfMock(code)
+ expect(exported).toEqual(['ns'])
+ expect(isMock(mock.ns)).toBe(true)
+ })
+
+ // Bug: the export names of a denied file skip enums and namespaces.
// Impact: the mock served in place of the file misses these exports, so
- // an importer of `ns`, `Color` or `Util` fails to link (build: missing
- // export error; dev: the page fails to load).
+ // an importer of `Color` or `Util` fails to link (build: missing export
+ // error; dev: the page fails to load).
// Source: PR #8504 (analysis.exports.test.ts "collects namespace
// re-exports, enums and namespaces").
- test.fails('a namespace re-export, an enum and a namespace', async () => {
- const code = `export * as ns from './foo'
-export enum Color { Red }
+ test.fails('an enum and a namespace', async () => {
+ const code = `export enum Color { Red }
export namespace Util { export const x = 1 }`
const exported = Object.keys(await evaluateOriginal(code))
const mock = await selfMock(code)
- expect(exported.sort()).toEqual(['Color', 'Util', 'ns'])
+ expect(exported.sort()).toEqual(['Color', 'Util'])
expect(Object.keys(mock)).toEqual(expect.arrayContaining(exported))
expect(exported.filter((name) => !isMock(mock[name]))).toEqual([])
})
From 37dd816965996bfe07d69edf578b8c52d637a8de Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:34:34 +0200
Subject: [PATCH 0253/1425] test(start-plugin-core): pin `export * as ns from`
a denied module
ip-location P7 (REPORT 6 #21): `rewriteDeniedImports` drops
`export * as ns from ''` without exporting a mock as `ns`
(rewrite.ts:140-195 only rewrites ExportSpecifiers), so an importer of
`ns` gets a missing export / undefined in a mock-mode build. Pinned
`.fails` (fix-agnostic: `ns` defined, denied module not loaded), plus a
passing companion: the module evaluates without loading the denied module.
Kills: spc:1262 (companion)
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/rewrite.test.ts | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/packages/start-plugin-core/tests/importProtection/rewrite.test.ts b/packages/start-plugin-core/tests/importProtection/rewrite.test.ts
index e61ef99a4a1..3c57e5c91b8 100644
--- a/packages/start-plugin-core/tests/importProtection/rewrite.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/rewrite.test.ts
@@ -111,6 +111,37 @@ export const value: Foo = 1 as Foo`,
expect(graph.evaluated).not.toContain('/test/secret.server.ts')
})
+ // Loosened: the namespace itself is pinned below.
+ test('a module with `export * as ns from` a denied module evaluates without it', async () => {
+ const { module, loaded } = await evaluateAfterRewrite(
+ `export * as ns from './secret.server'
+export const x = 1`,
+ ['./secret.server'],
+ secretServer,
+ )
+ expect(module.x).toBe(1)
+ expect(loaded).toEqual([])
+ })
+
+ // Bug: the rewrite removes `export * as ns from ''` without
+ // exporting a mock as `ns`.
+ // Source: src/import-protection/rewrite.ts:140-195 (only `ExportSpecifier`s
+ // of a denied re-export are rewritten; the namespace specifier is dropped).
+ // Impact: in a mock-mode build, an importer of `ns` from that module gets
+ // a missing export error or `undefined` instead of the mock.
+ test.fails(
+ 'a module with `export * as ns from` a denied module exports a mock as `ns`',
+ async () => {
+ const { module, loaded } = await evaluateAfterRewrite(
+ `export * as ns from './secret.server'`,
+ ['./secret.server'],
+ secretServer,
+ )
+ expect(loaded).toEqual([])
+ expect(module.ns).toBeDefined()
+ },
+ )
+
test('a module without denied imports evaluates unchanged', async () => {
expect(
await evaluateAfterRewrite(
From 912bd7fedb229c323ebbc99db38e6d7b0a6e4bfd Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:34:34 +0200
Subject: [PATCH 0254/1425] test(start-plugin-core): cover mock runtime uses
and Rsbuild mockAccess off
ip-misc P6-P9: `new` on a member of the silent and build mocks returns a
mock; the dev mock reports a string conversion, toString(), `new` and a
property write; the dev mock edge of a denied import reports through the
violation's runtime; the Rsbuild mock runtime (payload encoded as
ensureRuntimeMockModule does) is silent for mockAccess 'off' and reports
for 'error'.
Kills: spc:2078, spc:2071, spc:2073, spc:2077, spc:2079, spc:2141,
spc:2144, spc:2145, spc:2148, spc:2161, spc:2162, spc:2163
Co-Authored-By: Claude Opus 5.5
---
.../importProtection/virtualModules.test.ts | 157 ++++++++++++++----
1 file changed, 129 insertions(+), 28 deletions(-)
diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
index c2f70f0733b..48b2eb89d02 100644
--- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
@@ -10,6 +10,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
import {
generateDevSelfDenialModule,
generateSelfContainedMockModule,
+ loadMockRuntimeModule,
loadSilentMockModule,
mockRuntimeModuleIdFromViolation,
} from '../../src/import-protection/virtualModules'
@@ -17,6 +18,7 @@ import {
loadResolvedVirtualModule,
resolveInternalVirtualModuleId,
} from '../../src/vite/import-protection-plugin/virtualModules'
+import { evaluateWithMock, isMock } from './mock-helpers'
import type { ViolationInfo } from '../../src/import-protection/trace'
type MockValue = {
@@ -79,6 +81,13 @@ describe('the silent mock', () => {
expect(Object.getOwnPropertyNames(globalThis)).toEqual(globals)
expect(output.filter((spy) => spy.mock.calls.length > 0)).toEqual([])
})
+
+ test('`new` on a member returns a mock', async () => {
+ const mod = await evaluateGeneratedModule<{
+ default: Record unknown>
+ }>(loadSilentMockModule().code)
+ expect(isMock(new mod.default.Client!())).toBe(true)
+ })
})
describe('the build mock of a denied file', () => {
@@ -91,51 +100,70 @@ describe('the build mock of a denied file', () => {
expect(String(mod.getSecret())).toBe('[import-protection mock]')
})
+
+ test('`new` on a member of an export returns a mock', async () => {
+ const mod = await evaluateGeneratedModule<{
+ api: Record unknown>
+ }>(generateSelfContainedMockModule(['api']).code, { standalone: true })
+ expect(isMock(new mod.api.Client!())).toBe(true)
+ })
})
+/** A client violation of a dev mock: `./secret.server` in a route. */
+const violation: ViolationInfo = {
+ env: 'client',
+ envType: 'client',
+ type: 'file',
+ behavior: 'mock',
+ specifier: './secret.server',
+ importer: '/project/src/routes/index.tsx',
+ trace: [
+ { file: '/project/src/main.tsx' },
+ {
+ file: '/project/src/routes/index.tsx',
+ specifier: './secret.server',
+ line: 5,
+ column: 10,
+ },
+ ],
+}
+
+/**
+ * Silences `console.warn` and `console.error`; the returned function reads
+ * the messages of the errors logged since, by console method.
+ */
+function captureConsole() {
+ const spies = {
+ warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
+ error: vi.spyOn(console, 'error').mockImplementation(() => {}),
+ }
+ const messages = (spy: (typeof spies)['warn']) =>
+ spy.mock.calls.map(([logged]) => String((logged as Error).message))
+ return () => ({ warn: messages(spies.warn), error: messages(spies.error) })
+}
+
// In dev, the mock served in place of a denied file imports a mock runtime
// chosen for the violation (`mockAccess`, the environment): in the client,
// each use of the mock reports the violation in the browser console.
describe('the dev mock of a denied file', () => {
- const violation: ViolationInfo = {
- env: 'client',
- envType: 'client',
- type: 'file',
- behavior: 'mock',
- specifier: './secret.server',
- importer: '/project/src/routes/index.tsx',
- trace: [
- { file: '/project/src/main.tsx' },
- {
- file: '/project/src/routes/index.tsx',
- specifier: './secret.server',
- line: 5,
- column: 10,
- },
- ],
- }
-
/**
* Evaluates the dev mock of a denied file exporting `getSecret`, for
- * `violation` with the `mockAccess` mode `mode`, calls `getSecret()`, and
- * returns the messages the call logged, by console method.
+ * `violation` with the `mockAccess` mode `mode`, uses it (default: calls
+ * `getSecret()`), and returns the messages the use logged, by console
+ * method.
*/
async function useDevMock(
mode: 'error' | 'warn' | 'off',
info: ViolationInfo = violation,
+ use: (mod: { getSecret: any }) => unknown = (mod) => mod.getSecret(),
) {
- const spies = {
- warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
- error: vi.spyOn(console, 'error').mockImplementation(() => {}),
- }
+ const output = captureConsole()
const runtimeId = mockRuntimeModuleIdFromViolation(info, mode, '/project')
const mod = await evaluateGeneratedModule<{ getSecret: MockFunction }>(
generateDevSelfDenialModule(['getSecret'], runtimeId).code,
)
- mod.getSecret()
- const messages = (spy: (typeof spies)['warn']) =>
- spy.mock.calls.map(([logged]) => String((logged as Error).message))
- return { warn: messages(spies.warn), error: messages(spies.error) }
+ use(mod)
+ return output()
}
test.each([
@@ -159,6 +187,23 @@ describe('the dev mock of a denied file', () => {
},
)
+ test.each<{ name: string; use: (mod: { getSecret: any }) => unknown }>([
+ { name: 'a conversion to a string', use: (mod) => String(mod.getSecret) },
+ { name: 'a toString() call', use: (mod) => mod.getSecret.toString() },
+ { name: '`new`', use: (mod) => new mod.getSecret() },
+ {
+ name: 'a property write',
+ use: (mod) => {
+ mod.getSecret.value = 1
+ },
+ },
+ ])('$name is reported as a use', async ({ use }) => {
+ expect(await useDevMock('error', violation, use)).toEqual({
+ warn: [],
+ error: [expect.stringContaining('Mocked import used in dev client')],
+ })
+ })
+
test.each([
{ name: 'mockAccess off', mode: 'off', info: violation },
{
@@ -170,3 +215,59 @@ describe('the dev mock of a denied file', () => {
expect(await useDevMock(mode, info)).toEqual({ warn: [], error: [] })
})
})
+
+// In dev, an importer of a denied module links against a mock edge exporting
+// the names it uses, as members of the mock runtime chosen for the violation.
+test('the dev mock edge of a denied import reports a use in the client', async () => {
+ const output = captureConsole()
+ const { read } = await evaluateWithMock(
+ `import { secret } from './secret.server'
+export const read = () => secret()`,
+ './secret.server',
+ {
+ runtimeId: mockRuntimeModuleIdFromViolation(
+ violation,
+ 'error',
+ '/project',
+ ),
+ },
+ )
+ read()
+ expect(output()).toEqual({
+ warn: [],
+ error: [expect.stringContaining('Mocked import used in dev client')],
+ })
+})
+
+// Rsbuild serves the mock runtime of a client violation for every
+// `mockAccess` mode, with the mode in the module's payload (encoded as
+// `ensureRuntimeMockModule` in src/rsbuild/import-protection.ts does).
+test.each([
+ { mode: 'error', reported: 1 },
+ { mode: 'off', reported: 0 },
+] as const)(
+ 'the Rsbuild mock runtime for mockAccess $mode reports $reported use(s)',
+ async ({ mode, reported }) => {
+ const output = captureConsole()
+ const encoded = Buffer.from(
+ JSON.stringify({
+ mode,
+ env: 'client',
+ importer: '/project/src/routes/index.tsx',
+ specifier: './secret.server',
+ trace: [],
+ }),
+ 'utf8',
+ ).toString('base64url')
+ const mod = await evaluateGeneratedModule<{
+ default: Record
+ }>(loadMockRuntimeModule(encoded).code, { standalone: true })
+ mod.default.getSecret!()
+ expect(output()).toEqual({
+ warn: [],
+ error: Array.from({ length: reported }, () =>
+ expect.stringContaining('Mocked import used in dev client'),
+ ),
+ })
+ },
+)
From 56418a71484de55050e156d44c4b882ffcca178e Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:34:34 +0200
Subject: [PATCH 0255/1425] test(start-plugin-core): cover where import
protection reports a use
ip-analysis P8-P11 (low, location only): shadowing and loop/catch rows
for the post-compile usage lookup, the preferred use (call/new/member
read over a read, object keys vs values, re-exports), compiler-safe
boundaries vs look-alikes in the original-source lookup, and the lookups
of two denied sources of one module.
Kills (sampled): spc:829, spc:965, spc:970, spc:974, spc:976, spc:978,
spc:982, spc:923, spc:935, spc:952, spc:866, spc:880, spc:883, spc:888,
spc:904, spc:905, spc:511, spc:515, spc:540, spc:549, spc:555, spc:562,
spc:570, spc:586, spc:810
Co-Authored-By: Claude Opus 5.5
---
.../importProtection/analysis.usage.test.ts | 79 ++++++++++++
.../importProtection/sourceLocation.test.ts | 120 ++++++++++++++++++
2 files changed, 199 insertions(+)
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 bd7def74b77..472f4194f66 100644
--- a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts
@@ -189,6 +189,85 @@ describe('findPostCompileUsagePos', () => {
pos(`import { denied } from 'denied';\n(denied)()`, 'denied'),
).toEqual({ line: 2, column0: 1 })
})
+
+ // Line 2 does not use the import (it shadows or merely names `x`, or does
+ // not mention it); the use of the import is on line 3.
+ test.each([
+ ['a for-in on an identifier', `let k; for (k in {}) { console.log(k) }`],
+ ['a for without clauses', `for (;;) { break }`],
+ ['a for without a test', `for (let i = 0; ; i++) { break }`],
+ ['a for without an update', `for (let i = 0; i < 1; ) { break }`],
+ ['array holes', `function f() { const [, a] = [1, 2]; return a }`],
+ ['an object pattern parameter', `function a({ x }) { return x }`],
+ ['an object rest parameter', `function a({ ...x }) { return x }`],
+ ['an array pattern parameter', `function a([, x]) { return x }`],
+ ['a default parameter', `function a(x = 1) { return x }`],
+ ['a rest parameter', `function a(...x) { return x }`],
+ ['a block class', `function a(ok) { if (ok) { class x {} return x } }`],
+ [
+ 'a block function',
+ `function a(ok) { if (ok) { function x() {} return x } }`,
+ ],
+ ['a named function expression', `const g = function x() { return x }`],
+ ['a class expression name', `const C = class x { static y = 1 }`],
+ ['a for-let', `for (let x = 0; x < 1; x++) { console.log(x) }`],
+ ['a for-of', `for (const x of [1]) { console.log(x) }`],
+ ['an object key', `const o = { x: 1 }`],
+ ])('skips %s', (_name, line2) => {
+ const code = `import { x } from 'denied'\n${line2}\nexport const later = x`
+ expect(pos(code, 'denied')?.line).toBe(3)
+ })
+
+ test('a var of a nested function does not shadow the enclosing function', () => {
+ const code = `import { x } from 'denied'
+function a() { function b() { var x = 1 } return x }
+export const later = x`
+ expect(pos(code, 'denied')?.line).toBe(2)
+ })
+
+ // Line 2 uses the import inside a loop or a catch clause.
+ test.each([
+ ['a for initialiser', `for (let i = x(); i < 1; i++) {}`],
+ ['a for test', `for (let i = 0; x(i); i++) {}`],
+ ['a for update', `for (let i = 0; i < 1; i = x(i)) {}`],
+ ['a for body', `for (let i = 0; i < 1; i++) { x() }`],
+ ['a for-of right side', `for (const i of x()) {}`],
+ ['a for-of body', `for (const i of [1]) { x() }`],
+ ['a catch body', `try {} catch { x() }`],
+ ])('finds a use in %s', (_name, line2) => {
+ const code = `import { x } from 'denied'\n${line2}\nexport const later = x`
+ expect(pos(code, 'denied')?.line).toBe(2)
+ })
+
+ // A call (or `new`, or a namespace member read) is the clearest place to
+ // report a use; otherwise the first use is reported.
+ test.each([
+ ['a call after a read', `export const first = x\nx()`, 3],
+ ['a call after a call argument', `foo(x)\nx()`, 3],
+ ['`new` after a read', `export const first = x\nnew x()`, 3],
+ ['a call after a `new` argument', `new Map(x)\nx()`, 3],
+ [
+ 'the first read before a computed key',
+ `export const first = x\nobj[x]`,
+ 2,
+ ],
+ ['an object value', `export const o = { a: x }`, 2],
+ ['a re-export', `export { x }`, 2],
+ ['an aliased re-export', `export { x as leaked }`, 2],
+ ])('reports %s', (_name, rest, line) => {
+ expect(pos(`import { x } from 'denied'\n${rest}`, 'denied')?.line).toBe(
+ line,
+ )
+ })
+
+ test('reports a namespace member read after a read of the namespace', () => {
+ expect(
+ pos(
+ `import * as ns from 'denied'\nexport const first = ns\nns.read`,
+ 'denied',
+ )?.line,
+ ).toBe(3)
+ })
})
// The violation import protection reports for a denied import in a module the
diff --git a/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts b/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts
index f7296186d95..ea14e56e5b5 100644
--- a/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts
@@ -417,6 +417,126 @@ describe('findOriginalUsageLocation', () => {
),
).toBeUndefined()
})
+
+ // Each case has a use inside a boundary the compiler strips from `env`
+ // (skipped), then a look-alike use that is not one (reported).
+ const imports = `import { createServerFn, createServerOnlyFn, createClientOnlyFn, createIsomorphicFn, createMiddleware } from '@tanstack/react-start'
+import { getSecret } from './secret.server'
+`
+ test.each([
+ {
+ name: 'createServerOnlyFn, then an ordinary callback',
+ env: 'client',
+ body: `export const a = createServerOnlyFn(() => getSecret())
+export const b = run(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'createClientOnlyFn, then an ordinary callback',
+ env: 'server',
+ body: `export const a = createClientOnlyFn(() => getSecret())
+export const b = run(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'createIsomorphicFn().client, then .server',
+ env: 'server',
+ body: `export const a = createIsomorphicFn().client(() => getSecret())
+export const b = createIsomorphicFn().server(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'createIsomorphicFn().server, then .client',
+ env: 'client',
+ body: `export const a = createIsomorphicFn().server(() => getSecret())
+export const b = createIsomorphicFn().client(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'createIsomorphicFn().server, then a nested member call',
+ env: 'client',
+ body: `export const a = createIsomorphicFn().server(() => getSecret())
+export const b = api.helpers.run(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'createMiddleware().server, then another .server method',
+ env: 'client',
+ body: `export const a = createMiddleware().server(() => getSecret())
+export const b = api.server(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'a middleware variable, then a .handler method',
+ env: 'client',
+ body: `const authMiddleware = createMiddleware()
+export const a = authMiddleware.server(() => getSecret())
+export const b = queue.handler(() => getSecret())`,
+ line: 5,
+ },
+ {
+ name: 'a server fn builder variable, then an ordinary callback',
+ env: 'client',
+ body: `const myServerFn = createServerFn()
+export const a = myServerFn.handler(() => getSecret())
+export const b = run(() => getSecret())`,
+ line: 5,
+ },
+ {
+ name: 'a catch clause in a handler, then an ordinary callback',
+ env: 'client',
+ body: `export const a = createServerFn().handler(() => { try { return 1 } catch { return getSecret() } })
+export const b = run(() => getSecret())`,
+ line: 4,
+ },
+ {
+ name: 'a callback of a returned function',
+ env: 'client',
+ body: `export const a = getRunner()(() => getSecret())`,
+ line: 3,
+ },
+ {
+ name: 'a method of a constructed object',
+ env: 'client',
+ body: `export const a = new Api().run(() => getSecret())`,
+ line: 3,
+ },
+ ] as const)('$env: $name', ({ env, body, line }) => {
+ const provider = makeProvider('export {}\n', imports + body)
+ expect(
+ findOriginalUsageLocation(
+ provider,
+ '/test/src/a.ts',
+ './secret.server',
+ env,
+ )?.line,
+ ).toBe(line)
+ })
+})
+
+describe('findPostCompileUsageLocation', () => {
+ test('locates the use of each of two denied sources of a module', async () => {
+ const result: TransformResult = {
+ code: [
+ `import { a } from 'denied-a'`,
+ `import { b } from 'denied-b'`,
+ `export const one = a`,
+ `export const two = b`,
+ ].join('\n'),
+ map: undefined,
+ originalCode: undefined,
+ }
+ const provider: TransformResultProvider = {
+ getTransformResult: () => result,
+ }
+ const id = '/test/src/a.ts'
+ expect(
+ (await findPostCompileUsageLocation(provider, id, 'denied-a'))?.line,
+ ).toBe(3)
+ expect(
+ (await findPostCompileUsageLocation(provider, id, 'denied-b'))?.line,
+ ).toBe(4)
+ })
})
describe('ImportLocCache', () => {
From 0c24c41e2b89a26b7e674692cf4208812b888fe5 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:25:47 +0200
Subject: [PATCH 0256/1425] test(start-plugin-core): pair every server fn of a
module with its own id
sc-core P1 (High): a module with several server fns, compiled for the
client then the provider through the hosts' shared registry, calls each
handler by the id its client RPC carries, with default ids and with one
custom id for all (deduplicated ids stay distinct).
sc-core P5: a unique custom generateFunctionId id is the id clients call.
Kills: spc:2473, spc:2486, spc:2497
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-compiler-determinism.test.ts | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/packages/start-plugin-core/tests/start-compiler-determinism.test.ts b/packages/start-plugin-core/tests/start-compiler-determinism.test.ts
index 14d8ccc052e..5fe01e8a9cf 100644
--- a/packages/start-plugin-core/tests/start-compiler-determinism.test.ts
+++ b/packages/start-plugin-core/tests/start-compiler-determinism.test.ts
@@ -149,6 +149,8 @@ describe('server fn ids', () => {
},
})
expect(new Set(filenames)).toEqual(new Set(['src/fns.ts']))
+ // The id it returns (unique here) is the one clients call.
+ expect(result.rpc).toBe('custom:src/fns.ts')
expect(result.handlerIds).toEqual([result.rpc])
expect(result.called).toEqual(['from the server'])
},
@@ -163,6 +165,49 @@ describe('server fn ids', () => {
expect(result.called).toEqual(['from the server'])
})
+ // The provider gives each server fn of a module the id the client gave it,
+ // also when a custom generateFunctionId gives them all the same id.
+ test.each([
+ { name: 'default ids', generateFunctionId: undefined },
+ { name: 'one custom id for all', generateFunctionId: () => 'same-id' },
+ ])(
+ 'each server fn of a module with several reaches its own handler ($name)',
+ async ({ generateFunctionId }) => {
+ const registry: Record = {}
+ const id = '/app/src/fns.ts'
+ const code = `import { createServerFn } from '@tanstack/react-start'
+export const a = createServerFn().handler(async () => 'from a')
+export const b = createServerFn().handler(async () => 'from b')
+export const c = createServerFn().handler(async () => 'from c')`
+ const client = await evaluateModule(
+ (await compilerFor('client', '/app', registry, generateFunctionId)(
+ code,
+ id,
+ ))!,
+ )
+ const rpcs: Record = {
+ a: client.a.rpc.client,
+ b: client.b.rpc.client,
+ c: client.c.rpc.client,
+ }
+ expect(new Set(Object.values(rpcs)).size).toBe(3)
+ const provider = (await compilerFor(
+ 'server',
+ '/app',
+ registry,
+ generateFunctionId,
+ )(code, `${id}?tss-serverfn-split`))!
+ const handlers = handlersById(
+ await createServerGraph({ ssr: undefined, provider, id }).provider(),
+ )
+ const called: Record = {}
+ for (const [name, rpc] of Object.entries(rpcs)) {
+ called[name] = await handlers[rpc]?.({})
+ }
+ expect(called).toEqual({ a: 'from a', b: 'from b', c: 'from c' })
+ },
+ )
+
// Two server fns get the same custom id: the compilers tell them apart,
// and the server environment, compiling the modules in the other order,
// gives each the id the client gave it.
From bbce328cb765f0b97c50241989eee1a86889e60c Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:25:51 +0200
Subject: [PATCH 0257/1425] test(start-plugin-core): keep merged server fns
client-callable, locate deep diagnostics
sc-handlers 1 (High): a server fn the client and a server caller both
report, merged in either order the way the hosts merge reports, stays
callable from the client through the resolver's client-origin check.
spc:3769 (merge drops the existing entry) survives: every caller report
is client-referenced today (D6), so overwriting changes nothing; the
test kills it once server callers report server-only fns (D6 fix).
sc-handlers 6: a diagnostic on line 12 names that line and does not
quote the whole module.
Kills: spc:3773, spc:3844
Co-Authored-By: Claude Opus 5.5
---
.../createServerFn/createServerFn.test.ts | 69 +++++++++++++++----
1 file changed, 54 insertions(+), 15 deletions(-)
diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
index c1724d2d0e8..58384737113 100644
--- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
@@ -5,6 +5,7 @@ import {
getLookupKindsForEnv,
} from '../../src/start-compiler/compiler'
import { getLookupConfigurationsForEnv } from '../../src/start-compiler/config'
+import { mergeServerFnsById } from '../../src/start-compiler/host'
import { generateServerFnResolverModule } from '../../src/start-compiler/server-fn-resolver-module'
import {
callProvider,
@@ -60,6 +61,23 @@ describe('createServerFn compiles correctly', () => {
},
)
+ test('reports the location of a diagnostic deep in the module', async () => {
+ const error = await compileFor(
+ 'client',
+ [
+ `import { createServerFn } from '@tanstack/react-start'`,
+ ...Array.from({ length: 10 }, (_, i) => `export const v${i} = ${i}`),
+ `export const { fn } = createServerFn().handler(async () => 1)`,
+ ].join('\n'),
+ ).catch((caught: unknown) => caught)
+
+ expect(error).toBeInstanceOf(Error)
+ const message = stripVTControlCharacters((error as Error).message)
+ expect(message).toMatch(/\b12\b[^\n]*export const \{ fn \}/)
+ // It points at that line: it does not quote the whole module.
+ expect(message).not.toContain('export const v0 = 0')
+ })
+
test('should not remove handler identifier bindings that are still referenced', async () => {
const compiled =
await compileAll(`import { createServerFn } from '@tanstack/react-start'
@@ -291,32 +309,38 @@ export const greetUser = createServerFn().handler(async () => 'second')`,
// (N14).
describe('server functions discovered in caller modules', () => {
/**
- * Compiles `code` in the caller environment `env` (the provider being a
- * separate `rsc` environment), then generates the production resolver from
- * the server fns the callers reported and links it to the provider, like
- * the server-fn router. Returns the resolver's `getServerFnById` and the
- * reported ids.
+ * Compiles `code` in each caller environment of `envs`, in order (the
+ * provider being a separate `rsc` environment), merging what they report
+ * into one registry like the hosts do, then generates the production
+ * resolver from it and links it to the provider, like the server-fn router.
+ * Returns the resolver's `getServerFnById` and the reported ids.
*/
- async function resolverFor(env: 'client' | 'server', code: string) {
+ async function resolverFor(
+ envs: 'client' | 'server' | ReadonlyArray<'client' | 'server'>,
+ code: string,
+ ) {
const reported: Record = {}
const options = {
- envName: env === 'client' ? 'client' : 'rsc',
root: '/test',
framework: 'react' as const,
providerEnvName: 'rsc',
mode: 'build' as const,
loadModule: async () => {},
- lookupKinds: getLookupKindsForEnv(env),
- lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
resolveId: async (id: string) => id,
getKnownServerFns: () => ({}),
}
- const caller = new StartCompiler({
- ...options,
- env,
- onServerFnsById: (serverFns) => Object.assign(reported, serverFns),
- })
- await caller.compile({ code, id: '/test/src/caller.ts' })
+ for (const env of typeof envs === 'string' ? [envs] : envs) {
+ const caller = new StartCompiler({
+ ...options,
+ env,
+ envName: env === 'client' ? 'client' : 'rsc',
+ lookupKinds: getLookupKindsForEnv(env),
+ lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
+ onServerFnsById: (serverFns) =>
+ mergeServerFnsById(reported, serverFns),
+ })
+ await caller.compile({ code, id: '/test/src/caller.ts' })
+ }
const provider = await new StartCompiler({
...options,
env: 'server',
@@ -352,6 +376,21 @@ export const greet = createServerFn().handler(async () => 'hi')`
expect(await handler({})).toBe('hi')
})
+ // The client and a server caller both report it, in either order: the
+ // client's report keeps it callable from the client.
+ test.each([
+ ['client', 'server'],
+ ['server', 'client'],
+ ] as const)(
+ 'a server fn the client and a server caller both compile is callable from the client (%s first)',
+ async (...envs) => {
+ const { getServerFnById, ids } = await resolverFor(envs, greet)
+ expect(ids).toHaveLength(1)
+ const handler = await getServerFnById(ids[0], { origin: 'client' })
+ expect(await handler({})).toBe('hi')
+ },
+ )
+
// Bug: a server-environment caller reports every server fn it compiles as
// client-referenced (handleCreateServerFn.ts: `isClientReferenced` is true
// for every SSR-caller compile, which an RSC environment's callers are
From 60393fd4d3cc0e3054f51798cfa3d1608199b99b Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:27:07 +0200
Subject: [PATCH 0258/1425] test(start-plugin-core): compile isomorphic fns
next to other code
sc-core P2: a top-level call of an isomorphic fn next to a server fn
returns each environment's value.
sc-core P4: a createIsomorphicFn() builder kept in a variable keeps the
server implementation and its import off the client.
sc-core P9: a `.client()` call on a global object next to an isomorphic
fn compiles and keeps its meaning.
Kills: spc:2374, spc:3198, spc:3332, spc:3333
Co-Authored-By: Claude Opus 5.5
---
.../createIsomorphicFn.test.ts | 44 ++++++++++++++++++-
1 file changed, 43 insertions(+), 1 deletion(-)
diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts b/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts
index 0e135c44d7c..9dd165de7a7 100644
--- a/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts
+++ b/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, test } from 'vitest'
+import { describe, expect, test, vi } from 'vitest'
import {
compileCode,
evaluateModule,
@@ -222,3 +222,45 @@ export const moduleLevel = getEnvironment()`)
expectEnvImports({ client, ssr })
})
})
+
+describe('isomorphic fns next to other code', () => {
+ test('a top-level call next to a server fn returns the value of each environment', async () => {
+ const code = `import { createServerFn, createIsomorphicFn } from '@tanstack/react-start'
+const getEnv = createIsomorphicFn().server(() => 'server').client(() => 'client')
+export const env = getEnv()
+export const fn = createServerFn().handler(async () => 'x')`
+ const client = await evaluateModule((await compileCode('client', code))!)
+ expect(client.env).toBe('client')
+ const ssr = await evaluateModule(
+ (await compileCode('ssr', code))!,
+ {},
+ { env: 'server' },
+ )
+ expect(ssr.env).toBe('server')
+ })
+
+ test('a builder kept in a variable', async () => {
+ const code = `import { createIsomorphicFn } from '@tanstack/react-start'
+import { db } from './db.server'
+const iso = createIsomorphicFn()
+export const fn = iso.server(() => db.x()).client(() => 'client')`
+ const client = (await compileCode('client', code)) ?? code
+ expect(importSources(client)).not.toContain('./db.server')
+ expect((await evaluateModule(client)).fn()).toBe('client')
+ })
+
+ test('a .client() call on a global object is left alone', async () => {
+ const code = `import { createIsomorphicFn } from '@tanstack/react-start'
+export const fn = createIsomorphicFn().server(() => 'server').client(() => 'client')
+export const viaGlobal = () => ws.client()`
+ const client = (await compileCode('client', code)) ?? code
+ vi.stubGlobal('ws', { client: () => 'global' })
+ try {
+ const module = await evaluateModule(client)
+ expect(module.fn()).toBe('client')
+ expect(module.viaGlobal()).toBe('global')
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+})
From 3d6c0cc26c0171e1450884a8c33ac2985060a539 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:28:23 +0200
Subject: [PATCH 0259/1425] test(start-plugin-core): keep wrapped server and
env-only fns off the client
sc-core P3: a top-level server fn wrapped in two calls never ships its
handler to the client, with createServerFn alone or next to another
factory (a standalone test: as a `placements` row it would also feed the
createServerFn-alone placement pins, which it passes); a createServerOnlyFn
wrapped in two calls runs only on the server.
sc-core P12: in dev, a server fn imported through `export *` of the Start
package still compiles after an unrelated module is invalidated.
Kills: spc:2792, spc:2820, spc:2649
Co-Authored-By: Claude Opus 5.5
---
.../createServerFn/declaration-sites.test.ts | 16 ++++++++++
.../tests/start-compiler-factories.test.ts | 31 +++++++++++++++++++
2 files changed, 47 insertions(+)
diff --git a/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts b/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts
index 5b2dcbd5099..71779c8174b 100644
--- a/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts
@@ -91,6 +91,22 @@ test.each(placements.filter((placement) => !placement.declarator))(
},
)
+// A top-level server fn wrapped in calls (e.g. user helpers registering or
+// typing it) never ships its handler to the client, whatever factories the
+// module imports.
+test.each(['createServerFn alone', 'another Start factory'])(
+ 'with %s imported, a server fn wrapped in two calls does not ship its handler to the client',
+ async (importHead) => {
+ await expectNoClientHandler(
+ placementModule(
+ importHead,
+ `const wrap = (fn: T) => fn
+export const fn = wrap(wrap(createServerFn().handler(async () => db.inner())))`,
+ ),
+ )
+ },
+)
+
// A user error, reported as such.
test('rejects a validator call without a validator', async () => {
for (const output of outputs) {
diff --git a/packages/start-plugin-core/tests/start-compiler-factories.test.ts b/packages/start-plugin-core/tests/start-compiler-factories.test.ts
index 94bbd6b2c2d..1ec7612a444 100644
--- a/packages/start-plugin-core/tests/start-compiler-factories.test.ts
+++ b/packages/start-plugin-core/tests/start-compiler-factories.test.ts
@@ -451,6 +451,18 @@ export class Api extends Base {
},
)
+ test('a createServerOnlyFn wrapped in two calls runs only on the server', async () => {
+ const compiled = await compileAll(`${head}import { db } from './db.server'
+const wrap = (fn: T) => fn
+export const read = wrap(wrap(createServerOnlyFn(() => db.secret())))`)
+ const stubs = { './db.server': { db: { secret: () => 'secret' } } }
+ expect(importSources(compiled.client)).not.toContain('./db.server')
+ expect(settle((await evaluateModule(compiled.client, stubs)).read)).toBe(
+ serverOnlyError,
+ )
+ expect((await evaluateModule(compiled.ssr, stubs)).read()).toBe('secret')
+ })
+
// On the server, a middleware `.server()` next to an isomorphic fn stays a
// middleware: it is not compiled as the isomorphic fn's `.server()`.
test('isomorphic, server-only and middleware implementations in one module reach only their environment', async () => {
@@ -932,4 +944,23 @@ export const fn = base.handler(async () => db.x())`
)
expect(await fn()).toBe('x')
})
+
+ test('dev: a server fn imported through an export * of the Start package compiles after an unrelated edit', async () => {
+ const files = {
+ '/test/src/start.ts': `export * from '@tanstack/react-start'`,
+ '/test/src/other.ts': `export const x = 1`,
+ }
+ const { compiler, compile } = createStartCompiler({
+ env: 'client',
+ mode: 'dev',
+ files,
+ })
+ const code = `import { createServerFn } from './start'
+export const fn = createServerFn().handler(async () => 'secret-body')`
+ expect(await compile(code, '/test/src/a.ts')).not.toContain('secret-body')
+ compiler.invalidateModules(['/test/src/other.ts'])
+ const after = await compile(code, '/test/src/b.ts')
+ expect(after).not.toBeNull()
+ expect(after).not.toContain('secret-body')
+ })
})
From 7fd68970b31f757cf531671e0cbb80849f45d761 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:36:05 +0200
Subject: [PATCH 0260/1425] test(start-plugin-core): cover transforms, provider
exports and cycles in the compiler
sc-core P6: IIFEs and curried calls compile in a module calling a
configured external transform.
sc-core P7: unrelated virtual runtime imports next to a server fn (top
level) or a server-only fn (nested) are left alone and the module still
compiles, with a host that cannot load them.
sc-core P8: a provider with external transforms configured compiles
aliased env-only calls of a server fn module.
sc-core P11: compiling a self-referencing binding settles (bounded race).
Under 3029/3030/3032 the recursion never yields to the event loop, so
the race cannot fire: the worker runs out of memory (~35 s) and the file
fails; killed, but not within the race.
sc-handlers 3: the provider drops export lists (`export { Page }`,
`export { Page as component }`) and named re-exports of a browser-only
module (the latter a separate test: the shared rows' unused top-level
`window` read is user code the provider rightly keeps).
sc-handlers 5: an external transform reaches a top-level call imported
through a project re-export next to a built-in factory (the narrow shape
main supports; the others are pinned).
Kills: spc:2413, spc:2737, spc:2389, spc:2390, spc:2392, spc:2393,
spc:2327, spc:2271, spc:3029, spc:3030, spc:3032 (worker OOM),
spc:3705, spc:3707, spc:3417, spc:3418, spc:3419, spc:3421
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/compiler.test.ts | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts
index 25aecd57003..207a2c76f96 100644
--- a/packages/start-plugin-core/tests/compiler.test.ts
+++ b/packages/start-plugin-core/tests/compiler.test.ts
@@ -182,6 +182,49 @@ describe('compiler handles external import transforms', () => {
expect(component).toEqual(['card', injected])
})
+ test('compiles IIFEs and curried calls in a module calling a configured transform', async () => {
+ const result = await compileWithTransform(`import { renderThing } from '@example/runtime'
+const Card = () => 'card'
+export const value = (() => 1)()
+export const curried = ((a: number) => (b: number) => a + b)(1)(2)
+export const rendered = renderThing( )`)
+
+ expect(result).not.toBeNull()
+ const module = await evaluateModule(result!, runtimeStubs)
+ expect([module.value, module.curried, module.rendered]).toEqual([
+ 1,
+ 3,
+ ['card', injected],
+ ])
+ })
+
+ // The other shapes (a nested call; a module without a built-in factory)
+ // are pinned in known-bugs-start-compiler.test.ts.
+ test('runs configured transforms on a top-level call imported through a project re-export, next to a built-in factory', async () => {
+ const result = await compileWithTransform(
+ `import { renderThing } from './rt'
+import { createServerOnlyFn } from '@tanstack/react-start'
+export const only = createServerOnlyFn(() => 1)
+export const rendered = renderThing('card')`,
+ '/test/src/routes/card.tsx',
+ {
+ files: {
+ '/test/src/rt.ts': `export { renderThing } from '@example/runtime'`,
+ },
+ host: {
+ resolveId: async (id) =>
+ id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id,
+ },
+ },
+ )
+
+ expect(result).not.toBeNull()
+ const { rendered } = await evaluateModule(result!, {
+ './rt': runtimeStubs['@example/runtime'],
+ })
+ expect(rendered).toEqual(['card', injected])
+ })
+
test('does not run external transforms for shadowed import names', async () => {
const code = `
import { renderThing } from '@example/runtime'
@@ -339,6 +382,46 @@ ${body}`
expect(result ?? code).not.toContain('loadThing')
})
+ // The same next to a built-in Start factory, which the module still
+ // compiles.
+ test.each([
+ {
+ name: 'a top-level call next to a server fn',
+ code: `import { createServerFn } from '@tanstack/react-start'
+import { renderThing } from 'virtual:rsc-runtime'
+const Card = () => 'card'
+export const component = renderThing( )
+export const fn = createServerFn().handler(async () => 'server-body')`,
+ },
+ {
+ name: 'a nested call next to a server-only fn',
+ code: `import { createServerOnlyFn } from '@tanstack/react-start'
+import { renderThing } from 'virtual:rsc-runtime'
+const Card = () => 'card'
+export function component() { return renderThing( ) }
+export const so = createServerOnlyFn(() => 'server-body')`,
+ },
+ ])(
+ 'leaves unrelated virtual runtime imports alone: $name',
+ async ({ code }) => {
+ const result = await compileWithTransform(
+ code,
+ '/test/src/runtime.tsx',
+ {
+ host: {
+ resolveId: async (id) => `\0${id}`,
+ loadModule: async (id) => {
+ throw new Error(`cannot load ${id}`)
+ },
+ },
+ },
+ )
+
+ expect(result).not.toBeNull()
+ expect(result).not.toContain('loadThing')
+ },
+ )
+
// Source: #8504 compiler.test.ts "transforms configured aliases without
// tracing unrelated runtime calls"
test('transforms configured aliases next to an unrelated call of the same name', async () => {
@@ -415,6 +498,8 @@ describe('server function provider module directives', () => {
'export const Route = { component: Page }',
'export function Route() { return Page() }',
'export default function Route() { return Page() }',
+ 'export { Page }',
+ 'export { Page as component }',
])(
'removes browser-only dependencies of stripped exports: %s',
async (route) => {
@@ -431,6 +516,23 @@ ${route}`,
},
)
+ // `export * from` is pinned in known-bugs-start-compiler.test.ts.
+ test('does not load a browser-only module the stripped exports re-export', async () => {
+ const server = await compileServer(
+ `import { createServerFn } from '@tanstack/react-start'
+export const getMessage = createServerFn().handler(() => 'server message')
+export { Widget } from './browser'`,
+ { id: '/test/src/routes/message.tsx' },
+ )
+
+ expect(
+ await callProvider(server, 'getMessage', {
+ './browser': `throw new Error('browser-only module evaluated on the server')
+export const Widget = 1`,
+ }),
+ ).toBe('server message')
+ })
+
const code = `
import { createServerFn } from '@tanstack/react-start'
@@ -814,6 +916,60 @@ export const serverFn = createServerFn().handler(async () => {
expect(await callServerFn()).toBe(clientOnlyError)
})
+
+ // The same with an external transform configured (e.g. the RSC CSS one).
+ test('a provider with external transforms compiles aliased env-only calls of a server fn module', async () => {
+ const files = {
+ '/test/src/factory.ts': `export { createServerOnlyFn as makeServerOnly } from '@tanstack/start-fn-stubs'`,
+ }
+ const id = '/test/src/aliased.ts'
+ const source = `import { createServerFn } from '@tanstack/react-start'
+import { makeServerOnly } from './factory'
+const impl = makeServerOnly(() => 'server-only-value')
+export const serverFn = createServerFn().handler(async () => impl())`
+ const { compile } = createStartCompiler({
+ env: 'server',
+ files,
+ compilerTransforms: [renderThingTransform],
+ })
+
+ expect(
+ await settleAsync(async () =>
+ callProvider(
+ {
+ id,
+ ssr: (await compile(source, id)) ?? source,
+ provider: (await compile(source, `${id}?tss-serverfn-split`))!,
+ },
+ 'serverFn',
+ { './factory': { makeServerOnly: uncompiled } },
+ ),
+ ),
+ ).toBe('server-only-value')
+ })
+
+ // A binding whose initializer reads itself (code saved mid-edit): compiling
+ // settles, with a result or an error, instead of looping.
+ test.each([
+ `import { createServerFn } from '@tanstack/react-start'
+export const fn: any = fn.handler(async () => 'x')
+export const real = createServerFn().handler(async () => 'y')`,
+ `import { createIsomorphicFn } from '@tanstack/react-start'
+var a: any = b
+var b: any = a
+export const fn = a.server(() => 'x')
+export const real = createIsomorphicFn().client(() => 'c')`,
+ ])('compiling a self-referencing binding settles: %#', async (code) => {
+ const { compile } = createStartCompiler({ env: 'client' })
+ const settled = await Promise.race([
+ compile(code).then(
+ () => 'settled',
+ () => 'settled',
+ ),
+ new Promise((resolve) => setTimeout(resolve, 2_000, 'still compiling')),
+ ])
+ expect(settled).toBe('settled')
+ })
})
test('leaves unrelated SSR middleware chains to the bundler when a server function factory is present', async () => {
From 25c29a03abb170744b45cf6965ddff45d946296a Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:37:21 +0200
Subject: [PATCH 0261/1425] test(start-plugin-core): run the re-export
transform test through the hosts' factory
sc-handlers 5: compile the narrow re-export shape with the compiler the
hosts create (`createStartCompiler` of host.ts), which forwards
`compilerTransforms` to the lookup configurations, so dropping them there
fails the test too (a compile-level stand-in for the Vite build case).
Kills: spc:3765 (and still spc:3417, 3418, 3419, 3421)
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/compiler.test.ts | 48 +++++++++++++------
1 file changed, 34 insertions(+), 14 deletions(-)
diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts
index 207a2c76f96..da4d480cabc 100644
--- a/packages/start-plugin-core/tests/compiler.test.ts
+++ b/packages/start-plugin-core/tests/compiler.test.ts
@@ -5,6 +5,7 @@ import {
getLookupKindsForEnv,
} from '../src/start-compiler/compiler'
import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
+import { createStartCompiler as createHostCompiler } from '../src/start-compiler/host'
import {
callProvider,
clientOnlyError,
@@ -201,24 +202,43 @@ export const rendered = renderThing( )`)
// The other shapes (a nested call; a module without a built-in factory)
// are pinned in known-bugs-start-compiler.test.ts.
test('runs configured transforms on a top-level call imported through a project re-export, next to a built-in factory', async () => {
- const result = await compileWithTransform(
- `import { renderThing } from './rt'
+ const files: Record = {
+ '/test/src/rt.ts': `export { renderThing } from '@example/runtime'`,
+ }
+ const code = `import { renderThing } from './rt'
import { createServerOnlyFn } from '@tanstack/react-start'
export const only = createServerOnlyFn(() => 1)
-export const rendered = renderThing('card')`,
- '/test/src/routes/card.tsx',
- {
- files: {
- '/test/src/rt.ts': `export { renderThing } from '@example/runtime'`,
- },
- host: {
- resolveId: async (id) =>
- id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id,
- },
+export const rendered = renderThing('card')`
+ const compilerTransforms = [renderThingTransform]
+ // Created by the hosts' shared factory, as the bundler plugins do.
+ const compiler = createHostCompiler({
+ env: 'server',
+ envName: 'ssr',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'ssr',
+ mode: 'build',
+ compilerTransforms,
+ getKnownServerFns: () => ({}),
+ loadModule: async (id) => {
+ if (files[id] !== undefined) {
+ compiler.ingestModule({ code: files[id], id })
+ }
},
- )
+ resolveId: async (id) =>
+ id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id,
+ })
+ const result = (
+ await compiler.compile({
+ code,
+ id: '/test/src/routes/card.tsx',
+ detectedKinds: detectKindsInCode(code, 'server', {
+ compilerTransforms,
+ }),
+ })
+ )?.code
- expect(result).not.toBeNull()
+ expect(result).toBeDefined()
const { rendered } = await evaluateModule(result!, {
'./rt': runtimeStubs['@example/runtime'],
})
From a41a1db4d028e0e0c016d6db56c98effd677318e Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:42:00 +0200
Subject: [PATCH 0262/1425] test(start-plugin-core): cover default-exported
expressions and triple var redeclarations
sc-handlers 2: an object-literal (or other expression) default export
next to a server fn keeps every output a valid module, the client its
value and the provider its handler.
sc-handlers 7: three redeclared `var` server fns, named with `_`, `$` and
non-ASCII characters too, get distinct ids, each served by the provider.
spc:3677 (and 3676/3678 by analysis) turns the fresh-name loop into a
synchronous infinite loop: the run hangs (no in-process bound can fire),
so it is not counted as killed.
Kills: spc:3690, spc:3672
Co-Authored-By: Claude Opus 5.5
---
.../exports-and-directives.test.ts | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts b/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts
index d0fc3813d15..d482fd69435 100644
--- a/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts
@@ -97,6 +97,21 @@ export { a as default, b as '📙', c as as }`)
expect(await callProvider(compiled, 'fn')).toBe(1)
})
+ // E.g. a Vue options-API component or a config object.
+ test.each([
+ { code: 'export default { a: 1, b: 2 }', value: { a: 1, b: 2 } },
+ { code: 'export default 42', value: 42 },
+ ])(
+ 'a default-exported expression next to a server fn: $code',
+ async ({ code, value }) => {
+ const compiled = await compileAll(
+ `${head}export const fn = createServerFn().handler(async () => 1)\n${code}`,
+ )
+ expect((await evaluateModule(compiled.client)).default).toEqual(value)
+ expect(await callProvider(compiled, 'fn')).toBe(1)
+ },
+ )
+
// Source: @vitejs/plugin-rsc source-map/wrap-export/local-export-before-declaration.js
test('an export list before the server fn declaration', async () => {
const compiled = await compileAll(`${head}export { fn, fn as alias }
@@ -263,6 +278,22 @@ export { fn }`
expect(new Set(Object.keys(compiled.serverFns)).size).toBe(2)
})
+ // Which handler each one runs is pinned below; here every reported server
+ // fn has a handler in the provider.
+ test.each(['fn', '_fn', '$fn', 'ñandú'])(
+ 'three redeclared var server fns named %s get distinct ids the provider serves',
+ async (name) => {
+ const compiled = await compileAll(`${head}var ${name} = createServerFn().handler(async () => 1)
+var ${name} = createServerFn().handler(async () => 2)
+var ${name} = createServerFn().handler(async () => 3)
+export { ${name} }`)
+ expect(new Set(Object.keys(compiled.serverFns)).size).toBe(3)
+ expect(
+ await callReportedServerFns(compiled, compiled.serverFns),
+ ).toHaveLength(3)
+ },
+ )
+
// Bug: the provider runs each server fn through its variable
// (`fn.__executeServer`), so with a redeclared `var` every handler export
// runs the last declaration's handler.
From afd3d73b18d916d9c483cc18050c1a7e9eabc515 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:44:00 +0200
Subject: [PATCH 0263/1425] test(start-plugin-core): cover resolver order, SSR
map sources and renamed ClientOnly forms
sc-handlers 4: the server-fn resolver module is the same whatever order
the server fns were discovered in.
sc-core P10: the SSR output's source map carries the original source
(the client is left out: D1, client maps and stripped server code, is
out of scope).
sc-core P13: a renamed `` imported next to other specifiers,
or written without spaces, still drops its children on the server.
Kills: spc:3811, spc:3817, spc:2983, spc:2984, spc:2985, spc:2236,
spc:2238
Co-Authored-By: Claude Opus 5.5
---
.../tests/server-fn-resolver-module.test.ts | 30 +++++++++++++++++++
.../tests/start-compiler-import-forms.test.ts | 9 ++++--
...start-compiler-preserves-user-code.test.ts | 11 +++++++
3 files changed, 48 insertions(+), 2 deletions(-)
diff --git a/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts b/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts
index af9b9492d61..9f7a0d1f034 100644
--- a/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts
+++ b/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts
@@ -46,4 +46,34 @@ describe('generateServerFnResolverModule', () => {
).rejects.toThrow('Server function info not found')
},
)
+
+ // Server fns are discovered in whatever order the bundler compiles the
+ // modules; the resolver (and so the bundle's content hash) must not
+ // depend on it (R4).
+ test('generates the same module whatever order the server fns were discovered in', () => {
+ const serverFn = (name: string) => ({
+ functionName: `${name}Fn`,
+ functionId: name,
+ filename: `/app/${name}.ts`,
+ extractedFilename: `/app/${name}.ts?tss-serverfn-split`,
+ isClientReferenced: true,
+ })
+ const names = ['b', 'c', 'a', 'd']
+ const orders = [
+ names,
+ [...names].reverse(),
+ ['a', 'b', 'c', 'd'],
+ ['c', 'a', 'd', 'b'],
+ ]
+ const sources = orders.map((order) =>
+ generateServerFnResolverModule({
+ serverFnsById: Object.fromEntries(
+ order.map((name) => [name, serverFn(name)]),
+ ),
+ includeClientReferencedCheck: true,
+ memoizeModules: true,
+ }),
+ )
+ expect(new Set(sources).size).toBe(1)
+ })
})
diff --git a/packages/start-plugin-core/tests/start-compiler-import-forms.test.ts b/packages/start-plugin-core/tests/start-compiler-import-forms.test.ts
index e82b6295501..d9ab82014ec 100644
--- a/packages/start-plugin-core/tests/start-compiler-import-forms.test.ts
+++ b/packages/start-plugin-core/tests/start-compiler-import-forms.test.ts
@@ -214,6 +214,7 @@ ${construct.code(form.callee(construct.name), form.wrap)}`
/** `ClientOnly` renders its fallback on the server, its children on the client. */
const routerStub = (env: 'client' | 'server') => ({
[router]: {
+ Link: () => 'link',
ClientOnly: (props: { children?: string; fallback?: string }) =>
(env === 'server' ? props.fallback : props.children) ?? '',
},
@@ -246,8 +247,12 @@ export const Page = () => (
expect(client.Page()).toBe('
')
}
- test('through a renamed import', async () => {
- await checkBoundary(`import { ClientOnly as CO } from '${router}'`, 'CO')
+ test.each([
+ `import { ClientOnly as CO } from '${router}'`,
+ `import { Link, ClientOnly as CO } from '${router}'`,
+ `import{ClientOnly as CO}from '${router}'`,
+ ])('through a renamed import: %s', async (imports) => {
+ await checkBoundary(imports, 'CO')
})
// Bug: `` is detected by the `
}
},
)
+
+// Devtools and error reporters show the original source from the map. The
+// client is left out: whether client maps should carry stripped server code
+// (D1) is out of scope.
+test('ssr: the map carries the original source', async () => {
+ const code = `import { createServerFn } from '@tanstack/react-start'
+export const fn = createServerFn().handler(async () => 'x')
+`
+ const result = await compileFor('ssr', code)
+ expect(result.map?.sourcesContent).toEqual([code])
+})
From 59525c1ab5f6198ba6c72b477fd4e9305af7c44c Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:47:24 +0200
Subject: [PATCH 0264/1425] test(start-plugin-core): pin browser-only provider
exports and re-exported transform targets
sc-core P14: two rows on the pin "client: a createServerOnlyFn called
through $name is compiled": a local alias in a function-local const and
in a top-level block (both flip under the mutants, which fix main).
Pins (REPORT section 6), each verified red for the right reason:
- #15: the provider keeps `export * from './browser'` (the browser-only
module is evaluated by a server fn call) and `export default Page` with
the browser-only state it reads (`window is not defined`).
- #17: an external transform does not reach a call imported through a
project re-export when the call is nested, or when the module has no
built-in factory (the transform's argument is missing).
Kills: spc:2368, spc:2372 (pin rows flip)
Co-Authored-By: Claude Opus 5.5
---
.../tests/known-bugs-start-compiler.test.ts | 111 ++++++++++++++++++
1 file changed, 111 insertions(+)
diff --git a/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts b/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
index 4df75418044..2c988ed69a0 100644
--- a/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
+++ b/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
@@ -10,6 +10,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin'
import { buildStart, startBundlers } from './build-harness'
import {
+ callProvider,
callReportedServerFns,
clientOnlyError,
compileAll,
@@ -674,6 +675,25 @@ const so = createServerOnlyFn
export function getRead() { return so(() => db.secret()) }
export const read = () => getRead()()`,
},
+ {
+ name: 'a local alias in a function-local const',
+ code: `import { createServerOnlyFn } from '@tanstack/react-start'
+import { db } from './db.server'
+const so = createServerOnlyFn
+export function getRead() { const fn = so(() => db.secret()); return fn }
+export const read = () => getRead()()`,
+ },
+ {
+ name: 'a local alias in a top-level block',
+ code: `import { createServerOnlyFn } from '@tanstack/react-start'
+import { db } from './db.server'
+const so = createServerOnlyFn
+export let read: () => unknown
+{
+ const fn = so(() => db.secret())
+ read = () => fn()
+}`,
+ },
])(
'client: a createServerOnlyFn called through $name is compiled',
async ({ code }) => {
@@ -942,6 +962,44 @@ export const fn = createServerFn().handler(createServerOnlyFn(async () => db.x()
},
)
+ // Bug: the provider drops the module's exports so that only what the
+ // handlers need is left (compiler.test.ts "removes browser-only
+ // dependencies of stripped exports"), but it keeps `export * from` and an
+ // `export default` of an identifier, with the code that export reads.
+ // Impact: a server fn call evaluates the browser-only code of its route
+ // module on the server, and fails there (`window is not defined`) before
+ // the handler runs: a common component-file shape (`export default Page`).
+ // Source: handleCreateServerFn.ts safeRemoveExports (no case for
+ // ExportAllDeclaration; a default-exported identifier is kept as is);
+ // mutation-testing round 3 (sc-handlers A3, A4).
+ test.fails.each([
+ {
+ name: 'export * from a browser-only module',
+ code: `export * from './browser'`,
+ },
+ {
+ name: 'export default of a component reading browser-only state',
+ code: `const browserState = { window }
+function Page() { return browserState }
+export default Page`,
+ },
+ ])(
+ 'a provider call does not evaluate $name',
+ async ({ code }) => {
+ const server = await compileServer(
+ `${head}export const getMessage = createServerFn().handler(() => 'server message')
+${code}`,
+ { id: '/test/src/routes/message.tsx' },
+ )
+ expect(
+ await callProvider(server, 'getMessage', {
+ './browser': `throw new Error('browser-only module evaluated on the server')
+export const Widget = 1`,
+ }),
+ ).toBe('server message')
+ },
+ )
+
// Bug: the provider module (`?tss-serverfn-split`) keeps the `'use client'`
// directive of its source module.
// Impact: in RSC builds every export of the provider becomes a client
@@ -1296,6 +1354,59 @@ export const rendered = runtime[renderThing]( )`
expect(rendered).toEqual(['other', 'card'])
},
)
+
+ // Bug: a configured transform's export imported through a project module
+ // that re-exports it (`src/rt.ts`: `export { renderThing } from
+ // '@example/runtime'`) is only resolved for a top-level call in a module
+ // that also uses a built-in Start factory (compiler.test.ts "runs
+ // configured transforms on a top-level call imported through a project
+ // re-export, next to a built-in factory"). A nested call, or a module
+ // without a built-in factory, is left untransformed.
+ // Impact: with the RSC CSS transform (react-start-rsc), a server component
+ // rendered through the app's own re-export gets no CSS (its styles never
+ // load). Declaring external transforms import-bound instead (and dropping
+ // their lookup configurations) is the alternative to a fix.
+ // Source: compiler.ts: external direct-call candidates are only the calls
+ // of the library's own imports (`getExternalDirectCallCandidates`); other
+ // calls are only resolved as top-level declarator initializers
+ // (`isTopLevelDirectCallCandidate`), and only when the module has a
+ // built-in direct-call kind (`checkDirectCalls`). Mutation-testing round 3
+ // (sc-handlers E2/V2).
+ test.fails.each([
+ {
+ name: 'a nested call next to a built-in factory',
+ code: `import { renderThing } from './rt'
+import { createServerOnlyFn } from '@tanstack/react-start'
+export const only = createServerOnlyFn(() => 1)
+export function rendered() { return renderThing('card') }`,
+ },
+ {
+ name: 'a top-level call in a module without a built-in factory',
+ code: `import { renderThing } from './rt'
+const rendering = renderThing('card')
+export function rendered() { return rendering }`,
+ },
+ ])(
+ 'a transform reaches a call imported through a project re-export: $name',
+ async ({ code }) => {
+ const { compile } = createStartCompiler({
+ env: 'server',
+ compilerTransforms: [renderThingTransform],
+ files: {
+ '/test/src/rt.ts': `export { renderThing } from '@example/runtime'`,
+ },
+ host: {
+ resolveId: async (id) =>
+ id.startsWith('./') ? `/test/src/${id.slice(2)}.ts` : id,
+ },
+ })
+ const { rendered } = await evaluateModule(
+ (await compile(code, '/test/src/routes/card.tsx')) ?? code,
+ { './rt': runtimeStubs['@example/runtime'] },
+ )
+ expect(rendered()).toEqual(['card', { injected: 'loaded' }])
+ },
+ )
})
describe('Vue single-file components', () => {
From eaaf5df979581443eecb1de1e5b214303a6fe015 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:50:26 +0200
Subject: [PATCH 0265/1425] test(start-plugin-core): format the round-3
compiler core tests
`pnpm format` on the files this branch touched.
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/compiler.test.ts | 19 ++++++--------
.../createServerFn/createServerFn.test.ts | 3 +--
.../exports-and-directives.test.ts | 3 ++-
.../tests/known-bugs-start-compiler.test.ts | 25 ++++++++-----------
.../tests/start-compiler-determinism.test.ts | 10 +++++---
5 files changed, 28 insertions(+), 32 deletions(-)
diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts
index da4d480cabc..0bd83281fb2 100644
--- a/packages/start-plugin-core/tests/compiler.test.ts
+++ b/packages/start-plugin-core/tests/compiler.test.ts
@@ -184,7 +184,8 @@ describe('compiler handles external import transforms', () => {
})
test('compiles IIFEs and curried calls in a module calling a configured transform', async () => {
- const result = await compileWithTransform(`import { renderThing } from '@example/runtime'
+ const result =
+ await compileWithTransform(`import { renderThing } from '@example/runtime'
const Card = () => 'card'
export const value = (() => 1)()
export const curried = ((a: number) => (b: number) => a + b)(1)(2)
@@ -424,18 +425,14 @@ export const so = createServerOnlyFn(() => 'server-body')`,
])(
'leaves unrelated virtual runtime imports alone: $name',
async ({ code }) => {
- const result = await compileWithTransform(
- code,
- '/test/src/runtime.tsx',
- {
- host: {
- resolveId: async (id) => `\0${id}`,
- loadModule: async (id) => {
- throw new Error(`cannot load ${id}`)
- },
+ const result = await compileWithTransform(code, '/test/src/runtime.tsx', {
+ host: {
+ resolveId: async (id) => `\0${id}`,
+ loadModule: async (id) => {
+ throw new Error(`cannot load ${id}`)
},
},
- )
+ })
expect(result).not.toBeNull()
expect(result).not.toContain('loadThing')
diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
index 58384737113..f3bca9dc679 100644
--- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
@@ -336,8 +336,7 @@ describe('server functions discovered in caller modules', () => {
envName: env === 'client' ? 'client' : 'rsc',
lookupKinds: getLookupKindsForEnv(env),
lookupConfigurations: getLookupConfigurationsForEnv(env, 'react'),
- onServerFnsById: (serverFns) =>
- mergeServerFnsById(reported, serverFns),
+ onServerFnsById: (serverFns) => mergeServerFnsById(reported, serverFns),
})
await caller.compile({ code, id: '/test/src/caller.ts' })
}
diff --git a/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts b/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts
index d482fd69435..64d4003fc83 100644
--- a/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/exports-and-directives.test.ts
@@ -283,7 +283,8 @@ export { fn }`
test.each(['fn', '_fn', '$fn', 'ñandú'])(
'three redeclared var server fns named %s get distinct ids the provider serves',
async (name) => {
- const compiled = await compileAll(`${head}var ${name} = createServerFn().handler(async () => 1)
+ const compiled =
+ await compileAll(`${head}var ${name} = createServerFn().handler(async () => 1)
var ${name} = createServerFn().handler(async () => 2)
var ${name} = createServerFn().handler(async () => 3)
export { ${name} }`)
diff --git a/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts b/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
index 2c988ed69a0..f8c01d5940f 100644
--- a/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
+++ b/packages/start-plugin-core/tests/known-bugs-start-compiler.test.ts
@@ -983,22 +983,19 @@ export const fn = createServerFn().handler(createServerOnlyFn(async () => db.x()
function Page() { return browserState }
export default Page`,
},
- ])(
- 'a provider call does not evaluate $name',
- async ({ code }) => {
- const server = await compileServer(
- `${head}export const getMessage = createServerFn().handler(() => 'server message')
+ ])('a provider call does not evaluate $name', async ({ code }) => {
+ const server = await compileServer(
+ `${head}export const getMessage = createServerFn().handler(() => 'server message')
${code}`,
- { id: '/test/src/routes/message.tsx' },
- )
- expect(
- await callProvider(server, 'getMessage', {
- './browser': `throw new Error('browser-only module evaluated on the server')
+ { id: '/test/src/routes/message.tsx' },
+ )
+ expect(
+ await callProvider(server, 'getMessage', {
+ './browser': `throw new Error('browser-only module evaluated on the server')
export const Widget = 1`,
- }),
- ).toBe('server message')
- },
- )
+ }),
+ ).toBe('server message')
+ })
// Bug: the provider module (`?tss-serverfn-split`) keeps the `'use client'`
// directive of its source module.
diff --git a/packages/start-plugin-core/tests/start-compiler-determinism.test.ts b/packages/start-plugin-core/tests/start-compiler-determinism.test.ts
index 5fe01e8a9cf..9b558df2e15 100644
--- a/packages/start-plugin-core/tests/start-compiler-determinism.test.ts
+++ b/packages/start-plugin-core/tests/start-compiler-determinism.test.ts
@@ -180,10 +180,12 @@ export const a = createServerFn().handler(async () => 'from a')
export const b = createServerFn().handler(async () => 'from b')
export const c = createServerFn().handler(async () => 'from c')`
const client = await evaluateModule(
- (await compilerFor('client', '/app', registry, generateFunctionId)(
- code,
- id,
- ))!,
+ (await compilerFor(
+ 'client',
+ '/app',
+ registry,
+ generateFunctionId,
+ )(code, id))!,
)
const rpcs: Record = {
a: client.a.rpc.client,
From d2b854c7309729fd0459b68ce399a618e16145c2 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:22:58 +0200
Subject: [PATCH 0266/1425] test(start-plugin-core): run the real Rsbuild
metadata loader in tests
Harness gap H3: tests/rsbuild-test-loaders.ts ran a hand-written copy of
src/rsbuild/start-compiler-metadata-loader.ts; the app loader now
delegates to the TypeScript source through a global, like the
import-protection test loader.
Kills: spc:8047, 8049, 8050 (existing Rsbuild builds of
start-build-options.test.ts). spc:8048 survives (the compiler host always
sets the build-info callback before the loader runs).
Co-Authored-By: Claude Opus 5.5
---
.../tests/rsbuild-test-loaders.ts | 42 +++++++++----------
1 file changed, 20 insertions(+), 22 deletions(-)
diff --git a/packages/start-plugin-core/tests/rsbuild-test-loaders.ts b/packages/start-plugin-core/tests/rsbuild-test-loaders.ts
index badf942560e..9b9c6a5226d 100644
--- a/packages/start-plugin-core/tests/rsbuild-test-loaders.ts
+++ b/packages/start-plugin-core/tests/rsbuild-test-loaders.ts
@@ -1,34 +1,27 @@
/**
* Start's Rsbuild integration registers its loaders by the path of their
* compiled files, which do not exist when the tests run the TypeScript
- * sources, and Rspack loads loaders from disk. The test apps get equivalent
- * JavaScript loaders (`testLoaderFiles`), and `useTestLoaders` points the
- * Rspack rules at them.
+ * sources, and Rspack loads loaders from disk. The test apps get JavaScript
+ * loaders (`testLoaderFiles`) that run the TypeScript loaders the tests
+ * import, and `useTestLoaders` points the Rspack rules at them.
*/
import path from 'node:path'
import importProtectionLoader from '../src/rsbuild/import-protection-loader'
+import metadataLoader from '../src/rsbuild/start-compiler-metadata-loader'
import type { RsbuildPlugin } from '@rsbuild/core'
-/** Same as start-compiler-metadata-loader.ts. */
-const metadataLoader = `module.exports = function (source, map) {
- const { metadataById } = this.getOptions()
- const setBuildInfo = this['tanstack.start.setServerFnBuildInfo']
- if (setBuildInfo) setBuildInfo(metadataById.get(this.resource) ?? null)
- this.callback(null, source, map)
-}
-`
-
/**
- * The import-protection loader shares its state with the plugin through its
- * options; the app's loader runs the TypeScript loader the tests import.
+ * An app loader running `loader` (the TypeScript loader the tests import,
+ * which shares its state with the plugin through its options) through a
+ * global.
*/
-const importProtectionLoaderGlobal = '__tanstackStartTestImportProtectionLoader'
-;(globalThis as Record)[importProtectionLoaderGlobal] =
- importProtectionLoader
-const delegatingImportProtectionLoader = `module.exports = function (...args) {
- return globalThis.${importProtectionLoaderGlobal}.apply(this, args)
+function delegatingLoader(global: string, loader: unknown) {
+ ;(globalThis as Record)[global] = loader
+ return `module.exports = function (...args) {
+ return globalThis.${global}.apply(this, args)
}
`
+}
/** Loader file of the app in place of each compiled loader, by name. */
const loaderFileNames = {
@@ -38,9 +31,14 @@ const loaderFileNames = {
/** The loader files an app needs (`useTestLoaders` points the rules at them). */
export const testLoaderFiles: Record = {
- [loaderFileNames['start-compiler-metadata-loader']]: metadataLoader,
- [loaderFileNames['import-protection-loader']]:
- delegatingImportProtectionLoader,
+ [loaderFileNames['start-compiler-metadata-loader']]: delegatingLoader(
+ '__tanstackStartTestMetadataLoader',
+ metadataLoader,
+ ),
+ [loaderFileNames['import-protection-loader']]: delegatingLoader(
+ '__tanstackStartTestImportProtectionLoader',
+ importProtectionLoader,
+ ),
}
/**
From 84273eb31de6f48449909945ac5c2cc5624d24ae Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:24:13 +0200
Subject: [PATCH 0267/1425] test(start-plugin-core): build a server fn whose
builder comes from another module
sc-hosts 1: the shared env-separation build gets a server fn built with
a builder imported from src/base.ts (its handler code stays out of the
client and runs in SSR), and every client RPC id runs through the SSR
server fn resolver (origin client), on Vite and Rsbuild.
Kills: spc:6933.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-env-separation.test.ts | 38 ++++++++++++++++++-
1 file changed, 36 insertions(+), 2 deletions(-)
diff --git a/packages/start-plugin-core/tests/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
index babd0673db2..de3424ea787 100644
--- a/packages/start-plugin-core/tests/start-build-env-separation.test.ts
+++ b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
@@ -139,6 +139,7 @@ export const ${name} = (..._args: Array) => '${marker}'
const files = {
...routerRuntimeFiles,
...dependency('handlerDep', 'MARK_HANDLER_DEP'),
+ ...dependency('baseHandlerDep', 'MARK_BASE_HANDLER_DEP'),
...dependency('middlewareServer', 'MARK_MIDDLEWARE_SERVER'),
...dependency('middlewareValidator', 'MARK_MIDDLEWARE_VALIDATOR'),
...dependency('isomorphicServer', 'MARK_ISOMORPHIC_SERVER'),
@@ -155,19 +156,25 @@ import { middlewareValidator } from './deps/middlewareValidator'
export const fnMiddleware = createMiddleware({ type: 'function' })
.validator((data: unknown) => middlewareValidator(data))
.server(({ next }) => next({ context: { marker: middlewareServer() } }))
+`,
+ 'src/base.ts': `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn()
`,
'src/fns.ts': `import {
createIsomorphicFn,
createServerFn,
createServerOnlyFn,
} from '@tanstack/react-start'
+import { base } from './base'
import { fnMiddleware } from './middleware'
import { handlerDep } from './deps/handlerDep'
+import { baseHandlerDep } from './deps/baseHandlerDep'
import { isomorphicServer } from './deps/isomorphicServer'
import { serverOnlyDep } from './deps/serverOnlyDep'
export const fn = createServerFn()
.middleware([fnMiddleware])
.handler(async () => handlerDep())
+export const fromBase = base.handler(async () => baseHandlerDep())
export const isomorphic = createIsomorphicFn()
.server(() => isomorphicServer())
.client(() => 'MARK_ISOMORPHIC_CLIENT')
@@ -207,10 +214,14 @@ export const Route = createFileRoute('/api')({
'src/router.ts': `import { routeTree } from './routeTree.gen'
export const getRouter = () => ({ routeTree })
`,
- 'src/entry.ts': `export { fn, isomorphic, serverOnly } from './fns'
+ 'src/entry.ts': `export { fn, fromBase, isomorphic, serverOnly } from './fns'
export { fnMiddleware } from './middleware'
export { startInstance } from './start'
export { getRouter } from './router'
+`,
+ // The server-fn HTTP handler calls server fns through the resolver.
+ 'src/ssr-entry.ts': `export * from './entry'
+export { getServerFnById } from '#tanstack-start-server-fn-resolver'
`,
}
@@ -229,7 +240,7 @@ const next = (options) => options
build = await buildStart({
bundler,
files,
- entry: 'src/entry.ts',
+ entry: { client: 'src/entry.ts', ssr: 'src/ssr-entry.ts' },
registerCleanup: (cleanup) => {
dispose = cleanup
},
@@ -255,6 +266,12 @@ const next = (options) => options
run: `return await entry.fn()`,
expected: 'MARK_HANDLER_DEP',
},
+ {
+ construct: 'a server fn handler of a builder imported from another module',
+ marker: 'MARK_BASE_HANDLER_DEP',
+ run: `return await entry.fromBase()`,
+ expected: 'MARK_BASE_HANDLER_DEP',
+ },
{
construct: 'a function middleware .server()',
marker: 'MARK_MIDDLEWARE_SERVER',
@@ -324,5 +341,22 @@ const next = (options) => options
)
},
)
+
+ // An HTTP call of a client RPC runs the server fn of its id.
+ // Source: mutation round 3 sc-hosts 1 (no Rsbuild build made the
+ // compiler load another module, here the builder's).
+ test('each server fn of the client runs by its id in the SSR build', async () => {
+ const ids = (
+ await build.client.run(
+ `return [entry.fn.functionId, entry.fromBase.functionId]`,
+ )
+ ).result
+ const { result } = await build.ssr.run(`const results = []
+for (const id of ${JSON.stringify(ids)}) {
+ results.push(await (await entry.getServerFnById(id, { origin: 'client' }))({}))
+}
+return results`)
+ expect(result).toEqual(['MARK_HANDLER_DEP', 'MARK_BASE_HANDLER_DEP'])
+ })
})
})
From f9977472581575c9aa0772ed78324712f246f193 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:25:31 +0200
Subject: [PATCH 0268/1425] test(start-plugin-core): Rsbuild dev gives
same-named server fns distinct ids
sc-hosts 2: two modules export a server fn of the same name; the client
bundle of an Rsbuild dev server, run in a bare context, gets two
different RPC ids.
Kills: spc:6764.
Co-Authored-By: Claude Opus 5.5
---
.../tests/rsbuild/dev-server.test.ts | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts b/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts
index 8f74c2d1bcb..01332f8f41e 100644
--- a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts
+++ b/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts
@@ -2,6 +2,7 @@
* The Start compiler on a running Rsbuild dev server: editing a factory
* module rebuilds every client module compiled against it.
*/
+import vm from 'node:vm'
import { describe, expect, test } from 'vitest'
import { startRsbuildDevServer } from '../rsbuild-dev-server'
@@ -70,3 +71,37 @@ console.log(fn1, fn2)
timeout,
)
})
+
+describe('Rsbuild dev: server fn ids', () => {
+ // Server fns are called by id: two server fns sharing an id would run
+ // each other's handler.
+ // Source: mutation round 3 sc-hosts 2.
+ test(
+ 'same-named server fns of two modules get different ids',
+ async () => {
+ const fn = `import { createServerFn } from '@tanstack/react-start'
+export const getData = createServerFn().handler(async () => 'x')
+`
+ const server = await startRsbuildDevServer({
+ files: {
+ 'src/a.ts': fn,
+ 'src/b.ts': fn,
+ 'src/entry.ts': `import { getData as a } from './a'
+import { getData as b } from './b'
+;(globalThis as Record).ids = [a.functionId, b.functionId]
+`,
+ },
+ })
+ // The client bundle (one chunk) runs in a bare context
+ const context = vm.createContext({})
+ context.self = context
+ for (const code of Object.values(await server.outputs())) {
+ vm.runInContext(code, context)
+ }
+ const ids = context.ids as Array
+ expect(ids).toEqual([expect.any(String), expect.any(String)])
+ expect(ids[0]).not.toBe(ids[1])
+ },
+ timeout,
+ )
+})
From 683d62c3ed888561a5e6ad21b6b9b276c32f9799 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:26:24 +0200
Subject: [PATCH 0269/1425] test(start-plugin-core): Vite dev resolves server
fns compiled after the resolver loaded
sc-hosts 3: SSR calls a server fn by id, then imports another module and
calls its server fn by id through the same resolver.
Kills: spc:5946, 5949.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-compiler-dev-server.test.ts | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts b/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts
index 00b3c235c0a..c78e5554aaf 100644
--- a/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts
+++ b/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts
@@ -43,6 +43,38 @@ export const fn = base.handler(async () => db.x())
)
})
+describe('Vite dev: server fns by id', () => {
+ // A dev server compiles modules on demand: the resolver must find server
+ // fns of modules compiled after it first loaded.
+ // Source: mutation round 3 sc-hosts 3.
+ test(
+ 'SSR: a server fn of a module compiled after the resolver first loaded runs by id',
+ async () => {
+ const server = await startDevServer({
+ files: {
+ 'src/first.ts': `import { createServerFn } from '@tanstack/react-start'
+export const first = createServerFn().handler(() => 'MARK_FIRST')
+`,
+ 'src/later.ts': `import { createServerFn } from '@tanstack/react-start'
+export const later = createServerFn().handler(() => 'MARK_LATER')
+`,
+ },
+ })
+ /** Calls the server fn `name` of `url` the way an HTTP call does: by id. */
+ const callById = async (url: string, name: string) => {
+ const fn = (await server.import(url))[name]
+ const { getServerFnById } = await server.import(
+ '#tanstack-start-server-fn-resolver',
+ )
+ return (await getServerFnById(fn.functionId, { origin: 'client' }))({})
+ }
+ expect(await callById('/src/first.ts', 'first')).toBe('MARK_FIRST')
+ expect(await callById('/src/later.ts', 'later')).toBe('MARK_LATER')
+ },
+ timeout,
+ )
+})
+
describe('Vite dev: editing a server fn handler', () => {
// Source: replaces server-fn-resolver-module.test.ts "imports on every call
// by default" and "keeps the imported module when memoizeModules is set",
From 2b2ec243e5892e22c60c65679feb942fbf382ebf Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:30:10 +0200
Subject: [PATCH 0270/1425] test(start-plugin-core): build the start manifest
and the pruned client route tree
start-router A: a Vite and an Rsbuild build whose SSR entry exports
tanstack-start-manifest:v; the manifest preloads each page's component
chunk and links the stylesheet its component imports.
start-router B: the client route tree keeps a server-only route with a
page under it, the root stays a root, and (Vite) the server-only subtree
is pruned. Rsbuild row is a .fails pin of the known parity bug (only the
Vite plugin prunes; same bug as known-bugs-route-options.test.ts).
Kills: spc:3893, 3895, 3896, 3897, 3900, 3901, 3903, 3912, 3926.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-manifest.test.ts | 148 ++++++++++++++++++
1 file changed, 148 insertions(+)
create mode 100644 packages/start-plugin-core/tests/start-build-manifest.test.ts
diff --git a/packages/start-plugin-core/tests/start-build-manifest.test.ts b/packages/start-plugin-core/tests/start-build-manifest.test.ts
new file mode 100644
index 00000000000..65d7fb2c725
--- /dev/null
+++ b/packages/start-plugin-core/tests/start-build-manifest.test.ts
@@ -0,0 +1,148 @@
+/**
+ * What Start derives from the route tree of a real build, through Start's
+ * Vite and Rsbuild plugins (build-harness.ts): the server's start manifest
+ * (`tanstack-start-manifest:v`, which tells SSR what each route preloads and
+ * links), and the client route tree, which leaves out server-only routes.
+ */
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import { afterAll, beforeAll, describe, expect, test } from 'vitest'
+import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness'
+import type { StartBuild } from './build-harness'
+
+const timeout = 60_000
+
+const head = `import { createFileRoute } from '@tanstack/react-router'\n`
+const handlers = `server: { handlers: { GET: () => new Response('x') } }`
+
+/**
+ * Pages (`/`, and `/users/profile`, whose component imports a stylesheet,
+ * under the server-only `/users`) and a server-only subtree (`/api`,
+ * `/api/inner`).
+ */
+const files = {
+ ...routerRuntimeFiles,
+ 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router'
+export const Route = createRootRoute()
+`,
+ 'src/routes/index.tsx': `${head}export const Route = createFileRoute('/')({ component: () => 'MARK_INDEX' })\n`,
+ 'src/routes/users.tsx': `${head}export const Route = createFileRoute('/users')({ ${handlers} })\n`,
+ 'src/routes/users/profile.tsx': `${head}import { Profile } from '../../components/Profile'
+export const Route = createFileRoute('/users/profile')({ component: Profile })
+`,
+ 'src/components/Profile.tsx': `import './profile.css'
+export const Profile = () => 'MARK_PROFILE'
+`,
+ 'src/components/profile.css': `.profile::after { content: 'MARK_PROFILE_CSS'; }\n`,
+ 'src/routes/api.tsx': `${head}export const Route = createFileRoute('/api')({ ${handlers} })\n`,
+ 'src/routes/api/inner.tsx': `${head}export const Route = createFileRoute('/api/inner')({ ${handlers} })\n`,
+ 'src/router.ts': `import { routeTree } from './routeTree.gen'
+export const getRouter = () => ({ routeTree })
+`,
+ // Start's server entry renders with the start manifest
+ 'src/ssr-entry.ts': `export { getRouter } from './router'
+export { tsrStartManifest } from 'tanstack-start-manifest:v'
+`,
+}
+
+/**
+ * Script returning the route tree of the build: whether the root is a root
+ * (the generated tree gives it no parent) and the paths of its routes,
+ * depth first.
+ */
+const routeTree = `const tree = entry.getRouter().routeTree
+const paths = (route) => Object.values(route.children ?? {}).flatMap((child) => [
+ child.options.path,
+ ...paths(child),
+])
+return { rootIsRoot: !('getParentRoute' in tree.options), paths: paths(tree) }`
+
+describe.each(startBundlers)('%s build', (bundler) => {
+ let build: StartBuild
+ let dispose: (() => Promise) | undefined
+ beforeAll(async () => {
+ build = await buildStart({
+ bundler,
+ files,
+ entry: { client: 'src/router.ts', ssr: 'src/ssr-entry.ts' },
+ registerCleanup: (cleanup) => {
+ dispose = cleanup
+ },
+ })
+ }, timeout)
+ afterAll(() => dispose?.())
+
+ /** The start manifest the SSR build renders with. */
+ async function startManifest() {
+ return (await build.ssr.run(`return entry.tsrStartManifest()`))
+ .result as {
+ routes: Record<
+ string,
+ { preloads?: Array; css?: Array }
+ >
+ }
+ }
+
+ // Source: mutation round 3 start-router A (no build test loaded the start
+ // manifest).
+ test("the start manifest preloads each page's component chunk", async () => {
+ const { routes } = await startManifest()
+ /** The client chunk of a component, as a URL. */
+ const chunkOf = (marker: string) => {
+ const [chunk] = build.client.locate(marker).lazy
+ return `/${chunk}`
+ }
+ expect({
+ index: routes['/']?.preloads,
+ profile: routes['/users/profile']?.preloads,
+ }).toEqual({
+ index: expect.arrayContaining([chunkOf('MARK_INDEX')]),
+ profile: expect.arrayContaining([chunkOf('MARK_PROFILE')]),
+ })
+ })
+
+ test("the start manifest links the stylesheet of a page's component", async () => {
+ const { routes } = await startManifest()
+ const stylesheets = await Promise.all(
+ (routes['/users/profile']?.css ?? []).map((link) =>
+ readFile(
+ path.join(
+ build.client.outDir,
+ typeof link === 'string' ? link : link.href,
+ ),
+ 'utf8',
+ ),
+ ),
+ )
+ expect(
+ stylesheets.filter((css) => css.includes('MARK_PROFILE_CSS')),
+ ).toHaveLength(1)
+ })
+
+ // Source: mutation round 3 start-router B.
+ test('a server-only route with a page under it stays in the client route tree, under the root', async () => {
+ const { result } = await build.client.run(routeTree)
+ expect(result).toEqual({
+ rootIsRoot: true,
+ paths: expect.arrayContaining(['/', '/users', '/profile']),
+ })
+ })
+
+ // Vite: Start prunes server-only subtrees from the client route tree.
+ // Rsbuild: same bug as known-bugs-route-options.test.ts "a route with only
+ // server handlers is not in the client route tree" (only the Vite plugin
+ // prunes).
+ ;(bundler === 'vite' ? test : test.fails)(
+ 'the client route tree is the SSR route tree without the server-only subtree',
+ async () => {
+ const ssr = (await build.ssr.run(routeTree)).result as {
+ paths: Array
+ }
+ expect(ssr.paths).toEqual(expect.arrayContaining(['/api', '/inner']))
+ expect((await build.client.run(routeTree)).result).toEqual({
+ rootIsRoot: true,
+ paths: ssr.paths.filter((path) => path !== '/api' && path !== '/inner'),
+ })
+ },
+ )
+})
From c88a861b446481591a6960948ab7a24266a9efa0 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:32:38 +0200
Subject: [PATCH 0271/1425] test(start-plugin-core): prerender.enabled
prerenders every static page
start-router C: a Vite and an Rsbuild build with
prerender: { enabled: true, crawlLinks: false } request exactly the
static pages (/, /about), not the dynamic page or the server-only
route, from a stand-in request handler (Start's own prerender step runs).
Kills: spc:6032, 6033, 6035, 6036, 6037, 8020, 8022, 8023, 8025, 8026,
3882.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-prerender.test.ts | 97 +++++++++++++++++++
1 file changed, 97 insertions(+)
create mode 100644 packages/start-plugin-core/tests/start-build-prerender.test.ts
diff --git a/packages/start-plugin-core/tests/start-build-prerender.test.ts b/packages/start-plugin-core/tests/start-build-prerender.test.ts
new file mode 100644
index 00000000000..57e9a36f6d0
--- /dev/null
+++ b/packages/start-plugin-core/tests/start-build-prerender.test.ts
@@ -0,0 +1,97 @@
+/**
+ * Start's prerender step after a real build, through Start's Vite and
+ * Rsbuild plugins (build-harness.ts): which pages it requests. The app's
+ * server is a stand-in (`request`) that records the requested paths and
+ * answers with a page; Start's own prerender step (`src/prerender.ts`) runs
+ * with it.
+ */
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness'
+
+const timeout = 60_000
+
+/** Paths Start's prerender step requested. */
+const requested: Array = []
+const request = async (path: string) => {
+ requested.push(path)
+ return new Response('page', {
+ headers: { 'content-type': 'text/html' },
+ })
+}
+
+// Rsbuild's prerender step requests the pages from the server bundle's
+// request handler; the stand-in answers instead.
+vi.mock('../src/prerender', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ prerender: (options: Parameters[0]) =>
+ actual.prerender({
+ ...options,
+ handler: { ...options.handler, request, close: undefined },
+ }),
+ }
+})
+// Vite's prerender step starts a preview server of the app first.
+vi.mock('../src/vite/prerender', async () => {
+ const { prerender } = await import('../src/prerender')
+ return {
+ prerenderWithVite: ({
+ startConfig,
+ builder,
+ }: {
+ startConfig: Parameters[0]['startConfig']
+ builder: import('vite').ViteBuilder
+ }) =>
+ prerender({
+ startConfig,
+ handler: {
+ getClientOutputDirectory: () =>
+ builder.environments.client!.config.build.outDir,
+ request,
+ },
+ }),
+ }
+})
+
+beforeEach(() => {
+ requested.length = 0
+ globalThis.TSS_PRERENDABLE_PATHS = undefined
+})
+
+const head = `import { createFileRoute } from '@tanstack/react-router'\n`
+/** Static pages (`/`, `/about`), a dynamic page and a server-only route. */
+const files = {
+ ...routerRuntimeFiles,
+ 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router'
+export const Route = createRootRoute()
+`,
+ 'src/routes/index.tsx': `${head}export const Route = createFileRoute('/')({ component: () => null })\n`,
+ 'src/routes/about.tsx': `${head}export const Route = createFileRoute('/about')({ component: () => null })\n`,
+ 'src/routes/posts.$id.tsx': `${head}export const Route = createFileRoute('/posts/$id')({ component: () => null })\n`,
+ 'src/routes/api.tsx': `${head}export const Route = createFileRoute('/api')({ server: { handlers: {} } })\n`,
+ 'src/router.ts': `import { routeTree } from './routeTree.gen'
+export const getRouter = () => ({ routeTree })
+`,
+ 'src/entry.ts': `export { getRouter } from './router'\n`,
+}
+
+describe.each(startBundlers)('%s build', (bundler) => {
+ // Source: mutation round 3 start-router C (only `/` was prerendered,
+ // silently, when Start did not discover the static pages).
+ test(
+ 'prerender.enabled prerenders every static page of the route tree',
+ async () => {
+ await buildStart({
+ bundler,
+ files,
+ entry: 'src/entry.ts',
+ start: { prerender: { enabled: true, crawlLinks: false } },
+ })
+ expect(
+ requested.map((page) => page.replace(/(.)\/$/, '$1')).sort(),
+ ).toEqual(['/', '/about'])
+ },
+ timeout,
+ )
+})
From 74394b4bf78883a16e3d10bdbd13a2f415f86339 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:34:40 +0200
Subject: [PATCH 0272/1425] test(start-plugin-core): prerender only the static
pages of an app
start-router E: the prerendered-pages control runs Start's prerender
plugin on a whole app (index, nested, layout, group, lazy-only, dynamic,
server-only and cast routes) and gets exactly the static pages.
New pin (known bug class): a page whose options are wrapped in a cast
(as never, as any, satisfies) is not prerendered; the route generator
records no option keys for them. Verified red: the page is missing from
the prerendered paths.
Kills: spc:3882, 3887.
Co-Authored-By: Claude Opus 5.5
---
.../tests/known-bugs-route-options.test.ts | 83 +++++++++++++++----
1 file changed, 69 insertions(+), 14 deletions(-)
diff --git a/packages/start-plugin-core/tests/known-bugs-route-options.test.ts b/packages/start-plugin-core/tests/known-bugs-route-options.test.ts
index 4ad2658b896..f2a8c40c0ad 100644
--- a/packages/start-plugin-core/tests/known-bugs-route-options.test.ts
+++ b/packages/start-plugin-core/tests/known-bugs-route-options.test.ts
@@ -186,21 +186,25 @@ describe('prerendered pages', () => {
/**
* Runs the route generator with Start's prerender plugin on an app with a
- * root route and `routes/moved.tsx` holding `source`. Returns whether
- * `/moved` is in the paths Start prerenders. (The build harness has no
- * preview server, which Start's prerender step needs, so this reads the
- * pages the prerender plugin hands to it.)
+ * root route and `routes` (route files by path relative to the routes
+ * directory). Returns the paths Start prerenders. (This reads the pages the
+ * prerender plugin hands to Start's prerender step, without a build;
+ * start-build-prerender.test.ts runs the step.)
*/
- async function prerendered(source: string) {
+ async function prerenderedPaths(routes: Record) {
const root = await mkdtemp(path.join(tmpdir(), 'tss-route-options-'))
try {
- await mkdir(path.join(root, 'routes'))
- await writeFile(
- path.join(root, 'routes/__root.tsx'),
- `import { createRootRoute } from '@tanstack/react-router'
+ const files: Record = {
+ '__root.tsx': `import { createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute()`,
- )
- await writeFile(path.join(root, 'routes/moved.tsx'), source)
+ ...routes,
+ }
+ for (const [file, code] of Object.entries(files)) {
+ await mkdir(path.dirname(path.join(root, 'routes', file)), {
+ recursive: true,
+ })
+ await writeFile(path.join(root, 'routes', file), code)
+ }
const config = getConfig({
disableLogging: true,
routesDirectory: path.join(root, 'routes'),
@@ -211,14 +215,49 @@ export const Route = createRootRoute()`,
plugins: [prerenderRoutesPlugin()],
})
await new Generator({ config, root }).run()
- return (globalThis.TSS_PRERENDABLE_PATHS ?? []).some(
- (page) => page.path === '/moved',
- )
+ return (globalThis.TSS_PRERENDABLE_PATHS ?? []).map((page) => page.path)
} finally {
await rm(root, { recursive: true, force: true })
}
}
+ /** Whether `/moved` (`routes/moved.tsx` holding `source`) is prerendered. */
+ async function prerendered(source: string) {
+ return (await prerenderedPaths({ 'moved.tsx': source })).includes('/moved')
+ }
+
+ const page = (id: string) =>
+ `${routeHead}export const Route = createFileRoute('${id}')({ component: Page })\n`
+
+ // Control for the pins below: Start prerenders the static pages, not the
+ // dynamic ones, the layouts or the server-only routes.
+ // Source: mutation round 3 start-router E.
+ test('the static pages of an app are prerendered', async () => {
+ const paths = await prerenderedPaths({
+ 'index.tsx': page('/'),
+ 'about.tsx': page('/about'),
+ 'posts.tsx': page('/posts'),
+ 'posts.$id.tsx': page('/posts/$id'),
+ '_layout.tsx': page('/_layout'),
+ '_layout/inside.tsx': page('/_layout/inside'),
+ '(group)/grouped.tsx': page('/(group)/grouped'),
+ 'lazy-only.lazy.tsx': `import { createLazyFileRoute } from '@tanstack/react-router'
+export const Route = createLazyFileRoute('/lazy-only')({ component: () => null })
+`,
+ 'api.tsx': `${routeHead}export const Route = createFileRoute('/api')({ server: { handlers: {} } })\n`,
+ // Pinned below
+ 'cast.tsx': `${routeHead}export const Route = createFileRoute('/cast')({ component: Page } as never)\n`,
+ })
+ expect(paths.filter((path) => path !== '/cast').sort()).toEqual([
+ '/',
+ '/about',
+ '/grouped',
+ '/inside',
+ '/lazy-only',
+ '/posts',
+ ])
+ })
+
// Control for the pins below (same harness).
test.each([
{
@@ -252,4 +291,20 @@ export const Route = createRootRoute()`,
).toBe(true)
},
)
+
+ // Bug: same root cause: the route generator records no option keys of
+ // options wrapped in a cast, and Start's prerender plugin skips the page.
+ // Impact: the page is not prerendered.
+ // Source: lost-cases-campaign item 1 (route options wrapped in casts in
+ // real repos).
+ test.fails.each(casts)(
+ 'a page whose options are wrapped in $name is prerendered',
+ async ({ wrap }) => {
+ expect(
+ await prerendered(
+ `${routeHead}export const Route = createFileRoute('/moved')(${wrap('{ component: Page }')})`,
+ ),
+ ).toBe(true)
+ },
+ )
})
From 901a56df3fd4f385f66d9a1e61cc33bec18078ea Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:39:32 +0200
Subject: [PATCH 0273/1425] test(start-plugin-core): type-check the Register
types of the generated route tree
start-router F: Vite and Rsbuild builds with and without src/start.ts;
the SSR route tree keeps the user's router.routeTreeFileFooter, and
ts.createProgram on routeTree.gen.ts plus a check file proves the
Register interface carries ssr, the app's router and (with src/start.ts
only) the app's Start config, with @ts-expect-error negatives.
Kills: spc:3941, 3942, 3944, 3946-3952, 3955, 3957, 3958, 3961, 3962,
3965, 3967, 3968, 3970, 3976-3978, 3980, 3984-3986, 3988, 5993.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-options.test.ts | 124 ++++++++++++++++++
1 file changed, 124 insertions(+)
diff --git a/packages/start-plugin-core/tests/start-build-options.test.ts b/packages/start-plugin-core/tests/start-build-options.test.ts
index bdc18ff1d88..0c71dd82def 100644
--- a/packages/start-plugin-core/tests/start-build-options.test.ts
+++ b/packages/start-plugin-core/tests/start-build-options.test.ts
@@ -2,6 +2,9 @@
* Documented Start plugin options have their effect in real builds, through
* Start's Vite and Rsbuild plugins (build-harness.ts).
*/
+import { writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import ts from 'typescript'
import { describe, expect, test } from 'vitest'
import {
buildStart,
@@ -40,6 +43,78 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver'
`,
}
+/** Type declarations of the stand-in packages. */
+const typeFiles = {
+ 'node_modules/@tanstack/react-start/index.d.ts': `export interface Register {}
+export declare function createStart(getOptions: () => T): { getOptions: () => T }
+`,
+ 'node_modules/@tanstack/react-router/index.d.ts': `export interface FileRoutesByPath {}
+export interface RouteLike {
+ options: any
+ update(options: any): RouteLike
+ _addFileChildren(children: any): RouteLike
+ _addFileTypes(): RouteLike
+}
+export declare function createRootRoute(options?: any): RouteLike
+export declare function createFileRoute(path: any): (options?: any) => RouteLike
+export declare function lazyRouteComponent(...args: Array): any
+export declare function lazyFn(...args: Array): any
+`,
+}
+
+/** Type-checks `files` (relative to `root`); returns the diagnostics. */
+function typeCheck(root: string, files: Array) {
+ const program = ts.createProgram(
+ files.map((file) => path.join(root, file)),
+ {
+ strict: true,
+ noEmit: true,
+ target: ts.ScriptTarget.ESNext,
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ allowImportingTsExtensions: true,
+ jsx: ts.JsxEmit.Preserve,
+ skipLibCheck: true,
+ types: [],
+ },
+ )
+ return ts.getPreEmitDiagnostics(program).map((diagnostic) => {
+ const where = diagnostic.file
+ ? `${path.relative(root, diagnostic.file.fileName)}:${diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1}`
+ : ''
+ return `${where} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`
+ })
+}
+
+/**
+ * Type assertions on the `Register` interface the route tree declares:
+ * the app's router, and its Start config (`withConfig`) or none. (The
+ * generated route tree is not type-checked itself, `@ts-nocheck`, so a
+ * broken import there would only make these types `any`.)
+ */
+const registerChecks = (
+ withConfig: boolean,
+) => `import type { Register } from '@tanstack/react-start'
+type Equal =
+ (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2
+ ? true
+ : false
+type Expect = T
+export type Checks = [
+ Expect>,
+ Expect>,
+ ${withConfig ? `Expect>,` : `Expect>,`}
+]
+// @ts-expect-error the registered router is the app's
+export const router: Register['router'] = { brand: 'OTHER' }
+${
+ withConfig
+ ? `// @ts-expect-error the registered config is the app's
+export const config: Register['config'] = { brand: 'OTHER' }`
+ : ''
+}
+`
+
describe.each(startBundlers)('%s build', (bundler) => {
// Source: oracle-gaps "Start options never exercised" (serverFns.base).
test.each([
@@ -199,4 +274,53 @@ export const getRouter = () => ({ routeTree })
},
timeout,
)
+
+ // Source: mutation round 3 start-router F (the footer Start adds to the
+ // route tree was never type-checked).
+ test.each([
+ { name: 'with', withStart: true },
+ { name: 'without', withStart: false },
+ ])(
+ 'the route tree registers the router and Start config ($name src/start.ts) for TypeScript, and keeps the router.routeTreeFileFooter',
+ async ({ withStart }) => {
+ const { ssr } = await buildStart({
+ bundler,
+ files: {
+ ...routerRuntimeFiles,
+ ...typeFiles,
+ 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router'
+export const Route = createRootRoute()
+`,
+ 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({ component: () => null })
+`,
+ 'src/router.ts': `import { routeTree } from './routeTree.gen'
+export const getRouter = async () => ({ brand: 'ROUTER' as const, routeTree })
+`,
+ ...(withStart && {
+ 'src/start.ts': `export const startInstance = { getOptions: async () => ({ brand: 'CONFIG' as const }) }
+`,
+ }),
+ 'src/entry.ts': `export { FOOTER } from './routeTree.gen'\n`,
+ },
+ entry: { client: 'src/router.ts', ssr: 'src/entry.ts' },
+ start: {
+ router: {
+ routeTreeFileFooter: [`export const FOOTER = 'MARK_USER_FOOTER'`],
+ },
+ },
+ })
+ expect((await ssr.run(`return entry.FOOTER`)).result).toBe(
+ 'MARK_USER_FOOTER',
+ )
+ await writeFile(
+ path.join(ssr.root, 'src/check.ts'),
+ registerChecks(withStart),
+ )
+ expect(
+ typeCheck(ssr.root, ['src/routeTree.gen.ts', 'src/check.ts']),
+ ).toEqual([])
+ },
+ timeout,
+ )
})
From 35a8d0c700e5a60b9995cf696aa057e7069a8470 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:40:18 +0200
Subject: [PATCH 0274/1425] test(start-plugin-core): build with a
generateFunctionId that returns an id
sc-hosts 6: on Vite and Rsbuild, the client RPC calls the id
serverFns.generateFunctionId returns, and the SSR resolver runs the
handler under it.
Kills: spc:5799, 5801.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-options.test.ts | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/packages/start-plugin-core/tests/start-build-options.test.ts b/packages/start-plugin-core/tests/start-build-options.test.ts
index 0c71dd82def..88a81190414 100644
--- a/packages/start-plugin-core/tests/start-build-options.test.ts
+++ b/packages/start-plugin-core/tests/start-build-options.test.ts
@@ -167,6 +167,31 @@ describe.each(startBundlers)('%s build', (bundler) => {
timeout,
)
+ // Source: mutation round 3 sc-hosts 6 (a generator returning an id was
+ // never built).
+ test(
+ 'serverFns.generateFunctionId returning an id: the client calls that id, which the SSR build resolves',
+ async () => {
+ const { client, ssr } = await buildStart({
+ bundler,
+ files: greetFiles,
+ entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' },
+ start: { serverFns: { generateFunctionId: () => 'my-custom-id' } },
+ })
+ expect((await client.run(`return entry.greet.functionId`)).result).toBe(
+ 'my-custom-id',
+ )
+ expect(
+ (
+ await ssr.run(
+ `return await (await entry.getServerFnById('my-custom-id', { origin: 'client' }))({})`,
+ )
+ ).result,
+ ).toBe('MARK_HANDLER')
+ },
+ timeout,
+ )
+
/**
* What `process.env.NODE_ENV` reads in the SSR build when the server runs
* with `NODE_ENV=staging`.
From 13df9b9f61c6debcb3bcc1e3376a17fea4dda6c9 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:41:19 +0200
Subject: [PATCH 0275/1425] test(start-plugin-core): a server fn builder from a
missing module fails the build
sc-hosts 8: on Vite and Rsbuild, the build fails naming the missing
module; the wait is bounded (20 s) so a hanging build fails the test
fast.
Kills: spc:6906 (the mutant hangs the Rsbuild build; the bounded wait
reports it).
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-warnings.test.ts | 35 ++++++++++++++++++-
1 file changed, 34 insertions(+), 1 deletion(-)
diff --git a/packages/start-plugin-core/tests/start-build-warnings.test.ts b/packages/start-plugin-core/tests/start-build-warnings.test.ts
index 37840078933..4cefbbeea4a 100644
--- a/packages/start-plugin-core/tests/start-build-warnings.test.ts
+++ b/packages/start-plugin-core/tests/start-build-warnings.test.ts
@@ -3,7 +3,8 @@
* warning channel (Vite: the plugin context's `warn`, Rolldown's warnings;
* Rsbuild: the compilation's warnings), naming the module, once per module
* and environment. The compiler warns through its external import transforms
- * (`compilerTransforms`, e.g. React's RSC CSS transform).
+ * (`compilerTransforms`, e.g. React's RSC CSS transform). Its errors fail
+ * the build.
*/
import { describe, expect, test } from 'vitest'
import { buildStart, startBundlers } from './build-harness'
@@ -80,4 +81,36 @@ describe.each(startBundlers)('%s build', (bundler) => {
},
timeout,
)
+
+ // Source: mutation round 3 sc-hosts 8 (on Rsbuild, the build hung forever
+ // when the compiler's module resolution failed).
+ test(
+ 'a server fn whose builder comes from a missing module fails the build, naming the module',
+ async () => {
+ let timer: ReturnType | undefined
+ // A hanging build fails the test well before its timeout.
+ const hang = new Promise((resolve) => {
+ timer = setTimeout(() => resolve('the build hangs'), 20_000)
+ })
+ const outcome = await Promise.race([
+ buildStart({
+ bundler,
+ files: {
+ 'src/fns.ts': `import { base } from './missing-base'
+export const greet = base.handler(async () => 'MARK_HANDLER')
+`,
+ 'src/entry.ts': `export { greet } from './fns'\n`,
+ },
+ entry: 'src/entry.ts',
+ }).then(
+ () => 'the build succeeds',
+ (error: unknown) =>
+ error instanceof Error ? error.message : String(error),
+ ),
+ hang,
+ ]).finally(() => clearTimeout(timer))
+ expect(outcome).toContain('missing-base')
+ },
+ timeout,
+ )
})
From 206373081c03afe7836c7396ef12c436222a8c67 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:42:47 +0200
Subject: [PATCH 0276/1425] test(start-plugin-core): Vite compiles server fns
when one builder builds an environment twice
sc-hosts 4: a Vite builder builds the SSR environment twice (like the
@vitejs/plugin-rsc scan build) through the Start compiler plugin; the
second build's server fn, whose builder the compiler loads from another
module, is compiled and runs.
Kills: spc:5762, 5763, 5764.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-env-separation.test.ts | 104 +++++++++++++++++-
1 file changed, 102 insertions(+), 2 deletions(-)
diff --git a/packages/start-plugin-core/tests/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
index de3424ea787..0982c51c455 100644
--- a/packages/start-plugin-core/tests/start-build-env-separation.test.ts
+++ b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
@@ -8,8 +8,25 @@
* Route options go through Start's own router plugin wiring (its list of
* options deleted from client route modules).
*/
-import { afterAll, beforeAll, describe, expect, test } from 'vitest'
-import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness'
+import { readdir } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { createApp } from '@tanstack/compiler-test-utils/build-harness'
+import {
+ afterAll,
+ beforeAll,
+ describe,
+ expect,
+ onTestFinished,
+ test,
+} from 'vitest'
+import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin'
+import {
+ buildStart,
+ routerRuntimeFiles,
+ startBundlers,
+ startRuntimeFiles,
+} from './build-harness'
import type { StartBuild } from './build-harness'
const timeout = 60_000
@@ -360,3 +377,86 @@ return results`)
})
})
})
+
+describe('Vite: an environment built twice by one builder', () => {
+ // `@vitejs/plugin-rsc` runs a scan build of an environment before its real
+ // build, with the same plugins: the second build compiles server fns
+ // again, including ones whose builder the compiler loads from another
+ // module.
+ // Source: mutation round 3 sc-hosts 4.
+ test(
+ 'the second build compiles the server fns of its modules',
+ async () => {
+ const app = await createApp({
+ dir: path.dirname(fileURLToPath(import.meta.url)),
+ prefix: '.start-build-twice-',
+ registerCleanup: onTestFinished,
+ files: {
+ ...startRuntimeFiles,
+ 'src/direct.ts': `import { createServerFn } from '@tanstack/react-start'
+export const direct = createServerFn().handler(async () => 'MARK_DIRECT')
+`,
+ 'src/base.ts': `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn()
+`,
+ 'src/fns.ts': `import { base } from './base'
+export const greet = base.handler(async () => 'MARK_HANDLER')
+`,
+ },
+ })
+ const { createBuilder } = await import('vite')
+ /** Entry module of each build of the SSR environment. */
+ const inputs = ['src/direct.ts', 'src/fns.ts']
+ let builds = 0
+ const builder = await createBuilder({
+ root: app.root,
+ configFile: false,
+ logLevel: 'silent',
+ environments: {
+ client: { build: { outDir: 'dist/client' } },
+ // `ssr` names an entry; `build-twice-input` sets each build's
+ ssr: { build: { outDir: 'dist/ssr', ssr: inputs[0] } },
+ },
+ ssr: { noExternal: true },
+ builder: {
+ async buildApp(appBuilder) {
+ for (const _input of inputs) {
+ await appBuilder.build(appBuilder.environments.ssr!)
+ builds++
+ }
+ },
+ },
+ plugins: [
+ {
+ name: 'build-twice-input',
+ applyToEnvironment: (environment) => environment.name === 'ssr',
+ options: (options) => ({
+ ...options,
+ input: path.join(app.root, inputs[builds]!),
+ }),
+ },
+ startCompilerPlugin({
+ framework: 'react',
+ environments: [
+ { name: 'client', type: 'client' },
+ { name: 'ssr', type: 'server' },
+ ],
+ providerEnvName: 'ssr',
+ }),
+ ],
+ })
+ await builder.buildApp()
+ expect(builds).toBe(2)
+ const outDir = path.join(app.root, 'dist/ssr')
+ const output = (await readdir(outDir)).find((file) =>
+ /^fns\.m?js$/.test(file),
+ )
+ const { greet } = await import(
+ pathToFileURL(path.join(outDir, output!)).href
+ )
+ expect(typeof greet.functionId).toBe('string')
+ expect(await greet()).toBe('MARK_HANDLER')
+ },
+ timeout,
+ )
+})
From 6e7ac66b50d7261700a410499217c9de12c3d7b9 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:45:35 +0200
Subject: [PATCH 0277/1425] test(start-plugin-core): Vite dev with a server fn
provider environment other than SSR
sc-hosts 5: dev-server.ts takes the compiler's environments and
providerEnvName (optional, defaults unchanged; extra environments are
created as server environments). With an rsc provider, SSR resolves the
client's id through its configured getServerFnById module, and rsc runs
the handler.
Kills: spc:5677, 5679, 5681, 5932, 5934, 5936, 5937, 5938, 5939, 5941,
5942. Survive: spc:5935, 5940 (every environment the resolver applies
to is in the list, so the lookup always finds one).
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/dev-server.ts | 50 +++++++++++++++----
.../tests/start-compiler-dev-server.test.ts | 49 ++++++++++++++++++
2 files changed, 89 insertions(+), 10 deletions(-)
diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts
index 01b03a705a3..8cefd496093 100644
--- a/packages/start-plugin-core/tests/dev-server.ts
+++ b/packages/start-plugin-core/tests/dev-server.ts
@@ -12,6 +12,7 @@ import { createDevServer } from '@tanstack/compiler-test-utils/dev-server'
import { onTestFinished } from 'vitest'
import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin'
import { startRuntimeFiles } from './build-harness'
+import type { StartCompilerPluginOptions } from '../src/vite/start-compiler-plugin/plugin'
import type { PluginOption } from 'vite'
import type {
DevServer,
@@ -32,13 +33,36 @@ export interface StartDevServerOptions extends Omit<
plugins?:
| Array
| ((app: { root: string }) => Array)
+ /**
+ * The environments the Start compiler compiles (default `client`, and
+ * `ssr` as a server environment), as a framework passes them (a server
+ * environment other than the provider names its `getServerFnById`
+ * module). Environments other than `client` and `ssr` are created as
+ * server environments.
+ */
+ environments?: StartCompilerPluginOptions['environments']
+ /** The server fn provider environment (default `ssr`). */
+ providerEnvName?: string
}
/** Starts a Vite dev server with the Start compiler on an app of `files`. */
export function startDevServer(
options: StartDevServerOptions,
): Promise {
- const { files, plugins = [], ...devServerOptions } = options
+ const {
+ files,
+ plugins = [],
+ environments = [
+ { name: 'client', type: 'client' },
+ { name: 'ssr', type: 'server' },
+ ],
+ providerEnvName = 'ssr',
+ ...devServerOptions
+ } = options
+ /** Server environments Vite does not create by default. */
+ const extraEnvironments = environments
+ .map((environment) => environment.name)
+ .filter((name) => name !== 'client' && name !== 'ssr')
return createDevServer({
dir: harnessDir,
prefix: '.start-dev-server-',
@@ -46,19 +70,25 @@ export function startDevServer(
...devServerOptions,
files: { ...startRuntimeFiles, ...files },
plugins: ({ root }) => [
- startCompilerPlugin({
- framework: 'react',
- environments: [
- { name: 'client', type: 'client' },
- { name: 'ssr', type: 'server' },
- ],
- providerEnvName: 'ssr',
- }),
+ startCompilerPlugin({ framework: 'react', environments, providerEnvName }),
...(typeof plugins === 'function' ? plugins({ root }) : plugins),
{
name: 'start-dev-server-harness',
// The stand-in package imports the resolver virtual module
- config: () => ({ ssr: { noExternal: ['@tanstack/react-start'] } }),
+ config: () => ({
+ ssr: { noExternal: ['@tanstack/react-start'] },
+ ...(extraEnvironments.length > 0 && {
+ environments: Object.fromEntries(
+ extraEnvironments.map((name) => [
+ name,
+ {
+ consumer: 'server' as const,
+ resolve: { noExternal: ['@tanstack/react-start'] },
+ },
+ ]),
+ ),
+ }),
+ }),
},
],
})
diff --git a/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts b/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts
index c78e5554aaf..e11be72fdd5 100644
--- a/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts
+++ b/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts
@@ -75,6 +75,55 @@ export const later = createServerFn().handler(() => 'MARK_LATER')
)
})
+describe('Vite dev: a server fn provider environment other than SSR', () => {
+ // The RSC setup of a framework: the `rsc` environment runs the handlers,
+ // and SSR forwards calls by id to it through the `getServerFnById` module
+ // the framework configures.
+ // Source: mutation round 3 sc-hosts 5.
+ test(
+ 'SSR calls go through its configured getServerFnById, and the provider runs the handler of the client id',
+ async () => {
+ const server = await startDevServer({
+ environments: [
+ { name: 'client', type: 'client' },
+ {
+ name: 'ssr',
+ type: 'server',
+ getServerFnById: `export async function getServerFnById(id) {
+ return () => 'MARK_FORWARDED:' + id
+}
+`,
+ },
+ { name: 'rsc', type: 'server' },
+ ],
+ providerEnvName: 'rsc',
+ files: {
+ 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start'
+export const greet = createServerFn().handler(() => 'MARK_HANDLER')
+`,
+ },
+ })
+ // The client gets the id from the stand-in package
+ const id = (
+ await evaluateServed(server, '/src/fns.ts', { skip: /^\/@vite\b/ })
+ ).greet.functionId
+ /** Calls the server fn of `id` in `environment` through its resolver. */
+ const callById = async (environment: string) => {
+ const { getServerFnById } = await server.import(
+ '#tanstack-start-server-fn-resolver',
+ environment,
+ )
+ return (await getServerFnById(id, { origin: 'client' }))({})
+ }
+ expect({
+ ssr: await callById('ssr'),
+ rsc: await callById('rsc'),
+ }).toEqual({ ssr: `MARK_FORWARDED:${id}`, rsc: 'MARK_HANDLER' })
+ },
+ timeout,
+ )
+})
+
describe('Vite dev: editing a server fn handler', () => {
// Source: replaces server-fn-resolver-module.test.ts "imports on every call
// by default" and "keeps the imported module when memoizeModules is set",
From 2f7c14f2e9b49817a87450e62bbc6fce9de09ee5 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:49:42 +0200
Subject: [PATCH 0278/1425] test(start-plugin-core): Vite dev client route tree
follows page/server-only edits
Harness gap H1: dev-server.ts can mount Start's whole Vite plugin
(tanStackStartVite with the options @tanstack/react-start passes) through
the new opt-in start option, with stand-in entries, router and root route;
existing callers are unchanged. Control: the new test fails with the old
harness (no route tree is generated).
start-router D: new start-router-dev-server.test.ts edits a route from
server-only to page, to page without handlers, back to server-only; the
client route tree follows, page/server-only switches send a full reload,
and a handler edit of a server-only route sends nothing to the client.
Kills: spc:5953, 5955, 5960-5964, 5967, 5969, 5973-5975, 5977-5979,
5983-5988, 6002.
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/dev-server.ts | 60 ++++++++++++-
.../tests/start-router-dev-server.test.ts | 87 +++++++++++++++++++
2 files changed, 144 insertions(+), 3 deletions(-)
create mode 100644 packages/start-plugin-core/tests/start-router-dev-server.test.ts
diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts
index 8cefd496093..cbb56cdf7b0 100644
--- a/packages/start-plugin-core/tests/dev-server.ts
+++ b/packages/start-plugin-core/tests/dev-server.ts
@@ -5,14 +5,21 @@
* (`@tanstack/compiler-test-utils/dev-server`). `@tanstack/react-start` is the
* stand-in package of the build harness (`startRuntimeFiles`). The temporary
* apps live in this directory.
+ *
+ * With `start`, the dev server mounts Start's whole Vite plugin instead
+ * (`tanStackStartVite`, with the options `@tanstack/react-start` passes), so
+ * its dev server hooks, router generator, route tree client plugin and start
+ * manifest run too.
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createDevServer } from '@tanstack/compiler-test-utils/dev-server'
import { onTestFinished } from 'vitest'
import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin'
-import { startRuntimeFiles } from './build-harness'
+import { tanStackStartVite } from '../src/vite/plugin'
+import { routerRuntimeFiles, startRuntimeFiles } from './build-harness'
import type { StartCompilerPluginOptions } from '../src/vite/start-compiler-plugin/plugin'
+import type { TanStackStartInputConfig } from '../src/schema'
import type { PluginOption } from 'vite'
import type {
DevServer,
@@ -43,6 +50,46 @@ export interface StartDevServerOptions extends Omit<
environments?: StartCompilerPluginOptions['environments']
/** The server fn provider environment (default `ssr`). */
providerEnvName?: string
+ /**
+ * Mounts Start's whole Vite plugin with these Start plugin options
+ * (`tanstackStart(options)`) instead of the compiler plugin alone (then
+ * `environments` and `providerEnvName` do not apply: client and SSR, SSR
+ * as the provider). `@tanstack/react-router` is the build harness's
+ * stand-in (`routerRuntimeFiles`); the app's client and server entries are
+ * empty, and `src/router.ts` and `src/routes/__root.tsx` are stubs unless
+ * the app has them.
+ */
+ start?: TanStackStartInputConfig
+}
+
+/** The files the harness adds to an app with Start's plugin, unless it has them. */
+const startPluginFiles = {
+ ...routerRuntimeFiles,
+ 'harness/client-entry.ts': `export {}\n`,
+ 'harness/server-entry.ts': `export {}\n`,
+ 'harness/start.ts': `export const startInstance = undefined\n`,
+ 'src/router.ts': `export const getRouter = () => undefined\n`,
+ 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router'
+export const Route = createRootRoute()
+`,
+}
+
+/** Start's Vite plugin with the core options `@tanstack/react-start` passes. */
+function startPlugin(root: string, start: TanStackStartInputConfig) {
+ return tanStackStartVite(
+ {
+ framework: 'react',
+ defaultEntryPaths: {
+ client: path.join(root, 'harness/client-entry.ts'),
+ server: path.join(root, 'harness/server-entry.ts'),
+ start: path.join(root, 'harness/start.ts'),
+ },
+ providerEnvironmentName: 'ssr',
+ ssrIsProvider: true,
+ ssrResolverStrategy: { type: 'default' },
+ },
+ start,
+ )
}
/** Starts a Vite dev server with the Start compiler on an app of `files`. */
@@ -57,6 +104,7 @@ export function startDevServer(
{ name: 'ssr', type: 'server' },
],
providerEnvName = 'ssr',
+ start,
...devServerOptions
} = options
/** Server environments Vite does not create by default. */
@@ -68,9 +116,15 @@ export function startDevServer(
prefix: '.start-dev-server-',
registerCleanup: onTestFinished,
...devServerOptions,
- files: { ...startRuntimeFiles, ...files },
+ files: { ...startRuntimeFiles, ...(start && startPluginFiles), ...files },
plugins: ({ root }) => [
- startCompilerPlugin({ framework: 'react', environments, providerEnvName }),
+ start
+ ? startPlugin(root, start)
+ : startCompilerPlugin({
+ framework: 'react',
+ environments,
+ providerEnvName,
+ }),
...(typeof plugins === 'function' ? plugins({ root }) : plugins),
{
name: 'start-dev-server-harness',
diff --git a/packages/start-plugin-core/tests/start-router-dev-server.test.ts b/packages/start-plugin-core/tests/start-router-dev-server.test.ts
new file mode 100644
index 00000000000..4688409b02b
--- /dev/null
+++ b/packages/start-plugin-core/tests/start-router-dev-server.test.ts
@@ -0,0 +1,87 @@
+/**
+ * Start's router wiring on a running Vite dev server, with Start's whole
+ * Vite plugin (dev-server.ts `start`): the client route tree, which leaves
+ * out server-only routes, follows edits that turn a route into a page or
+ * into a server-only route.
+ */
+import { describe, expect, test } from 'vitest'
+import { startDevServer } from './dev-server'
+import type { HmrMessage } from '@tanstack/compiler-test-utils/dev-server'
+
+const timeout = 30_000
+
+const head = `import { createFileRoute } from '@tanstack/react-router'\n`
+const handlers = (text: string) =>
+ `server: { handlers: { GET: () => new Response('${text}') } }`
+/** `src/routes/api.tsx` with these route options. */
+const api = (options: string) =>
+ `${head}export const Route = createFileRoute('/api')({ ${options} })\n`
+const component = `component: () => 'MARK_API_PAGE'`
+
+/** The client environment's full page reloads among `messages`. */
+const fullReloads = (messages: Array) =>
+ messages
+ .filter((message) => message.environment === 'client')
+ .filter((message) => message.type === 'full-reload')
+ .map((message) => message.paths)
+
+describe('Vite dev: a route switching between page and server-only', () => {
+ // Source: mutation round 3 start-router D (dev-server.ts mounted only the
+ // compiler plugin, so Start's dev server hooks never ran).
+ test(
+ 'the client route tree follows the edits, and the page reloads when it changes',
+ async () => {
+ const server = await startDevServer({
+ start: {},
+ files: {
+ 'src/routes/page.tsx': `${head}export const Route = createFileRoute('/page')({ component: () => 'MARK_PAGE' })\n`,
+ 'src/routes/api.tsx': api(handlers('x')),
+ 'src/router.ts': `import { routeTree } from './routeTree.gen'
+export const getRouter = () => ({ routeTree })
+`,
+ },
+ })
+ /** Route modules the client route tree imports. */
+ const clientRoutes = async () =>
+ Object.keys(await server.load('/src/routeTree.gen.ts'))
+ .filter((url) => url.startsWith('/src/routes/'))
+ .sort()
+
+ expect(await clientRoutes()).toEqual([
+ '/src/routes/__root.tsx',
+ '/src/routes/page.tsx',
+ ])
+
+ // An edit of a server-only route that keeps it server-only changes
+ // nothing in the client
+ const handlerEdit = await server.edit({
+ 'src/routes/api.tsx': api(handlers('y')),
+ })
+ expect(
+ handlerEdit.filter((message) => message.environment === 'client'),
+ ).toEqual([])
+
+ // Server-only -> page
+ const toPage = await server.edit({
+ 'src/routes/api.tsx': api(`${handlers('y')}, ${component}`),
+ })
+ expect(fullReloads(toPage)).toContainEqual(['*'])
+ expect(await clientRoutes()).toContain('/src/routes/api.tsx')
+
+ // Page with handlers -> page without handlers
+ await server.edit({ 'src/routes/api.tsx': api(component) })
+ expect(await clientRoutes()).toContain('/src/routes/api.tsx')
+
+ // Page -> server-only
+ const toApi = await server.edit({
+ 'src/routes/api.tsx': api(handlers('z')),
+ })
+ expect(fullReloads(toApi)).toContainEqual(['*'])
+ expect(await clientRoutes()).toEqual([
+ '/src/routes/__root.tsx',
+ '/src/routes/page.tsx',
+ ])
+ },
+ timeout,
+ )
+})
From 439693545146f93357861da22d22841e7970075a Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:51:21 +0200
Subject: [PATCH 0279/1425] test(start-plugin-core): Rsbuild builds restored
from the persistent cache resolve server fns
sc-hosts 9: buildStart gets two additive options, builds (rebuild the
same app directory with a new plugin instance, describe the last build)
and rsbuildConfig (merged over the harness's). A second Rsbuild build
with performance.buildCache restores the modules from the cache, and
each client RPC id still runs its handler through the SSR resolver.
Kills: spc:6799, 6956, 6960, 6968. Survives: spc:6959 (restore tap at
stage +20 instead of -20, after the SSR resolver rebuild at -10): the
client environment's restore already filled the shared registry, so the
order within the SSR compiler is not observable here.
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/build-harness.ts | 32 ++++++++++---
.../tests/start-build-env-separation.test.ts | 46 +++++++++++++++++++
2 files changed, 72 insertions(+), 6 deletions(-)
diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts
index 11174e0e416..654ceb0f2d0 100644
--- a/packages/start-plugin-core/tests/build-harness.ts
+++ b/packages/start-plugin-core/tests/build-harness.ts
@@ -49,7 +49,7 @@ import type {
} from '@tanstack/compiler-test-utils/build-harness'
import type { TanStackStartInputConfig } from '../src/schema'
import type { StartCompilerImportTransform } from '../src/types'
-import type { RsbuildPlugin } from '@rsbuild/core'
+import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core'
import type { Plugin } from 'vite'
const startPackage = 'node_modules/@tanstack/react-start'
@@ -232,6 +232,15 @@ export interface StartBuildOptions {
* it in `afterAll`.
*/
registerCleanup?: (cleanup: () => Promise) => void
+ /**
+ * Builds the app this many times in a row, in the same directory and with
+ * a new plugin instance each time (default 1). The results describe the
+ * last build, e.g. one Rsbuild restores from its persistent cache
+ * (`rsbuildConfig: { performance: { buildCache: true } }`).
+ */
+ builds?: number
+ /** Rsbuild configuration merged over the harness's (Rsbuild builds). */
+ rsbuildConfig?: RsbuildConfig
}
export interface StartBuild {
@@ -318,13 +327,23 @@ export async function buildStart(
sourcemap: options.sourcemap ?? false,
start: options.start ?? {},
compilerTransforms: options.compilerTransforms,
+ rsbuildConfig: options.rsbuildConfig,
}
const warnings = { client: [], ssr: [] } as Record>
const consoleMessages: Array = []
- const started = performance.now()
- const outputs = await withBuildEnvironment(mode, consoleMessages, () =>
+ let started = performance.now()
+ let outputs = await withBuildEnvironment(mode, consoleMessages, () =>
builders[bundler](target, warnings),
)
+ for (let build = 1; build < (options.builds ?? 1); build++) {
+ warnings.client.length = 0
+ warnings.ssr.length = 0
+ consoleMessages.length = 0
+ started = performance.now()
+ outputs = await withBuildEnvironment(mode, consoleMessages, () =>
+ builders[bundler](target, warnings),
+ )
+ }
const duration = performance.now() - started
const describe = (env: Env) =>
describeBuild({
@@ -353,6 +372,7 @@ interface BuildTarget {
sourcemap: boolean
start: TanStackStartInputConfig
compilerTransforms: Array | undefined
+ rsbuildConfig: RsbuildConfig | undefined
}
type Outputs = Record }>
@@ -458,7 +478,7 @@ async function buildWithRsbuild(
target: BuildTarget,
warnings: Record>,
): Promise {
- const { createRsbuild } = await import('@rsbuild/core')
+ const { createRsbuild, mergeRsbuildConfig } = await import('@rsbuild/core')
const outputs: Partial = {}
const errors: Array = []
/** Records what each environment emitted. */
@@ -487,7 +507,7 @@ async function buildWithRsbuild(
const rsbuild = await createRsbuild({
cwd: target.root,
loadEnv: false,
- rsbuildConfig: {
+ rsbuildConfig: mergeRsbuildConfig({
root: target.root,
mode: target.mode,
logLevel: 'silent',
@@ -515,7 +535,7 @@ async function buildWithRsbuild(
useTestLoaders(target.root),
outputPlugin,
],
- },
+ }, target.rsbuildConfig ?? {}),
})
await rsbuild.build()
} catch (error) {
diff --git a/packages/start-plugin-core/tests/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
index 0982c51c455..12378987078 100644
--- a/packages/start-plugin-core/tests/start-build-env-separation.test.ts
+++ b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
@@ -460,3 +460,49 @@ export const greet = base.handler(async () => 'MARK_HANDLER')
timeout,
)
})
+
+describe('Rsbuild: a build restored from the persistent cache', () => {
+ // With `performance.buildCache`, Rspack restores unchanged modules from
+ // its cache without running Start's compiler on them again; Start must
+ // still know their server fns.
+ // Source: mutation round 3 sc-hosts 9 (e2e apps custom-server-rsbuild and
+ // server-functions build with buildCache).
+ test(
+ 'each server fn of the client runs by its id in the SSR build',
+ async () => {
+ const { client, ssr } = await buildStart({
+ bundler: 'rsbuild',
+ builds: 2,
+ rsbuildConfig: { performance: { buildCache: true } },
+ files: {
+ 'src/base.ts': `import { createServerFn } from '@tanstack/react-start'
+export const base = createServerFn()
+`,
+ 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start'
+import { base } from './base'
+export const greet = createServerFn().handler(async () => 'MARK_GREET')
+export const fromBase = base.handler(async () => 'MARK_FROM_BASE')
+`,
+ 'src/client-entry.ts': `export { greet, fromBase } from './fns'\n`,
+ 'src/ssr-entry.ts': `export { greet, fromBase } from './fns'
+export { getServerFnById } from '#tanstack-start-server-fn-resolver'
+`,
+ },
+ entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' },
+ })
+ expect(client.where(/MARK_GREET|MARK_FROM_BASE/)).toBe('nowhere')
+ const ids = (
+ await client.run(
+ `return [entry.greet.functionId, entry.fromBase.functionId]`,
+ )
+ ).result
+ const { result } = await ssr.run(`const results = []
+for (const id of ${JSON.stringify(ids)}) {
+ results.push(await (await entry.getServerFnById(id, { origin: 'client' }))({}))
+}
+return results`)
+ expect(result).toEqual(['MARK_GREET', 'MARK_FROM_BASE'])
+ },
+ timeout,
+ )
+})
From 00cd8464c56f06439958486a4f8966dfbccf9c3b Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:53:31 +0200
Subject: [PATCH 0280/1425] test(start-plugin-core): SSR route splitting
follows router.codeSplittingOptions
start-router H: the router.codeSplittingOptions rows also assert the SSR
build places the loader and component like the client build, on Vite
and Rsbuild.
Kills: spc:6051, 8034, 8035.
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-options.test.ts | 30 ++++++++++++-------
1 file changed, 19 insertions(+), 11 deletions(-)
diff --git a/packages/start-plugin-core/tests/start-build-options.test.ts b/packages/start-plugin-core/tests/start-build-options.test.ts
index 88a81190414..c0b6b8ec93a 100644
--- a/packages/start-plugin-core/tests/start-build-options.test.ts
+++ b/packages/start-plugin-core/tests/start-build-options.test.ts
@@ -12,6 +12,7 @@ import {
startBundlers,
startRuntimeFiles,
} from './build-harness'
+import type { StartBuild } from './build-harness'
const timeout = 60_000
@@ -258,9 +259,10 @@ export const getRouter = () => ({ routeTree })
'src/entry.ts': `export { getRouter } from './router'\n`,
}
- // Start passes the router options through to the code splitter (only
- // replacing the options it owns: the route options it deletes from client
- // builds, and HMR). By default the loader stays with the route.
+ // Start passes the router options through to the code splitter of each
+ // build (only replacing the options it owns: the route options it deletes
+ // from client builds, and HMR). By default the loader stays with the
+ // route.
// Source: oracle-gaps "Start options never exercised"
// (router.codeSplittingOptions through Start).
test.each([
@@ -281,21 +283,27 @@ export const getRouter = () => ({ routeTree })
expected: { loader: 'lazy', component: 'lazy', oneChunk: true },
},
])(
- 'router.codeSplittingOptions: the client loads the route loader and component $name',
+ 'router.codeSplittingOptions: the client and SSR builds load the route loader and component $name',
async ({ router, expected }) => {
- const { client } = await buildStart({
+ const { client, ssr } = await buildStart({
bundler,
files: routeFiles,
entry: 'src/entry.ts',
start: { router },
})
- expect({
- loader: client.where('MARK_LOADER'),
- component: client.where('MARK_COMPONENT'),
+ /** Where a build has the loader and the component. */
+ const placement = (build: StartBuild['client']) => ({
+ loader: build.where('MARK_LOADER'),
+ component: build.where('MARK_COMPONENT'),
oneChunk:
- client.locate('MARK_LOADER').lazy[0] ===
- client.locate('MARK_COMPONENT').lazy[0],
- }).toEqual(expected)
+ build.locate('MARK_LOADER').lazy[0] ===
+ build.locate('MARK_COMPONENT').lazy[0],
+ })
+ // Source: mutation round 3 start-router H for the SSR build.
+ expect({ client: placement(client), ssr: placement(ssr) }).toEqual({
+ client: expected,
+ ssr: expected,
+ })
},
timeout,
)
From 974aec9e1b02bd3eb2c80f09c38ddef5a64db8c4 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:56:56 +0200
Subject: [PATCH 0281/1425] test(start-plugin-core): format the round 3 host
tests
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/build-harness.ts | 51 ++++++++++---------
.../tests/start-build-env-separation.test.ts | 3 +-
.../tests/start-build-manifest.test.ts | 3 +-
3 files changed, 30 insertions(+), 27 deletions(-)
diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts
index 654ceb0f2d0..d5dc3bd8ae0 100644
--- a/packages/start-plugin-core/tests/build-harness.ts
+++ b/packages/start-plugin-core/tests/build-harness.ts
@@ -507,35 +507,38 @@ async function buildWithRsbuild(
const rsbuild = await createRsbuild({
cwd: target.root,
loadEnv: false,
- rsbuildConfig: mergeRsbuildConfig({
- root: target.root,
- mode: target.mode,
- logLevel: 'silent',
- output: {
- minify: target.minify,
- sourceMap: target.sourcemap ? { js: 'source-map' } : false,
- cleanDistPath: true,
- },
- // React's JSX runtime, as `pluginReact()` of a React Start app
- // configures it.
- tools: {
- swc: {
- jsc: {
- transform: {
- react: {
- runtime: 'automatic',
- development: target.mode === 'development',
+ rsbuildConfig: mergeRsbuildConfig(
+ {
+ root: target.root,
+ mode: target.mode,
+ logLevel: 'silent',
+ output: {
+ minify: target.minify,
+ sourceMap: target.sourcemap ? { js: 'source-map' } : false,
+ cleanDistPath: true,
+ },
+ // React's JSX runtime, as `pluginReact()` of a React Start app
+ // configures it.
+ tools: {
+ swc: {
+ jsc: {
+ transform: {
+ react: {
+ runtime: 'automatic',
+ development: target.mode === 'development',
+ },
},
},
},
},
+ plugins: [
+ tanStackStartRsbuild(reactStartCoreOptions(target), target.start),
+ useTestLoaders(target.root),
+ outputPlugin,
+ ],
},
- plugins: [
- tanStackStartRsbuild(reactStartCoreOptions(target), target.start),
- useTestLoaders(target.root),
- outputPlugin,
- ],
- }, target.rsbuildConfig ?? {}),
+ target.rsbuildConfig ?? {},
+ ),
})
await rsbuild.build()
} catch (error) {
diff --git a/packages/start-plugin-core/tests/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
index 12378987078..9b112e8043a 100644
--- a/packages/start-plugin-core/tests/start-build-env-separation.test.ts
+++ b/packages/start-plugin-core/tests/start-build-env-separation.test.ts
@@ -284,7 +284,8 @@ const next = (options) => options
expected: 'MARK_HANDLER_DEP',
},
{
- construct: 'a server fn handler of a builder imported from another module',
+ construct:
+ 'a server fn handler of a builder imported from another module',
marker: 'MARK_BASE_HANDLER_DEP',
run: `return await entry.fromBase()`,
expected: 'MARK_BASE_HANDLER_DEP',
diff --git a/packages/start-plugin-core/tests/start-build-manifest.test.ts b/packages/start-plugin-core/tests/start-build-manifest.test.ts
index 65d7fb2c725..0199ade11e4 100644
--- a/packages/start-plugin-core/tests/start-build-manifest.test.ts
+++ b/packages/start-plugin-core/tests/start-build-manifest.test.ts
@@ -74,8 +74,7 @@ describe.each(startBundlers)('%s build', (bundler) => {
/** The start manifest the SSR build renders with. */
async function startManifest() {
- return (await build.ssr.run(`return entry.tsrStartManifest()`))
- .result as {
+ return (await build.ssr.run(`return entry.tsrStartManifest()`)).result as {
routes: Record<
string,
{ preloads?: Array; css?: Array }
From c3c846d2d132440d5fba6122c9f9af05a7727353 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:52:47 +0200
Subject: [PATCH 0282/1425] test(start-plugin-core): cover Rsbuild dev import
protection across rebuilds
Harness gap H2: the Rsbuild dev-server harness now exposes the warnings of
each compile (compiler-test-utils, additive) and closes the dev server when
its first compile fails (e.g. times out), so a hung compile does not leak a
watcher into later tests. `runRsbuildClient()` runs the emitted client code
and returns what it logged.
Items:
- ip-rsbuild R3 (H2 control): `log: 'once'` reports a violation once across
rebuilds, `'always'` again.
- ip-rsbuild R4: after an edit, `info.resolved` names the new target.
- ip-rsbuild R7 + ip-misc P10: each client mock (two denied files, one
denied specifier) reports its own import at runtime.
Kills: spc:7076, 7548, 7553, 7692, 7698, 7131, 7944, 7962, 7966
Co-Authored-By: Claude Opus 5.5
---
.../src/rsbuild-dev-server.ts | 18 ++-
.../tests/rsbuild-dev-server.ts | 37 ++++++-
.../tests/rsbuild/import-protection.test.ts | 104 +++++++++++++++++-
3 files changed, 155 insertions(+), 4 deletions(-)
diff --git a/packages/compiler-test-utils/src/rsbuild-dev-server.ts b/packages/compiler-test-utils/src/rsbuild-dev-server.ts
index 73023cdf13a..72a8407d664 100644
--- a/packages/compiler-test-utils/src/rsbuild-dev-server.ts
+++ b/packages/compiler-test-utils/src/rsbuild-dev-server.ts
@@ -53,6 +53,8 @@ export interface RsbuildDevServer {
rebuilt: Array
/** Errors of the last compile. */
errors: Array
+ /** Warnings of the last compile. */
+ warnings: Array
/** Stops the dev server and removes the app directory. */
close: () => Promise
}
@@ -77,6 +79,7 @@ export async function createRsbuildDevServer(
let compiles = 0
const rebuilt: Array = []
const errors: Array = []
+ const warnings: Array = []
const waiters: Array<{ after: number; resolve: () => void }> = []
/** Compiles start and finish in order: `compiles` counts the started ones. */
let finished = 0
@@ -96,6 +99,7 @@ export async function createRsbuildDevServer(
const all = 'stats' in stats ? stats.stats : [stats]
rebuilt.length = 0
errors.length = 0
+ warnings.length = 0
for (const environmentStats of all) {
const { compilation } = environmentStats
for (const module of compilation.builtModules) {
@@ -107,6 +111,9 @@ export async function createRsbuildDevServer(
for (const error of compilation.errors) {
errors.push(error.message)
}
+ for (const warning of compilation.warnings) {
+ warnings.push(warning.message)
+ }
}
for (const waiter of [...waiters]) {
if (finished > waiter.after) {
@@ -145,6 +152,8 @@ export async function createRsbuildDevServer(
let rsbuild: RsbuildInstance
let server: Awaited>
+ /** The dev server once created, closed if its first compile fails. */
+ let created: typeof server | undefined
try {
rsbuild = await createRsbuild({
cwd: root,
@@ -168,9 +177,15 @@ export async function createRsbuildDevServer(
),
})
server = await rsbuild.createDevServer()
+ created = server
await compiled(0, 'the app')
} catch (error) {
- await app.dispose()
+ try {
+ // e.g. the first compile timed out: stop watching the app
+ await created?.close()
+ } finally {
+ await app.dispose()
+ }
throw error
}
@@ -214,6 +229,7 @@ export async function createRsbuildDevServer(
},
rebuilt,
errors,
+ warnings,
close,
}
}
diff --git a/packages/start-plugin-core/tests/rsbuild-dev-server.ts b/packages/start-plugin-core/tests/rsbuild-dev-server.ts
index f97a0f5fa18..573db9bfbca 100644
--- a/packages/start-plugin-core/tests/rsbuild-dev-server.ts
+++ b/packages/start-plugin-core/tests/rsbuild-dev-server.ts
@@ -5,11 +5,12 @@
* (`@tanstack/compiler-test-utils/rsbuild-dev-server`).
* `@tanstack/react-start` is the stand-in package of the build harness
* (`startRuntimeFiles`). With `importProtection`, Start's Rsbuild import
- * protection also runs on the `client` environment. The temporary apps live in
- * this directory.
+ * protection also runs on the `client` environment. `runRsbuildClient()` runs
+ * the emitted client code. The temporary apps live in this directory.
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
+import vm from 'node:vm'
import { createRsbuildDevServer } from '@tanstack/compiler-test-utils/rsbuild-dev-server'
import { onTestFinished } from 'vitest'
import { registerImportProtection } from '../src/rsbuild/import-protection'
@@ -75,3 +76,35 @@ export function startRsbuildDevServer(
],
})
}
+
+/**
+ * Runs the JavaScript the dev server's client environment emitted, like a
+ * browser loading the page (in a fresh VM context). Returns what it logged
+ * to the console, one message per call (`Error`s by their message).
+ */
+export async function runRsbuildClient(
+ server: Pick,
+): Promise> {
+ const messages: Array = []
+ const record = (...args: Array) => {
+ messages.push(
+ args
+ .map((arg) =>
+ // errors of the VM context are not `instanceof Error` here
+ typeof arg === 'object' && arg !== null && 'message' in arg
+ ? String(arg.message)
+ : String(arg),
+ )
+ .join(' '),
+ )
+ }
+ const context = vm.createContext({
+ console: { log: record, info: record, warn: record, error: record },
+ })
+ context.self = context
+ context.globalThis = context
+ for (const code of Object.values(await server.outputs())) {
+ vm.runInContext(code, context)
+ }
+ return messages
+}
diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts
index 1a49d99f944..627477f5781 100644
--- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts
+++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts
@@ -2,7 +2,8 @@ import path from 'node:path'
import { describe, expect, test } from 'vitest'
import { compileMatchers } from '../../src/import-protection/matchers'
import { getRsbuildResolvedImportProtectionCheck } from '../../src/rsbuild/import-protection'
-import { startRsbuildDevServer } from '../rsbuild-dev-server'
+import { reportedViolations } from '../importProtection/start-apps'
+import { runRsbuildClient, startRsbuildDevServer } from '../rsbuild-dev-server'
import type { ViolationInfo } from '../../src/import-protection/trace'
describe('getRsbuildResolvedImportProtectionCheck', () => {
@@ -107,3 +108,104 @@ console.log(page())
30_000,
)
})
+
+describe('Rsbuild dev: reports across rebuilds', () => {
+ /** An entry using the denied file `src/secret.server.ts` and `src/other.ts`. */
+ const app = {
+ 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`,
+ 'src/other.ts': `export const other = 1\n`,
+ 'src/entry.ts': `import { secret } from './secret.server'
+import { other } from './other'
+console.log(secret(), other)
+`,
+ }
+
+ // Source: mutation run R3 ip-rsbuild R3; a control of the harness running
+ // an Rsbuild dev rebuild with import protection on (the warnings of each
+ // compile).
+ test.each([
+ { log: 'once' as const, afterRebuild: [] },
+ { log: 'always' as const, afterRebuild: ['client src/entry.ts:3:13'] },
+ ])(
+ "log '$log': a rebuild for an unrelated edit reports $afterRebuild",
+ async ({ log, afterRebuild }) => {
+ const server = await startRsbuildDevServer({
+ importProtection: { log },
+ files: app,
+ })
+ const reported = () =>
+ reportedViolations(server.warnings).map(
+ ({ environment, importer }) => `${environment} ${importer}`,
+ )
+ expect(reported()).toEqual(['client src/entry.ts:3:13'])
+ await server.edit({ 'src/other.ts': `export const other = 2\n` })
+ expect(server.rebuilt).toContain('src/other.ts')
+ expect(reported()).toEqual(afterRebuild)
+ },
+ 30_000,
+ )
+
+ // Source: mutation run R3 ip-rsbuild R4 (the resolve cache of a previous
+ // compile).
+ test('a denied specifier that an edit makes resolve elsewhere is reported with its new target', async () => {
+ const resolved: Array = []
+ const server = await startRsbuildDevServer({
+ importProtection: {
+ client: { specifiers: [/^\.\/lib$/] },
+ onViolation: (info) => {
+ // relative to the app root (src/...)
+ resolved.push(
+ (info as ViolationInfo).resolved?.replace(/^.*\/(?=src\/)/, ''),
+ )
+ },
+ },
+ files: {
+ 'src/lib.ts': `export const lib = 1\n`,
+ 'src/entry.ts': `import { lib } from './lib'\nconsole.log(lib)\n`,
+ },
+ })
+ expect(resolved).toEqual(['src/lib.ts'])
+ await server.edit({
+ 'src/lib.ts': null,
+ 'src/lib/index.ts': `export const lib = 2\n`,
+ 'src/entry.ts': `import { lib } from './lib'\nconsole.log(lib, 2)\n`,
+ })
+ expect(resolved).toEqual(['src/lib.ts', 'src/lib/index.ts'])
+ }, 30_000)
+})
+
+// Source: mutation run R3 ip-rsbuild R7 and ip-misc P10.
+test('Rsbuild dev: the client mock of each denied import reports its own use in the browser console', async () => {
+ const server = await startRsbuildDevServer({
+ importProtection: { client: { specifiers: ['db-lib'] } },
+ files: {
+ 'node_modules/db-lib/package.json': JSON.stringify({
+ name: 'db-lib',
+ type: 'module',
+ exports: { '.': './index.js' },
+ }),
+ 'node_modules/db-lib/index.js': `export const query = () => 'MARK_LIB'\n`,
+ 'src/a.server.ts': `export const a = () => 'MARK_A'\n`,
+ 'src/b.server.ts': `export const b = () => 'MARK_B'\n`,
+ 'src/entry.ts': `import { a } from './a.server'
+import { b } from './b.server'
+import { query } from 'db-lib'
+console.log(a(), b(), query())
+`,
+ },
+ })
+ expect(server.errors).toEqual([])
+ const messages = await runRsbuildClient(server)
+ expect(messages.filter((message) => message.includes('MARK_'))).toEqual([])
+ expect(
+ [
+ ...new Set(
+ messages
+ .filter((message) =>
+ message.includes('Mocked import used in dev client'),
+ )
+ .map((message) => /Denied import: "([^"]+)"/.exec(message)?.[1]),
+ ),
+ ].sort(),
+ ).toEqual(['db-lib', 'src/a.server.ts', 'src/b.server.ts'])
+}, 30_000)
From 20c891a337b504956eea76c7f10c0357b6a47154 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:52:59 +0200
Subject: [PATCH 0283/1425] test(start-plugin-core): register Start's entries
in the Vite dev import-protection harness
Harness gap H1: `startProtectedDevServer` and the known-bugs dev config now
register Start's router entry (and `src/start.ts` when present) the way
`tanStackStart()` does, so the dev reachability check runs. Apps in
dev.test.ts are loaded from their router entry `src/entry.ts`, like a
browser loads a Start app. The provider environment can be `rsc`.
Re-running the suite with the entries registered turned two passing tests
red; investigated, a real main bug (not a harness artefact), now pinned:
- Bug (new): an edit drops the import graph edges to the edited module
(`ImportGraph.invalidate` removes the edges where the file is the
target), so a violation found when it is transformed again is discarded
as unreachable: a denied import added by an edit is mocked without a
warning, and `log: 'always'` does not log again after an edit. Pins:
"added by an edit of the importer is logged at the use, with a trace from
the entry", "'always' logs a violation again after an edit" (both flip
with a scratch fix keeping incoming edges). The mocking half stays a
passing test.
Items:
- ip-vite V2: the logging half of the "importer first" pin is a normal test.
- V3: SSR import of a client-only-marked module (already transformed, and
through its importer) is mocked and logged.
- V4 (a) only the leak is logged, not a server fn factory's .server()
imports; (b) a factory module calling a denied import at the top level
is logged (the H1 control: fails without the entry).
- V7 denied specifier logged at its use; V8 dev onViolation; V9 rsc
provider env; V12 runtime mock reports; V14 JSX locations; V15 'always'
does not re-log on other loads.
Kills: spc:5091, 5094, 5103, 5106, 5109, 5113, 4305, 4312, 5070, 5077,
4547, 4560, 4561, 4584, 4586, 4588 (hang), 4594, 4597, 4604, 4607, 4613,
4626, 4631, 4642, 4643, 4644, 4656, 4657, 4662, 5011, 5141, 5143, 5146,
4533, 4581, 4520, 4900, 4902, 4678, 4685, 4687, 5014, 5017, 5021, 5446,
4744, 4761, 4764, 4326, 4794, 5351, 4169, 4170, 4173, 4187, 4189, 4731
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/dev.test.ts | 448 ++++++++++++++++--
.../tests/importProtection/start-apps.ts | 77 ++-
.../tests/known-bugs-dev-server.test.ts | 47 +-
3 files changed, 515 insertions(+), 57 deletions(-)
diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts
index 173d6f31763..7e85a18bc56 100644
--- a/packages/start-plugin-core/tests/importProtection/dev.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts
@@ -1,32 +1,62 @@
/**
* Import protection on a Vite dev server (Start's import-protection plugin
- * after the Start compiler): what the browser gets for a denied import, and
- * the warnings the dev server logs, including after edits (without a
- * restart). The client loads modules the way a browser does: an importer
+ * after the Start compiler, with Start's entries registered): what the
+ * browser gets for a denied import, and the warnings the dev server logs,
+ * including after edits (without a restart). The client loads modules the way
+ * a browser does: from the app's router entry `src/entry.ts`, an importer
* before the modules it imports.
*/
-import { describe, expect, test } from 'vitest'
+import path from 'node:path'
+import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server'
+import { afterEach, describe, expect, test, vi } from 'vitest'
import {
distinct,
+ factoryTopLevelCall,
+ reactRuntimeFiles,
rejection,
reportedViolations,
+ serverFnFactoryApp,
startProtectedDevServer,
} from './start-apps'
import type { ImportProtectionOptions } from '../../src/schema'
const timeout = 30_000
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
/** Client modules (by URL) whose served code contains `marker`. */
function modulesContaining(modules: Record, marker: string) {
return Object.keys(modules).filter((url) => modules[url]!.includes(marker))
}
-/** `src/a.ts` uses `secret` of the denied file `src/secret.server.ts`. */
+/** `environment importer` of each distinct violation logged in `logs`. */
+function loggedViolations(logs: Array) {
+ return distinct(
+ reportedViolations(logs).map(
+ ({ environment, importer }) => `${environment} ${importer}`,
+ ),
+ )
+}
+
+/** The files of the `Trace:` steps of an import-protection warning. */
+function traceFiles(report: string) {
+ return (/Trace:\n([\s\S]*?)\n\n/.exec(report)?.[1] ?? '')
+ .split('\n')
+ .map((line) => /^\s*\d+\. ([^\s:]+)/.exec(line)?.[1])
+}
+
+/**
+ * `src/a.ts` uses `secret` of the denied file `src/secret.server.ts`; the
+ * router entry re-exports `src/a.ts`.
+ */
const deniedFileApp = {
'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`,
'src/a.ts': `import { secret } from './secret.server'
export const read = () => secret()
`,
+ 'src/entry.ts': `export * from './a'\n`,
}
/** The location reported for `src/a.ts`'s use of `secret`. */
@@ -38,33 +68,64 @@ describe('a client import of a denied file', () => {
'is mocked, and the dev server logs a warning naming the importer at the use',
async () => {
const server = await startProtectedDevServer(deniedFileApp)
- const client = await server.load('/src/a.ts')
+ const client = await server.load('/src/entry.ts')
expect(modulesContaining(client, 'MARK_SECRET')).toEqual([])
- expect(
- distinct(
- reportedViolations(server.logs).map(
- ({ environment, importer }) => `${environment} ${importer}`,
- ),
- ),
- ).toEqual([`client ${deniedFileUse}`])
+ expect(loggedViolations(server.logs)).toEqual([`client ${deniedFileUse}`])
},
timeout,
)
+ /**
+ * Starts the app with a `src/a.ts` that imports nothing, loads it, then
+ * edits `src/a.ts` to import the denied file and loads it again.
+ */
+ async function addDeniedImportByAnEdit() {
+ const server = await startProtectedDevServer({
+ ...deniedFileApp,
+ 'src/a.ts': `export const read = () => 'plain'\n`,
+ })
+ await server.load('/src/entry.ts')
+ await server.edit({ 'src/a.ts': deniedFileApp['src/a.ts'] })
+ const client = await server.load('/src/entry.ts')
+ return { server, client }
+ }
+
test(
- 'added by an edit of the importer is mocked and logged, without a restart',
+ 'added by an edit of the importer is mocked, without a restart',
async () => {
- const server = await startProtectedDevServer({
- ...deniedFileApp,
- 'src/a.ts': `export const read = () => 'plain'\n`,
- })
- await server.load('/src/a.ts')
- await server.edit({ 'src/a.ts': deniedFileApp['src/a.ts'] })
- const client = await server.load('/src/a.ts')
+ const { client } = await addDeniedImportByAnEdit()
expect(modulesContaining(client, 'MARK_SECRET')).toEqual([])
+ },
+ timeout,
+ )
+
+ // Bug: when a module changes, dev import protection drops every import
+ // graph edge that points to it (`hotUpdate` → `ImportGraph.invalidate`,
+ // import-protection/trace.ts, removes the edges where the file is the
+ // target, not only those where it is the importer). Its importers are not
+ // transformed again, so the edges do not come back, and a violation found
+ // when the edited module is transformed again is unreachable from Start's
+ // router entry: `processPendingViolations`
+ // (vite/import-protection-plugin/plugin.ts) discards it.
+ // Impact: in dev (mock mode), a denied import added to an existing module
+ // by an edit is mocked without any warning; with `log: 'always'`, a
+ // violation is not logged again after an edit (the 'always' pin below).
+ // A scratch run that keeps the incoming edges passes both pins.
+ // Source: found registering Start's entries in the dev harness (mutation
+ // run R3, harness gap H1); the trace part is ip-vite V13.
+ test.fails(
+ 'added by an edit of the importer is logged at the use, with a trace from the entry',
+ async () => {
+ const { server } = await addDeniedImportByAnEdit()
+ const reports = server.logs.filter((log) =>
+ log.includes('[import-protection] Import denied'),
+ )
expect(
- distinct(reportedViolations(server.logs).map((v) => v.importer)),
+ distinct(reportedViolations(reports).map((v) => v.importer)),
).toEqual([deniedFileUse])
+ for (const report of reports) {
+ expect(traceFiles(report)).toEqual(['src/entry.ts', 'src/a.ts'])
+ }
},
timeout,
)
@@ -80,7 +141,7 @@ describe('a client import of a denied file', () => {
const server = await startProtectedDevServer(deniedFileApp, {
behavior,
})
- const error = await rejection(server.load('/src/a.ts'))
+ const error = await rejection(server.load('/src/entry.ts'))
expect(reportedViolations(error.message)).toEqual([
{ environment: 'client', importer: deniedFileUse },
])
@@ -94,7 +155,7 @@ describe('a client import of a denied file', () => {
const server = await startProtectedDevServer(deniedFileApp, {
behavior: { build: 'error' },
})
- const client = await server.load('/src/a.ts')
+ const client = await server.load('/src/entry.ts')
expect(modulesContaining(client, 'MARK_SECRET')).toEqual([])
expect(reportedViolations(server.logs)).not.toEqual([])
},
@@ -104,12 +165,13 @@ describe('a client import of a denied file', () => {
describe('log', () => {
/**
- * The violations logged when the client loads `src/a.ts`, and those logged
- * when it loads it again after an edit that keeps the denied import.
+ * The violations logged when the client loads the app, and those logged
+ * when it loads it again after an edit of `src/a.ts` that keeps the denied
+ * import.
*/
async function logsAroundAnEdit(log: 'once' | 'always') {
const server = await startProtectedDevServer(deniedFileApp, { log })
- await server.load('/src/a.ts')
+ await server.load('/src/entry.ts')
const first = reportedViolations(server.logs)
await server.edit({
'src/a.ts': `import { secret } from './secret.server'
@@ -117,7 +179,7 @@ export const read = () => secret()
export const other = 1
`,
})
- await server.load('/src/a.ts')
+ await server.load('/src/entry.ts')
return {
first,
afterEdit: reportedViolations(server.logs).slice(first.length),
@@ -135,7 +197,16 @@ export const other = 1
timeout,
)
- test(
+ // Bug: same as "added by an edit of the importer is logged at the use"
+ // above: the edit drops the import graph edges to `src/a.ts`, and the
+ // violation found when it is transformed again is discarded as unreachable
+ // from Start's router entry.
+ // Impact: with `log: 'always'`, a violation is not logged again after an
+ // edit of its module.
+ // Source: found registering Start's entries in the dev harness (mutation
+ // run R3, harness gap H1); this test passed while the harness registered
+ // no entry.
+ test.fails(
"'always' logs a violation again after an edit",
async () => {
const { first, afterEdit } = await logsAroundAnEdit('always')
@@ -147,6 +218,30 @@ export const other = 1
timeout,
)
+ // Source: mutation run R3 ip-vite V15.
+ test(
+ "'always' does not log a violation again when the client loads other modules",
+ async () => {
+ const server = await startProtectedDevServer(
+ {
+ ...deniedFileApp,
+ 'src/b.ts': `export const b = 1\n`,
+ 'src/c.ts': `export const c = 1\n`,
+ 'src/entry.ts': `export * from './a'
+export { b } from './b'
+`,
+ },
+ { log: 'always' },
+ )
+ await server.load('/src/entry.ts')
+ const first = reportedViolations(server.logs)
+ expect(first).not.toEqual([])
+ await server.load('/src/c.ts')
+ expect(reportedViolations(server.logs)).toEqual(first)
+ },
+ timeout,
+ )
+
// Bug: in dev, one client import of a denied file is logged twice: once
// for the import as written (`./secret.server`) and once for the resolved
// path the dev mock rewrite puts in its place, which Vite resolves again;
@@ -161,20 +256,135 @@ export const other = 1
"'once' logs one warning for one import",
async () => {
const server = await startProtectedDevServer(deniedFileApp)
- await server.load('/src/a.ts')
+ await server.load('/src/entry.ts')
expect(reportedViolations(server.logs)).toHaveLength(1)
},
timeout,
)
})
+describe('a client import of a denied specifier', () => {
+ /**
+ * `src/a.ts` uses the denied file `src/secret.server.ts` and the denied
+ * specifier `@tanstack/react-start/server`.
+ */
+ const deniedSpecifierApp = {
+ 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`,
+ 'src/a.ts': `import { secret } from './secret.server'
+import { getRequest } from '@tanstack/react-start/server'
+export const read = () => String(secret())
+export const request = () => getRequest()
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ }
+
+ // Source: mutation run R3 ip-vite V7.
+ test(
+ 'is logged at its use, next to the use of the denied file',
+ async () => {
+ const server = await startProtectedDevServer(deniedSpecifierApp)
+ await server.load('/src/entry.ts')
+ expect(loggedViolations(server.logs).sort()).toEqual([
+ 'client src/a.ts:3:34',
+ 'client src/a.ts:4:30',
+ ])
+ },
+ timeout,
+ )
+
+ // Source: mutation run R3 ip-vite V12.
+ test(
+ 'is a mock that reports its use in the browser console, naming the import, like the mock of the denied file',
+ async () => {
+ const server = await startProtectedDevServer(deniedSpecifierApp)
+ const errors: Array = []
+ vi.spyOn(console, 'error').mockImplementation((error: unknown) => {
+ errors.push(error instanceof Error ? error.message : String(error))
+ })
+ const module = await evaluateServed(server, '/src/entry.ts')
+ expect(errors).toEqual([])
+ expect(String(module.request())).toBe('[import-protection mock]')
+ expect(module.read()).toBe('[import-protection mock]')
+ const reports = errors
+ .filter((error) => error.includes('Mocked import used in dev client'))
+ .map((error) => ({
+ denied: /Denied import: "([^"]+)"/.exec(error)?.[1],
+ access: /Access: (.*)/.exec(error)?.[1],
+ }))
+ expect(reports).toContainEqual({
+ denied: '@tanstack/react-start/server',
+ access: expect.stringMatching(/\bgetRequest\(\)/),
+ })
+ expect(reports).toContainEqual({
+ denied: 'src/secret.server.ts',
+ access: expect.stringMatching(/\bsecret\(\)/),
+ })
+ },
+ timeout,
+ )
+})
+
+describe('onViolation', () => {
+ // Source: mutation run R3 ip-vite V8.
+ test(
+ 'returning false silences the warning; it receives the importer',
+ async () => {
+ const importers: Array = []
+ const server = await startProtectedDevServer(deniedFileApp, {
+ onViolation: (info) => {
+ importers.push(info.importer)
+ return false
+ },
+ })
+ const client = await server.load('/src/entry.ts')
+ expect(modulesContaining(client, 'MARK_SECRET')).toEqual([])
+ expect(reportedViolations(server.logs)).toEqual([])
+ expect(
+ distinct(
+ importers.map((importer) => path.relative(server.root, importer)),
+ ),
+ ).toEqual(['src/a.ts'])
+ },
+ timeout,
+ )
+
+ test.each([
+ { name: 'a denied file', files: deniedFileApp },
+ {
+ name: 'a denied specifier',
+ files: {
+ 'src/a.ts': `import { getRequest } from '@tanstack/react-start/server'
+export const read = () => getRequest()
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ },
+ },
+ ])(
+ 'with behavior "error", an import of $name it allows loads',
+ async ({ files }) => {
+ const server = await startProtectedDevServer(files, {
+ behavior: 'error',
+ onViolation: () => false,
+ })
+ const client = await server.load('/src/entry.ts')
+ expect(Object.keys(client)).toContain('/src/a.ts')
+ expect(reportedViolations(server.logs)).toEqual([])
+ },
+ timeout,
+ )
+})
+
describe('a client import of a server-only-marked module', () => {
- /** `src/a.ts` imports `src/b.ts`, which starts with `marker`. */
+ /**
+ * `src/a.ts` imports `src/b.ts`, which starts with `marker`; the router
+ * entry re-exports `src/a.ts`.
+ */
const markedApp = (marker: string) => ({
'src/b.ts': `${marker}export const secret = () => 'MARK_B_CODE'\n`,
'src/a.ts': `import { secret } from './b'
export const read = () => secret()
`,
+ 'src/entry.ts': `export * from './a'\n`,
})
const serverOnly = `import '@tanstack/react-start/server-only'\n`
@@ -185,12 +395,24 @@ export const read = () => secret()
async () => {
const server = await startProtectedDevServer(markedApp(serverOnly))
await server.load('/src/b.ts')
- const client = await server.load('/src/a.ts')
+ const client = await server.load('/src/entry.ts')
expect(modulesContaining(client, 'MARK_B_CODE')).toEqual([])
},
timeout,
)
+ // Source: mutation run R3 ip-vite V2 (the logging half of the pin below,
+ // which passes).
+ test(
+ "is logged at the importer's use when the client loads the importer first",
+ async () => {
+ const server = await startProtectedDevServer(markedApp(serverOnly))
+ await server.load('/src/entry.ts')
+ expect(loggedViolations(server.logs)).toContain('client src/a.ts:2:27')
+ },
+ timeout,
+ )
+
// Bug: import protection decides to mock an import of a server-only-marked
// module when it resolves the import, and only knows a module is marked
// once it has transformed it. A browser requests the importer first, so
@@ -206,7 +428,7 @@ export const read = () => secret()
'is mocked when the client loads the importer first',
async () => {
const server = await startProtectedDevServer(markedApp(serverOnly))
- const client = await server.load('/src/a.ts')
+ const client = await server.load('/src/entry.ts')
expect(modulesContaining(client, 'MARK_B_CODE')).toEqual([])
},
timeout,
@@ -227,14 +449,170 @@ export const read = () => secret()
async () => {
const server = await startProtectedDevServer(markedApp(''))
expect(
- modulesContaining(await server.load('/src/a.ts'), 'MARK_B_CODE'),
+ modulesContaining(await server.load('/src/entry.ts'), 'MARK_B_CODE'),
).toEqual(['/src/b.ts'])
await server.edit(markedApp(serverOnly))
await server.load('/src/b.ts')
- const client = await server.load('/src/a.ts')
+ const client = await server.load('/src/entry.ts')
expect(reportedViolations(server.logs)).not.toEqual([])
expect(modulesContaining(client, 'MARK_B_CODE')).toEqual([])
},
timeout,
)
})
+
+// Source: mutation run R3 ip-vite V3 (the SSR counterpart of the client
+// control above).
+test(
+ 'an SSR import of a client-only-marked module the server already transformed is mocked and logged',
+ async () => {
+ const server = await startProtectedDevServer({
+ 'src/b.ts': `import '@tanstack/react-start/client-only'
+export const widget = () => 'MARK_B_CODE'
+`,
+ 'src/a.ts': `import { widget } from './b'
+export const read = () => widget()
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ })
+ await server.load('/src/b.ts', { environment: 'ssr' })
+ const served = await server.load('/src/entry.ts', { environment: 'ssr' })
+ expect(modulesContaining(served, 'MARK_B_CODE')).toEqual([])
+ expect(loggedViolations(server.logs)).toContain('server src/a.ts:2:27')
+ },
+ timeout,
+)
+
+// Source: mutation run R3 ip-vite V3 (the SSR counterpart of the client
+// logging test "is logged at the importer's use when the client loads the
+// importer first").
+test(
+ 'an SSR load of a client-only-marked module through its importer logs the marker and the import',
+ async () => {
+ const server = await startProtectedDevServer({
+ 'src/b.ts': `import '@tanstack/react-start/client-only'
+export const widget = () => 'MARK_B_CODE'
+`,
+ 'src/a.ts': `import { widget } from './b'
+export const read = () => widget()
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ })
+ await server.load('/src/entry.ts', { environment: 'ssr' })
+ expect(loggedViolations(server.logs).sort()).toEqual([
+ 'server src/a.ts:2:27',
+ 'server src/b.ts:1:8',
+ ])
+ },
+ timeout,
+)
+
+// Source: mutation run R3 ip-vite V9.
+test(
+ 'the server fn provider environment (rsc) mocks and logs an import of a .client file',
+ async () => {
+ const server = await startProtectedDevServer(
+ {
+ 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`,
+ 'src/fn.ts': `import { widget } from './widget.client'
+export const read = () => widget()
+`,
+ 'src/entry.ts': `export * from './fn'\n`,
+ },
+ {},
+ { providerEnvironment: 'rsc' },
+ )
+ const served = await server.load('/src/entry.ts', { environment: 'rsc' })
+ expect(modulesContaining(served, 'MARK_CLIENT')).toEqual([])
+ expect(loggedViolations(server.logs)).toEqual(['server src/fn.ts:2:27'])
+ },
+ timeout,
+)
+
+describe('reachability from the router entry', () => {
+ // Source: mutation run R3 ip-vite V4(a).
+ test(
+ 'only a leak is logged, not the imports a server fn factory uses inside .server()',
+ async () => {
+ const server = await startProtectedDevServer(serverFnFactoryApp())
+ const client = await server.load('/src/entry.ts')
+ expect(modulesContaining(client, 'MARK_SECRET')).toEqual([])
+ expect(loggedViolations(server.logs)).toEqual(['client src/leak.ts:2:27'])
+ },
+ timeout,
+ )
+
+ // Source: mutation run R3 ip-vite V4(b); a control of the harness
+ // registering Start's router entry: the factory module is a module the
+ // Start compiler looked up, whose violations are only logged once they
+ // are known to be reachable from an entry (without the entry, this test
+ // fails).
+ test(
+ 'a server fn factory module that also calls a denied import at the top level is logged',
+ async () => {
+ const server = await startProtectedDevServer(
+ serverFnFactoryApp(factoryTopLevelCall),
+ )
+ await server.load('/src/entry.ts')
+ expect(loggedViolations(server.logs)).toContain(
+ 'client src/factory.ts:9:24',
+ )
+ },
+ timeout,
+ )
+})
+
+describe('diagnostics', () => {
+ // Source: mutation run R3 ip-vite V14 (the served code of a JSX module
+ // has an extra import line, of the JSX runtime).
+ test.each([
+ {
+ name: 'a use of a denied specifier (client)',
+ a: `import { getRequest } from '@tanstack/react-start/server'
+export const Page = () => {String(getRequest())}
+`,
+ environment: 'client',
+ importProtection: {},
+ expected: 'client src/a.tsx:2:40',
+ },
+ {
+ name: 'a use of a file denied by a custom file rule (client)',
+ a: `import { secret } from './private/db'
+export const Page = () => {String(secret())}
+`,
+ environment: 'client',
+ importProtection: { client: { files: ['**/private/**'] } },
+ expected: 'client src/a.tsx:2:40',
+ },
+ {
+ name: 'a client-only marker (SSR)',
+ a: `import '@tanstack/react-start/client-only'
+export const Page = () => {String(1)}
+`,
+ environment: 'ssr',
+ importProtection: {},
+ expected: 'server src/a.tsx:1:8',
+ },
+ ])(
+ 'in a JSX module, $name is located on its source line',
+ async ({ a, environment, importProtection, expected }) => {
+ const server = await startProtectedDevServer(
+ {
+ ...reactRuntimeFiles,
+ 'src/private/db.ts': `export const secret = () => 'MARK_SECRET'\n`,
+ 'src/a.tsx': a,
+ 'src/entry.ts': `export * from './a'\n`,
+ },
+ importProtection,
+ )
+ await server.load('/src/entry.ts', { environment })
+ // (SSR: the entry's import of the marked module is reported as well)
+ expect(
+ loggedViolations(server.logs).filter((violation) =>
+ violation.includes(' src/a.tsx'),
+ ),
+ ).toEqual([expected])
+ },
+ timeout,
+ )
+})
diff --git a/packages/start-plugin-core/tests/importProtection/start-apps.ts b/packages/start-plugin-core/tests/importProtection/start-apps.ts
index fb7555047b0..9015d5de299 100644
--- a/packages/start-plugin-core/tests/importProtection/start-apps.ts
+++ b/packages/start-plugin-core/tests/importProtection/start-apps.ts
@@ -13,34 +13,105 @@ import type { DevServer } from '@tanstack/compiler-test-utils/dev-server'
import type { ImportProtectionOptions } from '../../src/schema'
import type { ImportProtectionPluginOptions } from '../../src/vite/import-protection-plugin/types'
+export interface ProtectedDevServerOptions {
+ /**
+ * The app's router entry (relative to the app root), registered with
+ * import protection like `tanStackStart()` registers the app's
+ * `src/router.tsx`: the module every client module of a Start app is
+ * reached from. Default `src/entry.ts`. The start entry `src/start.ts` is
+ * registered when the app has one.
+ */
+ router?: string
+ /**
+ * The server fn provider environment, when it is not `ssr` (e.g. `rsc`):
+ * a third, server environment of the dev server.
+ */
+ providerEnvironment?: string
+}
+
/**
* Starts a Vite dev server (`startDevServer`) on an app of `files` with
* Start's import protection, configured by `importProtection` (Start's
- * `importProtection` option; source directory `src`).
+ * `importProtection` option; source directory `src`), and with Start's
+ * entries registered (`options.router`).
*/
export function startProtectedDevServer(
files: Record,
importProtection: ImportProtectionOptions = {},
+ options: ProtectedDevServerOptions = {},
): Promise {
+ const { router = 'src/entry.ts', providerEnvironment = 'ssr' } = options
return startDevServer({
files,
+ config:
+ providerEnvironment === 'ssr'
+ ? undefined
+ : { environments: { [providerEnvironment]: { consumer: 'server' } } },
plugins: ({ root }) => [
importProtectionPlugin({
getConfig: (() => ({
startConfig: { importProtection },
- resolvedStartConfig: { srcDirectory: path.join(root, 'src') },
+ resolvedStartConfig: {
+ srcDirectory: path.join(root, 'src'),
+ routerFilePath: path.join(root, router),
+ startFilePath:
+ 'src/start.ts' in files
+ ? path.join(root, 'src/start.ts')
+ : undefined,
+ },
})) as unknown as ImportProtectionPluginOptions['getConfig'],
framework: 'react',
environments: [
{ name: 'client', type: 'client' },
{ name: 'ssr', type: 'server' },
+ ...(providerEnvironment === 'ssr'
+ ? []
+ : [{ name: providerEnvironment, type: 'server' as const }]),
],
- providerEnvName: 'ssr',
+ providerEnvName: providerEnvironment,
}),
],
})
}
+/**
+ * An app whose server fn factory module `src/factory.ts` uses its denied
+ * imports (the denied file `src/secret.server.ts` and the denied specifier
+ * `@tanstack/react-start/server`) only inside `createMiddleware().server()`,
+ * which the client never runs, and whose `src/leak.ts` uses the denied file
+ * in a function the client may call; the router entry `src/entry.ts`
+ * re-exports a server fn of the factory and the leak. `factoryTail` is
+ * appended to the factory module (from line 9).
+ */
+export const serverFnFactoryApp = (factoryTail = '') => ({
+ 'src/secret.server.ts': `export const getSecret = () => 'MARK_SECRET'\n`,
+ 'src/factory.ts': `import { createMiddleware, createServerFn } from '@tanstack/react-start'
+import { getRequest } from '@tanstack/react-start/server'
+import { getSecret } from './secret.server'
+const mw = createMiddleware({ type: 'function' }).server(({ next }) => {
+ const req = getRequest()
+ return next({ context: { req, secret: getSecret() } })
+})
+export const createSecretFn = createServerFn().middleware([mw])
+${factoryTail}`,
+ 'src/usage.ts': `import { createSecretFn } from './factory'
+export const fn = createSecretFn().handler(async ({ context }) => context)
+`,
+ 'src/leak.ts': `import { getSecret } from './secret.server'
+export const leak = () => getSecret()
+`,
+ 'src/entry.ts': `export { fn } from './usage'
+export { leak } from './leak'
+`,
+})
+
+/**
+ * A `factoryTail` of `serverFnFactoryApp` calling the denied
+ * `getRequest()` at the top level of the factory module, which the client
+ * runs (located at `src/factory.ts:9:24`).
+ */
+export const factoryTopLevelCall = `export const request = getRequest()\n`
+
/** A stand-in `react` package with the JSX runtime compiled JSX imports. */
export const reactRuntimeFiles: Record = {
'node_modules/react/package.json': JSON.stringify({
diff --git a/packages/start-plugin-core/tests/known-bugs-dev-server.test.ts b/packages/start-plugin-core/tests/known-bugs-dev-server.test.ts
index 50916cb1497..9421caa980b 100644
--- a/packages/start-plugin-core/tests/known-bugs-dev-server.test.ts
+++ b/packages/start-plugin-core/tests/known-bugs-dev-server.test.ts
@@ -208,21 +208,30 @@ export const base = createServerFn()
})
describe('import protection', () => {
- /** Import protection with the default rules, in its dev (mock) mode. */
- const importProtection = ({ root }: { root: string }) => [
- importProtectionPlugin({
- getConfig: (() => ({
- startConfig: { importProtection: {} },
- resolvedStartConfig: { srcDirectory: path.join(root, 'src') },
- })) as unknown as ImportProtectionPluginOptions['getConfig'],
- framework: 'react',
- environments: [
- { name: 'client', type: 'client' },
- { name: 'ssr', type: 'server' },
- ],
- providerEnvName: 'ssr',
- }),
- ]
+ /**
+ * Import protection with the default rules, in its dev (mock) mode, with
+ * `router` (the module the test loads first) registered as the app's router
+ * entry, like `tanStackStart()` registers `src/router.tsx`.
+ */
+ const importProtection =
+ (router: string) =>
+ ({ root }: { root: string }) => [
+ importProtectionPlugin({
+ getConfig: (() => ({
+ startConfig: { importProtection: {} },
+ resolvedStartConfig: {
+ srcDirectory: path.join(root, 'src'),
+ routerFilePath: path.join(root, router),
+ },
+ })) as unknown as ImportProtectionPluginOptions['getConfig'],
+ framework: 'react',
+ environments: [
+ { name: 'client', type: 'client' },
+ { name: 'ssr', type: 'server' },
+ ],
+ providerEnvName: 'ssr',
+ }),
+ ]
/**
* A client module with a server fn whose top-level code calls a function
@@ -244,7 +253,7 @@ export const fn = createServerFn().handler(async () => request)
async () => {
const server = await startDevServer({
files: deniedCallFiles('./request.server'),
- plugins: importProtection,
+ plugins: importProtection('src/a.ts'),
})
await expect(server.transform('/src/a.ts')).resolves.toEqual(
expect.any(String),
@@ -267,7 +276,7 @@ export const fn = createServerFn().handler(async () => request)
async () => {
const server = await startDevServer({
files: deniedCallFiles('@tanstack/react-start/server'),
- plugins: importProtection,
+ plugins: importProtection('src/a.ts'),
})
await expect(server.transform('/src/a.ts')).resolves.toEqual(
expect.any(String),
@@ -292,7 +301,7 @@ export const secret = () => 'MARK_B_CODE'
export const read = () => secret()
`,
},
- plugins: importProtection,
+ plugins: importProtection('src/a.ts'),
})
await server.load('/src/b.ts')
expect(
@@ -352,7 +361,7 @@ export const read = () => secret() + '!'
'src/request.server.ts': `export const getRequest = () => ({ url: '/' })\n`,
...files,
},
- plugins: importProtection,
+ plugins: importProtection('src/c.ts'),
})
const module = await evaluateServed(server, '/src/c.ts').catch(
(error: unknown) => error,
From c079f2e35180ec2c41ce37c6a8a28b29c048b867 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:53:07 +0200
Subject: [PATCH 0284/1425] test(start-plugin-core): pin dev import-protection
bugs (factory lookup, Rsbuild module types, throwing onViolation)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pins (each verified red for the bug as a plain test):
- REPORT §6 #4 (ip-vite B3): in Vite dev with behavior "error", a server fn
factory module calling a denied import at the top level loads silently
(`serverFnLookupModules` keys by path without the lookup query). Red:
the load resolves ('loaded'). Control: the same call in another module
fails to load. The mock-mode counterpart passes (dev.test.ts V4(b)).
- #2 (ip-rsbuild R2): Rsbuild dev never checks importers of non-JS module
types (`.mdx`-like): a denied file is mocked but not reported, a denied
specifier's real code runs. Red: reported [] (and ranRealCode true).
Flipped by spc:7307 and 7168 (denied-file row).
- #23: an `onViolation` that throws hangs the Rsbuild dev compile. Bounded
wait (compileTimeout 3 s); red: "Rsbuild did not compile the app within
3000 ms"; the harness closes the server afterwards.
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/known-bugs.test.ts | 174 ++++++++++++++++++
1 file changed, 174 insertions(+)
diff --git a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
index 91023bdc318..5f6db47205e 100644
--- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
@@ -6,7 +6,14 @@ import { transformWithOxc } from 'vite'
import { describe, expect, test } from 'vitest'
import { rewriteDeniedImports } from '../../src/import-protection/rewrite'
import { buildStart, startBundlers } from '../build-harness'
+import { runRsbuildClient, startRsbuildDevServer } from '../rsbuild-dev-server'
import { evaluateWithMock, isMock, selfMock } from './mock-helpers'
+import {
+ factoryTopLevelCall,
+ reportedViolations,
+ serverFnFactoryApp,
+ startProtectedDevServer,
+} from './start-apps'
const mockModule = `data:text/javascript,${encodeURIComponent(
`export default new Proxy({}, { get: (_, key) => typeof key === 'string' ? 'mock:' + key : undefined })`,
@@ -272,3 +279,170 @@ describe.each(startBundlers)('%s build', (bundler) => {
60_000,
)
})
+
+describe('Vite dev, behavior "error"', () => {
+ /**
+ * `serverFnFactoryApp` with a top-level call of the denied `getRequest()`
+ * in `module` (the server fn factory module or another one), without the
+ * leak (behavior "error" stops at the first violation).
+ */
+ const topLevelCallApp = (module: 'src/factory.ts' | 'src/other.ts') => {
+ const app: Record = {
+ ...serverFnFactoryApp(
+ module === 'src/factory.ts' ? factoryTopLevelCall : '',
+ ),
+ 'src/entry.ts': `export { fn } from './usage'\nexport * from './other'\n`,
+ 'src/other.ts':
+ module === 'src/other.ts'
+ ? `import { getRequest } from '@tanstack/react-start/server'\n${factoryTopLevelCall}`
+ : `export const other = 1\n`,
+ }
+ delete app['src/leak.ts']
+ return app
+ }
+
+ /** The violations loading the app reports, or `'loaded'`. */
+ async function loadOutcome(files: Record) {
+ const server = await startProtectedDevServer(files, { behavior: 'error' })
+ return server.load('/src/entry.ts').then(
+ () => 'loaded',
+ (error: Error) =>
+ reportedViolations(error.message).map(
+ ({ environment, importer }) =>
+ `${environment} ${importer.replace(/:.*/, '')}`,
+ ),
+ )
+ }
+
+ // Control for the pin below (same harness): a top-level call of a denied
+ // specifier in a module the Start compiler did not look up fails to load.
+ test('a module calling a denied import at the top level fails to load', async () => {
+ expect(await loadOutcome(topLevelCallApp('src/other.ts'))).toEqual([
+ 'client src/other.ts',
+ ])
+ }, 30_000)
+
+ // Bug: the Start compiler resolves the imports of a server fn factory
+ // module with a lookup query (`SERVER_FN_LOOKUP`), and import protection
+ // records the module as a lookup module by its path without the query
+ // (`serverFnLookupModules`, vite/import-protection-plugin/plugin.ts
+ // `resolveId`). The client's own transform of the module then counts as a
+ // lookup too, whose violations behavior "error" silences in dev (no later
+ // verification).
+ // Impact: in dev with behavior "error", the client of a server fn factory
+ // module that calls a denied import (here `getRequest()` of
+ // `@tanstack/react-start/server`) at the top level gets the real module,
+ // without any error; in mock mode the same module is mocked and logged
+ // (dev.test.ts "a server fn factory module that also calls a denied import
+ // at the top level is logged").
+ // Source: mutation run R3, ip-vite B3 (REPORT §6 #4).
+ test.fails(
+ 'a server fn factory module calling a denied import at the top level fails to load',
+ async () => {
+ expect(await loadOutcome(topLevelCallApp('src/factory.ts'))).toEqual([
+ 'client src/factory.ts',
+ ])
+ },
+ 30_000,
+ )
+})
+
+describe('Rsbuild dev', () => {
+ /** A module type another loader compiles to JS (a stand-in for `.mdx`). */
+ const pageModules = {
+ tools: {
+ rspack: {
+ module: { rules: [{ test: /\.page$/, type: 'javascript/auto' }] },
+ },
+ },
+ } as const
+
+ // Bug: Rsbuild import protection checks importers by file extension
+ // (`isImportProtectionSourceFile`, rsbuild/import-protection.ts, only
+ // JS/TS extensions), and its loader rule only rewrites those files, so an
+ // importer of another module type that compiles to JS (e.g. `.mdx`) is
+ // never checked: a denied file is mocked (the file rule) but not reported,
+ // and a denied specifier is neither reported nor mocked.
+ // Impact: behavior "error" does not fail for denied imports of such
+ // modules, and the real code of a denied specifier runs on the client.
+ // Source: mutation run R3, ip-rsbuild R2 (REPORT §6 #2); the JS/TS
+ // extensions are covered by rsbuild/import-protection.test.ts "is reported
+ // and mocked when it is a .$extension file".
+ test.fails.each([
+ {
+ name: 'a denied file',
+ files: {
+ 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`,
+ 'src/home.page': `import { secret } from './secret.server'\nexport const page = () => secret()\n`,
+ },
+ },
+ {
+ name: 'a denied specifier',
+ files: {
+ 'node_modules/db-lib/package.json': JSON.stringify({
+ name: 'db-lib',
+ type: 'module',
+ exports: { '.': './index.js' },
+ }),
+ 'node_modules/db-lib/index.js': `export const secret = () => 'MARK_LIB'\n`,
+ 'src/home.page': `import { secret } from 'db-lib'\nexport const page = () => secret()\n`,
+ },
+ },
+ ])(
+ 'a module of another type importing $name is reported, and its real code does not run',
+ async ({ files }) => {
+ const server = await startRsbuildDevServer({
+ importProtection: {
+ behavior: 'error',
+ client: { specifiers: ['db-lib'] },
+ },
+ config: pageModules,
+ files: {
+ ...files,
+ 'src/entry.ts': `import { page } from './home.page'\nconsole.log(page())\n`,
+ },
+ })
+ const messages = await runRsbuildClient(server)
+ expect({
+ reported: reportedViolations(server.errors).map(
+ ({ environment, importer }) =>
+ `${environment} ${importer.replace(/:.*/, '')}`,
+ ),
+ ranRealCode: messages.some((message) => message.includes('MARK_')),
+ }).toEqual({ reported: ['client src/home.page'], ranRealCode: false })
+ },
+ 30_000,
+ )
+
+ // Bug: in Rsbuild dev, an error thrown by `onViolation` is never reported:
+ // the compile does not finish (no done, no error), while a build fails with
+ // the error (rsbuild/import-protection.ts `reportViolation`, awaited in the
+ // `processAssets` hook).
+ // Impact: a throwing `onViolation` hangs the Rsbuild dev server on its
+ // first compile, with no message.
+ // Source: mutation run R3, ip-rsbuild (REPORT §6 #23). The wait is bounded
+ // (`compileTimeout`) so the pin fails fast; the harness closes the dev
+ // server when its first compile does not finish.
+ test.fails(
+ 'an onViolation that throws ends the compile with its error',
+ async () => {
+ const outcome = await startRsbuildDevServer({
+ importProtection: {
+ onViolation: () => {
+ throw new Error('MARK_THROWN')
+ },
+ },
+ compileTimeout: 3_000,
+ files: {
+ 'src/secret.server.ts': `export const secret = () => 'secret'\n`,
+ 'src/entry.ts': `import { secret } from './secret.server'\nconsole.log(secret())\n`,
+ },
+ }).then(
+ (server) => server.errors.join('\n'),
+ (error: Error) => error.message,
+ )
+ expect(outcome).toContain('MARK_THROWN')
+ },
+ 15_000,
+ )
+})
From aa53dfaf45a4b7a529aad5acd1005e008aab3c76 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:57:23 +0200
Subject: [PATCH 0285/1425] test(start-plugin-core): type the new dev
import-protection tests
`onViolation` receives `unknown` in Start's options type: cast it to
`ViolationInfo` like the neighbouring tests; type the Rsbuild config and
the rows of the module-type pin.
Co-Authored-By: Claude Opus 5.5
---
.../start-plugin-core/tests/importProtection/dev.test.ts | 3 ++-
.../tests/importProtection/known-bugs.test.ts | 7 ++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts
index 7e85a18bc56..90238c1bbc2 100644
--- a/packages/start-plugin-core/tests/importProtection/dev.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts
@@ -19,6 +19,7 @@ import {
startProtectedDevServer,
} from './start-apps'
import type { ImportProtectionOptions } from '../../src/schema'
+import type { ViolationInfo } from '../../src/import-protection/trace'
const timeout = 30_000
@@ -332,7 +333,7 @@ describe('onViolation', () => {
const importers: Array = []
const server = await startProtectedDevServer(deniedFileApp, {
onViolation: (info) => {
- importers.push(info.importer)
+ importers.push((info as ViolationInfo).importer)
return false
},
})
diff --git a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
index 5f6db47205e..a8f30085454 100644
--- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
@@ -14,6 +14,7 @@ import {
serverFnFactoryApp,
startProtectedDevServer,
} from './start-apps'
+import type { RsbuildConfig } from '@rsbuild/core'
const mockModule = `data:text/javascript,${encodeURIComponent(
`export default new Proxy({}, { get: (_, key) => typeof key === 'string' ? 'mock:' + key : undefined })`,
@@ -349,13 +350,13 @@ describe('Vite dev, behavior "error"', () => {
describe('Rsbuild dev', () => {
/** A module type another loader compiles to JS (a stand-in for `.mdx`). */
- const pageModules = {
+ const pageModules: RsbuildConfig = {
tools: {
rspack: {
module: { rules: [{ test: /\.page$/, type: 'javascript/auto' }] },
},
},
- } as const
+ }
// Bug: Rsbuild import protection checks importers by file extension
// (`isImportProtectionSourceFile`, rsbuild/import-protection.ts, only
@@ -368,7 +369,7 @@ describe('Rsbuild dev', () => {
// Source: mutation run R3, ip-rsbuild R2 (REPORT §6 #2); the JS/TS
// extensions are covered by rsbuild/import-protection.test.ts "is reported
// and mocked when it is a .$extension file".
- test.fails.each([
+ test.fails.each<{ name: string; files: Record }>([
{
name: 'a denied file',
files: {
From 28f95a1c7e1d5447b0ebfe5b1844a5b0f3230bd7 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:36:39 +0200
Subject: [PATCH 0286/1425] test(start-plugin-core): cover mock-mode and marker
outcomes of import-protection builds
Round 3 mutation gaps (both bundlers, build.test.ts):
- ip-location P1: re-exports from a denied specifier are mocked in mock
mode; other re-exports keep their values.
- ip-vite V1: a denied .server.mts/.server.mjs file is mocked.
- ip-vite V10: mock mode with a CSS import builds and mocks.
- ip-vite V11: the mocks of a build are silent when used.
- ip-misc P2: a server import of a client-only-marked module fails the
build; in mock mode it is mocked (Vite: new row of the existing
markerMockTest pin, same bug).
- ip-misc P3 + ip-vite V5: the client (server) build may import a
client-only (server-only) marked module from several modules; replaces
the single-importer server test.
- ip-vite V16 + ip-rsbuild R11: such a clean build prints no
import-protection output (no perf report by default).
- ip-location P4: a .server file in a dot directory is denied.
Kills: spc:1254, 1282, 1292, 5319, 5243, 5349, 7910, 7911, 7927, 7929,
7930, 7931, 7935, 1173, 1174, 4299, 4307, 5466, 3990, 7002
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/build.test.ts | 242 +++++++++++++++---
1 file changed, 212 insertions(+), 30 deletions(-)
diff --git a/packages/start-plugin-core/tests/importProtection/build.test.ts b/packages/start-plugin-core/tests/importProtection/build.test.ts
index b9001c49f3e..0c152b56465 100644
--- a/packages/start-plugin-core/tests/importProtection/build.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/build.test.ts
@@ -38,6 +38,13 @@ export const secret = () => 'MARK_SECRET'
type DeniedModule = keyof typeof deniedModules
const deniedModuleNames = Object.keys(deniedModules) as Array
+/** A client-only module (denied to the server by its marker). */
+const clientOnlyMarked = {
+ 'src/marked.ts': `import '@tanstack/react-start/client-only'
+export const secret = () => 'MARK_SECRET'
+`,
+}
+
/** An app whose client entry `src/entry.ts` uses `denied`. */
function appUsing(denied: DeniedModule) {
const { importedAs, files } = deniedModules[denied]
@@ -132,6 +139,16 @@ describe.each(startBundlers)('%s build, default rules', (bundler) => {
files: appUsing('a denied file'),
reported: 'src/entry.ts:2:27',
},
+ {
+ name: 'a client import of a *.server.* file in a dot directory',
+ files: {
+ 'src/.private/db.server.ts': `export const secret = () => 'MARK_SECRET'\n`,
+ 'src/entry.ts': `import { secret } from './.private/db.server'
+export const read = () => secret()
+`,
+ },
+ reported: 'src/entry.ts:2:27',
+ },
{
name: 'a client import of @tanstack/react-start/server',
files: {
@@ -176,17 +193,71 @@ export const read = () => getRequest()
)
test(
- 'the server build may import a server-only-marked module',
+ 'a server import of a client-only-marked module fails the build, naming the module',
async () => {
- const { ssr } = await buildStart({
+ const error = await rejection(
+ buildStart({
+ bundler,
+ files: {
+ ...clientOnlyMarked,
+ 'src/server.ts': `import { secret } from './marked'
+export const read = () => secret()
+`,
+ 'src/client.ts': `export const read = () => 'client'\n`,
+ },
+ entry: { client: 'src/client.ts', ssr: 'src/server.ts' },
+ }),
+ )
+ expect(reportedViolations(error.message)).toEqual([
+ {
+ environment: 'server',
+ importer: expect.stringMatching(/^src\/marked\.ts/),
+ },
+ ])
+ },
+ timeout,
+ )
+
+ // Several modules import the marked module (directly and through a
+ // re-export), so the marker check sees it imported after it has seen its
+ // marker.
+ test.each([
+ { env: 'ssr', marker: 'server-only', build: 'server' },
+ { env: 'client', marker: 'client-only', build: 'client' },
+ ] as const)(
+ 'the $build build may import a $marker-marked module from several modules, without any import-protection output',
+ async ({ env, marker }) => {
+ const built = await buildStart({
bundler,
files: {
- ...appUsing('a server-only-marked module'),
- 'src/client.ts': `export const read = () => 'client'\n`,
+ 'src/marked.ts': `import '@tanstack/react-start/${marker}'
+export const secret = () => 'MARK_SECRET'
+`,
+ 'src/d.ts': `import { secret } from './marked'
+export const d = () => secret()
+`,
+ 'src/c.ts': `export { d } from './d'\n`,
+ 'src/user.ts': `import { secret } from './marked'
+import { d } from './c'
+export const read = () => secret() + '|' + d()
+`,
+ 'src/other.ts': `export const read = () => 'other'\n`,
},
- entry: { client: 'src/client.ts', ssr: 'src/entry.ts' },
+ entry:
+ env === 'ssr'
+ ? { client: 'src/other.ts', ssr: 'src/user.ts' }
+ : { client: 'src/user.ts', ssr: 'src/other.ts' },
})
- expect((await ssr.run(`return entry.read()`)).result).toBe('MARK_SECRET')
+ expect((await built[env].run(`return entry.read()`)).result).toBe(
+ 'MARK_SECRET|MARK_SECRET',
+ )
+ expect(
+ [
+ ...built.client.warnings,
+ ...built.ssr.warnings,
+ ...built.client.consoleMessages,
+ ].filter((message) => message.includes('import-protection')),
+ ).toEqual([])
},
timeout,
)
@@ -205,59 +276,170 @@ export const read = () => getRequest()
})
// `behavior: 'mock'` builds: a denied import is replaced with a mock (and
-// a warning); the real module's code stays out of the client bundle.
+// a warning); the real module's code stays out of the bundle.
describe.each(startBundlers)('%s build, mock mode', (bundler) => {
- /** Whether the client gets a mock for `secret` in a mock-mode build. */
- async function mockedRead(denied: DeniedModule) {
- const { client } = await buildStart({
+ interface MockedImport {
+ /** The specifier `src/entry.ts` imports `secret` with. */
+ importedAs: string
+ files: Record
+ /** Other imports of `src/entry.ts`. */
+ imports?: string
+ /** The environment that imports it (default `client`). */
+ env?: 'client' | 'ssr'
+ }
+
+ /**
+ * What the environment that imports `secret` gets for it in a mock-mode
+ * build.
+ */
+ async function mockedRead(denied: MockedImport) {
+ const env = denied.env ?? 'client'
+ const built = await buildStart({
bundler,
files: {
- ...appUsing(denied),
- 'src/entry.ts': `import { secret } from '${deniedModules[denied].importedAs}'
+ ...denied.files,
+ 'src/entry.ts': `${denied.imports ?? ''}import { secret } from '${denied.importedAs}'
export const read = () => secret
`,
+ 'src/other.ts': `export const read = () => 'other'\n`,
},
- entry: 'src/entry.ts',
+ entry:
+ env === 'client'
+ ? 'src/entry.ts'
+ : { client: 'src/other.ts', ssr: 'src/entry.ts' },
start: { importProtection: { behavior: 'mock' } },
})
+ const { result, logs } = await built[env].run(`const secret = entry.read()
+return String(secret())`)
return {
- warned: reportedViolations(client.warnings).length > 0,
- hasRealCode: client.where('MARK_SECRET') !== 'nowhere',
- /** What the client gets for `secret`, converted to a string. */
- read: (await client.run(`return String(entry.read())`)).result,
+ warned: reportedViolations(built[env].warnings).length > 0,
+ hasRealCode: built[env].where('MARK_SECRET') !== 'nowhere',
+ /** What calling `secret` returns, converted to a string. */
+ read: result,
+ /** What using it logged. */
+ logs,
}
}
- /** A mock converts to this text. */
+ /** A mock converts to this text; the mocks of a build are silent. */
const mocked = {
warned: true,
hasRealCode: false,
read: '[import-protection mock]',
+ logs: [],
}
- test(
- 'a denied file is mocked, with a warning',
- async () => {
- expect(await mockedRead('a denied file')).toEqual(mocked)
+ const deniedFile = (extension: string): MockedImport => ({
+ importedAs: `./secret.server.${extension}`,
+ files: {
+ [`src/secret.server.${extension}`]: `export const secret = () => 'MARK_SECRET'\n`,
+ },
+ })
+
+ const deniedFiles: Record = {
+ 'a denied file': deniedModules['a denied file'],
+ 'a denied .server.mts file': deniedFile('mts'),
+ 'a denied .server.mjs file': deniedFile('mjs'),
+ 'a denied file of a module that imports CSS': {
+ ...deniedModules['a denied file'],
+ files: {
+ ...deniedModules['a denied file'].files,
+ 'src/style.css': `.page { color: red }\n`,
+ },
+ imports: `import './style.css'\n`,
+ },
+ }
+
+ test.each(Object.keys(deniedFiles))(
+ '%s is mocked, with a warning',
+ async (name) => {
+ expect(await mockedRead(deniedFiles[name]!)).toEqual(mocked)
},
timeout,
)
- // Bug (Vite): in a build, a violation of a server-only marker only
- // replaces the marker import itself with a mock (`handleViolation` in
+ const markedModules: Record = {
+ 'a server-only-marked module': deniedModules['a server-only-marked module'],
+ 'a client-only-marked module imported by the server': {
+ importedAs: './marked',
+ files: clientOnlyMarked,
+ env: 'ssr',
+ },
+ }
+
+ // Bug (Vite): in a build, a violation of a marker (server-only in the
+ // client, client-only in the server) only replaces the marker import
+ // itself with a mock (`handleViolation` in
// vite/import-protection-plugin/plugin.ts returns a mock edge for the
// marker specifier); the marked module keeps its code, and its importers
// import it. The Rsbuild adapter replaces the module with a mock
// (self-denial in its import-protection loader). Vite dev mocks the
// importers' import (see dev.test.ts).
- // Impact: a mock-mode Vite build ships the server-only module's real code
- // in the client bundle (only a warning is logged).
- // Source: found writing the H2 rows (oracle-gaps REPORT.md).
+ // Impact: a mock-mode Vite build ships the marked module's real code in
+ // the bundle of the environment it is denied to (only a warning is
+ // logged).
+ // Source: found writing the H2 rows (oracle-gaps REPORT.md); client-only
+ // row: round 3 mutation testing (ip-misc P2).
const markerMockTest = bundler === 'vite' ? test.fails : test
- markerMockTest(
- 'a server-only-marked module is mocked, with a warning',
+ markerMockTest.each(Object.keys(markedModules))(
+ '%s is mocked, with a warning',
+ async (name) => {
+ expect(await mockedRead(markedModules[name]!)).toEqual(mocked)
+ },
+ timeout,
+ )
+
+ // A module that re-exports from a denied specifier is rewritten like a
+ // module that imports from it; re-exports of other modules keep their
+ // values.
+ test(
+ 're-exports from a denied specifier are mocked; other re-exports keep their values',
async () => {
- expect(await mockedRead('a server-only-marked module')).toEqual(mocked)
+ const { client } = await buildStart({
+ bundler,
+ files: {
+ 'node_modules/denied-pkg/package.json': JSON.stringify({
+ name: 'denied-pkg',
+ type: 'module',
+ main: 'index.js',
+ sideEffects: false,
+ }),
+ 'node_modules/denied-pkg/index.js': `export const secret = () => 'MARK_SECRET'\n`,
+ 'node_modules/safe-pkg/package.json': JSON.stringify({
+ name: 'safe-pkg',
+ type: 'module',
+ main: 'index.js',
+ }),
+ 'node_modules/safe-pkg/index.js': `export const ok = () => 'MARK_SAFE'
+export const fine = () => 'MARK_FINE'
+`,
+ 'src/barrel.ts': `export { secret } from 'denied-pkg'\n`,
+ 'src/mixed.ts': `import { secret as aliased } from 'denied-pkg'
+export { ok } from 'safe-pkg'
+export * from './fine'
+export const s = () => aliased
+`,
+ 'src/fine.ts': `export { fine } from 'safe-pkg'\n`,
+ 'src/entry.ts': `import { secret } from './barrel'
+import { ok, fine, s } from './mixed'
+export const read = () => [String(secret), ok(), fine(), String(s())].join(',')
+`,
+ },
+ entry: 'src/entry.ts',
+ start: {
+ importProtection: {
+ behavior: 'mock',
+ client: { specifiers: ['denied-pkg'] },
+ },
+ },
+ })
+ expect({
+ hasRealCode: client.where('MARK_SECRET') !== 'nowhere',
+ read: (await client.run(`return entry.read()`)).result,
+ }).toEqual({
+ hasRealCode: false,
+ read: '[import-protection mock],MARK_SAFE,MARK_FINE,[import-protection mock]',
+ })
},
timeout,
)
From 3b998c76b81b88ba4193f49bac550f2daa3cfb0f Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:44:35 +0200
Subject: [PATCH 0287/1425] test(start-plugin-core): cover onViolation, log,
excludeFiles and maxTraceDepth builds
Round 3 mutation gaps (build-options.test.ts, both bundlers unless noted):
- ip-rsbuild R1: an onViolation that returns nothing does not allow the
import (denied file and marker rows).
- ip-rsbuild R5: the marker violation is typed marker and located at the
marker import (line; the two bundlers differ by one column).
- ip-misc P4: in mock mode, onViolation gets info.resolved for each
import of several denied specifiers (resolve cache hit), and the
production mocks are silent.
- ip-rsbuild R6: log 'always' reports each violation of a build once.
- ip-misc P5: excludeFiles with several patterns (table row).
- ip-vite V6: client.excludeFiles [] with a server-only-marked dependency
fails the Vite build (Vite row only: Rsbuild awaits a ruling).
- ip-location P5 + ip-rsbuild R10: maxTraceDepth cuts a deeper trace and
keeps one within the limit complete.
Adds violation-report.ts, a reader of the trace and code frame of the
reports a build prints.
Kills: spc:7564, 7825, 7418, 7278, 7280, 7353, 1886, 5260, 7838, 7845,
7846, 7849, 7851, 7857, 7863, 7864, 7865, 7874, 7878, 7972, 7963, 7964,
7965, 1685, 1686, 1709, 1710, 1712, 1725, 1737, 7642
Co-Authored-By: Claude Opus 5.5
---
.../importProtection/build-options.test.ts | 234 +++++++++++++++++-
.../importProtection/violation-report.ts | 103 ++++++++
2 files changed, 336 insertions(+), 1 deletion(-)
create mode 100644 packages/start-plugin-core/tests/importProtection/violation-report.ts
diff --git a/packages/start-plugin-core/tests/importProtection/build-options.test.ts b/packages/start-plugin-core/tests/importProtection/build-options.test.ts
index e5f97ebd3ef..7f0550dc67e 100644
--- a/packages/start-plugin-core/tests/importProtection/build-options.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/build-options.test.ts
@@ -7,7 +7,8 @@
*/
import { describe, expect, test } from 'vitest'
import { buildStart, startBundlers } from '../build-harness'
-import { rejection, reportedViolations } from './start-apps'
+import { distinct, rejection, reportedViolations } from './start-apps'
+import { violationReports } from './violation-report'
import type { ImportProtectionOptions } from '../../src/schema'
const timeout = 60_000
@@ -252,6 +253,13 @@ export const read = () => getRequest()
importProtection: { client: { excludeFiles: ['**/vendor/**'] } },
expected: ['client src/entry.ts'],
},
+ {
+ name: 'a file matching any one of several patterns is not denied',
+ importProtection: {
+ client: { excludeFiles: ['**/vendor/**', '**/node_modules/**'] },
+ },
+ expected: 'built',
+ },
])(
'$name',
async ({ importProtection, expected }) => {
@@ -278,5 +286,229 @@ export const read = () => getRequest()
},
timeout,
)
+
+ // Vite only: what Rsbuild does with the markers of dependencies awaits
+ // a ruling (with `[]`, the Rsbuild build succeeds).
+ if (bundler === 'vite') {
+ test(
+ '[] checks the markers of dependencies: a server-only-marked dependency fails the build',
+ async () => {
+ expect(
+ await outcome(
+ {
+ ...dependency('db-lib'),
+ 'node_modules/db-lib/index.js': `import '@tanstack/react-start/server-only'
+export const secret = () => 'MARK_SECRET'
+`,
+ 'src/entry.ts': userOf('db-lib'),
+ },
+ { client: { excludeFiles: [] } },
+ ),
+ ).toEqual(['client node_modules/db-lib/index.js'])
+ },
+ timeout,
+ )
+ }
+ })
+
+ describe('onViolation', () => {
+ // Only `false` allows an import: a callback that returns nothing (one
+ // that logs, say) leaves the import denied.
+ test.each([
+ {
+ name: 'a denied file',
+ files: { ...secretServer, 'src/entry.ts': userOf('./secret.server') },
+ reported: /^src\/entry\.ts:2:27$/,
+ type: 'file',
+ },
+ {
+ name: 'a server-only-marked module',
+ files: {
+ 'src/marked.ts': `export const before = 1
+import '@tanstack/react-start/server-only'
+export const secret = () => 'MARK_SECRET'
+`,
+ 'src/entry.ts': userOf('./marked'),
+ },
+ // At the marker import.
+ reported: /^src\/marked\.ts:2:\d+$/,
+ type: 'marker',
+ },
+ ])(
+ 'a callback that returns nothing does not allow $name: the build fails, and the callback gets a $type violation',
+ async ({ files, reported, type }) => {
+ const seen: Array = []
+ const error = await rejection(
+ buildStart({
+ bundler,
+ files,
+ entry: 'src/entry.ts',
+ start: {
+ importProtection: {
+ onViolation: (info) => {
+ seen.push(info as Violation)
+ },
+ },
+ },
+ }),
+ )
+ expect(reportedViolations(error.message)).toEqual([
+ { environment: 'client', importer: expect.stringMatching(reported) },
+ ])
+ expect(distinct(seen.map((info) => info.type))).toEqual([type])
+ },
+ timeout,
+ )
+
+ test(
+ 'in mock mode, it gets each import of a denied specifier with the file the specifier resolves to; the mocks are silent',
+ async () => {
+ const seen: Array = []
+ const { client } = await buildStart({
+ bundler,
+ files: {
+ ...dependency('db-lib'),
+ ...dependency('mail-lib'),
+ 'src/a.ts': `import { secret } from 'db-lib'
+import { secret as mail } from 'mail-lib'
+import { b } from './b'
+export const a = () => String(secret()) + String(mail()) + '|' + b()
+`,
+ 'src/b.ts': `import { secret } from 'db-lib'
+export const b = () => String(secret())
+`,
+ 'src/entry.ts': `import { a } from './a'
+export const read = () => a()
+`,
+ },
+ entry: 'src/entry.ts',
+ start: {
+ importProtection: {
+ behavior: 'mock',
+ client: { specifiers: ['db-lib', 'mail-lib'] },
+ onViolation: (info) => {
+ seen.push(info as Violation)
+ },
+ },
+ },
+ })
+ const fromRoot = (file: string) =>
+ file.replace(/^.*?\/(src|node_modules)\//, '$1/')
+ expect(
+ distinct(
+ seen
+ .filter((info) => info.envType === 'client')
+ .map(
+ (info) =>
+ `${fromRoot(info.importer)} ${info.specifier} -> ${info.resolved ? fromRoot(info.resolved) : 'none'}`,
+ ),
+ ).sort(),
+ ).toEqual([
+ 'src/a.ts db-lib -> node_modules/db-lib/index.js',
+ 'src/a.ts mail-lib -> node_modules/mail-lib/index.js',
+ 'src/b.ts db-lib -> node_modules/db-lib/index.js',
+ ])
+ expect(await client.run(`return entry.read()`)).toMatchObject({
+ result:
+ '[import-protection mock][import-protection mock]|[import-protection mock]',
+ logs: [],
+ })
+ },
+ timeout,
+ )
+ })
+
+ describe('log', () => {
+ test(
+ "'always' reports each violation of a build once",
+ async () => {
+ const error = await rejection(
+ buildStart({
+ bundler,
+ files: {
+ 'src/secret.server.ts': `export const secret = () => 'secret'
+export const other = () => 'other'
+`,
+ 'src/marked.ts': `import '@tanstack/react-start/server-only'
+export const m = () => 'MARK_SECRET'
+`,
+ 'src/a.ts': `import { m } from './marked'\nexport const a = () => m()\n`,
+ 'src/b.ts': `import { m } from './marked'\nexport const b = () => m() + m()\n`,
+ 'src/entry.ts': `import { secret, other } from './secret.server'
+export const read = () => secret() + other() + secret()
+export { a } from './a'
+export { b } from './b'
+`,
+ },
+ entry: 'src/entry.ts',
+ start: { importProtection: { log: 'always' } },
+ }),
+ )
+ const reported = reportedViolations(error.message).map(
+ ({ importer }) => importer,
+ )
+ expect(reported).toContain('src/entry.ts:2:27')
+ expect(reported).toEqual(distinct(reported))
+ },
+ timeout,
+ )
+ })
+
+ describe('maxTraceDepth', () => {
+ /** The client imports `src/secret.server.ts` through a chain of modules. */
+ const chain = {
+ ...secretServer,
+ 'src/b.ts': userOf('./secret.server'),
+ 'src/a.ts': `import { read } from './b'\nexport const page = () => read()\n`,
+ 'src/entry.ts': `export * from './a'\n`,
+ }
+
+ /** The modules of the trace of the violation the build reports. */
+ async function traceOf(importProtection: ImportProtectionOptions) {
+ const error = await rejection(
+ buildStart({
+ bundler,
+ files: chain,
+ entry: 'src/entry.ts',
+ start: { importProtection },
+ }),
+ )
+ const [report, ...others] = violationReports(error.message)
+ expect(others).toEqual([])
+ return report!.trace.map(({ at }) => at.replace(/:\d+:\d+$/, ''))
+ }
+
+ test(
+ 'cuts a deeper trace, keeping the importer',
+ async () => {
+ const trace = await traceOf({ maxTraceDepth: 1 })
+ expect(trace.length).toBeGreaterThan(0)
+ expect(trace.length).toBeLessThanOrEqual(2)
+ expect(trace.at(-1)).toBe('src/b.ts')
+ expect(trace).not.toContain('src/entry.ts')
+ },
+ timeout,
+ )
+
+ test(
+ 'keeps a trace that is not deeper complete',
+ async () => {
+ expect((await traceOf({ maxTraceDepth: 3 })).slice(-3)).toEqual([
+ 'src/entry.ts',
+ 'src/a.ts',
+ 'src/b.ts',
+ ])
+ },
+ timeout,
+ )
})
})
+
+/** What `onViolation` gets (guide/import-protection.md). */
+interface Violation {
+ envType: 'client' | 'server'
+ type: 'specifier' | 'file' | 'marker'
+ specifier: string
+ importer: string
+ resolved?: string
+}
diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.ts b/packages/start-plugin-core/tests/importProtection/violation-report.ts
new file mode 100644
index 00000000000..d5b1a016945
--- /dev/null
+++ b/packages/start-plugin-core/tests/importProtection/violation-report.ts
@@ -0,0 +1,103 @@
+/**
+ * Reads the parts of an import-protection violation report that tell the
+ * user where the leak comes from: the import trace (from the entry to the
+ * importer) and the code frame. Read from the text a build prints (Vite
+ * errors, and Rsbuild errors, whose lines it boxes with `│`).
+ */
+
+export interface ReportedTraceStep {
+ /** The module, as `file:line:column` when the step has a location. */
+ at: string
+ /** Whether the step is tagged `(entry)`. */
+ entry: boolean
+ /** The specifier the step imports the next step with. */
+ imports?: string
+}
+
+export interface ReportedCodeFrame {
+ /** The line number the frame highlights. */
+ line: number
+ /** The highlighted line's code from the column the caret points at. */
+ pointsAt: string
+ /** The code of each line the frame shows. */
+ shows: Array
+ /** The location printed under the frame. */
+ location: string
+}
+
+export interface ViolationReport {
+ /** `Importer:`, as `file:line:column`. */
+ importer: string
+ /** The numbered trace steps, in order. */
+ trace: Array
+ code?: ReportedCodeFrame
+}
+
+/** The violation reports in `output`, in order. */
+export function violationReports(
+ output: string | Array,
+): Array {
+ const text = (Array.isArray(output) ? output.join('\n') : output).replace(
+ /^.*│ ?/gm,
+ '',
+ )
+ return text
+ .split('[import-protection] Import denied in ')
+ .slice(1)
+ .map(parseReport)
+}
+
+function parseReport(report: string): ViolationReport {
+ const lines = report.split('\n')
+ const trace: Array = []
+ const traceStart = lines.findIndex((line) => line.trim() === 'Trace:')
+ for (
+ let index = traceStart + 1;
+ traceStart !== -1 && index < lines.length;
+ index++
+ ) {
+ const step = /^\s+(\d+)\. (\S+)( \(entry\))?(?: \(import "(.*)"\))?$/.exec(
+ lines[index]!,
+ )
+ if (!step) {
+ break
+ }
+ if (Number(step[1]) !== trace.length + 1) {
+ throw new Error(`trace step ${step[1]} is out of order`)
+ }
+ trace.push({ at: step[2]!, entry: !!step[3], imports: step[4] })
+ }
+ return {
+ importer: /Importer: (\S+)/.exec(report)?.[1] ?? '',
+ trace,
+ code: parseCodeFrame(lines),
+ }
+}
+
+function parseCodeFrame(lines: Array): ReportedCodeFrame | undefined {
+ const start = lines.findIndex((line) => line.trim() === 'Code:')
+ if (start === -1) {
+ return undefined
+ }
+ const frame: Array = []
+ for (let index = start + 1; lines[index]?.trim(); index++) {
+ frame.push(lines[index]!)
+ }
+ const highlighted = frame.findIndex((line) => /^\s*>\s*\d+ \|/.test(line))
+ const caret = frame[highlighted + 1]?.indexOf('^') ?? -1
+ if (highlighted === -1 || caret === -1) {
+ throw new Error(`no highlighted line with a caret in:\n${frame.join('\n')}`)
+ }
+ return {
+ line: Number(/(\d+) \|/.exec(frame[highlighted]!)![1]),
+ pointsAt: frame[highlighted]!.slice(caret),
+ shows: frame.flatMap(
+ (line) => /^\s*>?\s*\d+ \| ?(.*)$/.exec(line)?.[1] ?? [],
+ ),
+ location:
+ lines
+ .slice(start + frame.length + 1)
+ .find((line) => line.trim())
+ ?.trim() ?? '',
+ }
+}
From 79ac508df54afa15f647f16ae1a2f5bd67c41379 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:51:39 +0200
Subject: [PATCH 0288/1425] test(start-plugin-core): cover the trace and code
frame of build violation reports
Round 3 mutation gaps (reported-location.test.ts, Vite and Rsbuild builds):
- ip-location P2 + ip-rsbuild R9: the trace lists each import from the
entry (only the first step tagged entry), located at its specifier.
- ip-location P3: the code frame shows the import and the use, its caret
under the reported column, and the location under it.
- ip-location P6: the trace is the shortest import chain (diamond).
- ip-rsbuild R8: with source maps, Rsbuild locates a trace import on its
line after compiling moved it (comment, blank line, type-only import).
Pins (#21, build part):
- ip-location B1: without a captured source map, trace steps use
compiled-code lines (Vite: every row; Rsbuild without source maps:
blank line and type-only import rows). Red: line 2 reported for an
import on line 3.
- ip-location B2 (Vite): the trace stops at modules outside src. Red: the
trace is only the importer.
Kills: spc:1317, 1319, 1321, 1323, 1328, 1331, 1334, 1752, 1754, 1755,
1815, 1816, 1821, 1822, 1826, 1649, 1650, 1653, 7236, 7237, 7405, 7435,
7436, 7441, 7444, 7447, 7462, 7465, 7473, 7474, 7490, 7491, 1723, 7216
Co-Authored-By: Claude Opus 5.5
---
.../reported-location.test.ts | 269 +++++++++++++++++-
1 file changed, 268 insertions(+), 1 deletion(-)
diff --git a/packages/start-plugin-core/tests/importProtection/reported-location.test.ts b/packages/start-plugin-core/tests/importProtection/reported-location.test.ts
index 6c27d19ca0b..7dee58e4562 100644
--- a/packages/start-plugin-core/tests/importProtection/reported-location.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/reported-location.test.ts
@@ -9,7 +9,7 @@
* Rsbuild: original source first), so each case runs on both.
*/
import { describe, expect, test } from 'vitest'
-import { buildStart } from '../build-harness'
+import { buildStart, startBundlers } from '../build-harness'
import { startRsbuildDevServer } from '../rsbuild-dev-server'
import {
distinct,
@@ -19,6 +19,9 @@ import {
reportedViolations,
startProtectedDevServer,
} from './start-apps'
+import { violationReports } from './violation-report'
+import type { StartBundler } from '../build-harness'
+import type { ReportedTraceStep, ViolationReport } from './violation-report'
const timeout = 60_000
@@ -188,3 +191,267 @@ describe('the reported location of a violation is the leaking use', () => {
timeout,
)
})
+
+/**
+ * The report of the one violation of a build of `files` (client entry
+ * `src/entry.ts`, default rules).
+ */
+async function buildReport(
+ bundler: StartBundler,
+ files: Record,
+ { sourcemap = false } = {},
+): Promise {
+ const error = await rejection(
+ buildStart({ bundler, files, entry: 'src/entry.ts', sourcemap }),
+ )
+ const [report, ...others] = violationReports(error.message)
+ expect(others).toEqual([])
+ return report!
+}
+
+/** The modules of the steps of `trace`, without their locations. */
+const modulesOf = (trace: Array) =>
+ trace.map(({ at }) => at.replace(/:\d+:\d+$/, ''))
+
+const secretServer = {
+ 'src/secret.server.ts': `export const secret = () => 'secret'\n`,
+}
+
+/**
+ * The client entry imports `src/secret.server.ts` through `src/a.ts` and
+ * `src/b.ts`, each import after the first line of its module.
+ */
+const chain = {
+ ...secretServer,
+ 'src/b.ts': `// helper
+export const one = 1
+import { secret } from './secret.server'
+export const leak = () => secret()
+`,
+ 'src/a.ts': `export const two = 2
+export const three = 3
+import { leak } from './b'
+export const page = () => leak()
+`,
+ 'src/entry.ts': `export const zero = 0
+export * from './a'
+`,
+}
+
+/** `src/b.ts` imports the denied module, and `src/a.ts` imports `b`. */
+const leakingB = {
+ ...secretServer,
+ 'src/b.ts': `import { secret } from './secret.server'
+export const leak = () => secret()
+`,
+}
+
+// What a build reports about where a violation comes from: the trace of
+// imports from the entry to the importer, and the code frame of the use.
+describe.each(startBundlers)(
+ '%s build: the report of a violation',
+ (bundler) => {
+ let chainReport: Promise | undefined
+ /** The report of the `chain` build, built once. */
+ const reportOfChain = () =>
+ (chainReport ??= buildReport(bundler, chain, {}))
+
+ test(
+ 'the trace lists each import from the entry, located at its specifier',
+ async () => {
+ const { importer, trace } = await reportOfChain()
+ expect(importer).toBe('src/b.ts:4:27')
+ expect(trace.map(({ entry }) => entry)).toEqual(
+ trace.map((_, index) => index === 0),
+ )
+ expect(
+ trace.slice(-3).map(({ at, imports }) => ({ at, imports })),
+ ).toEqual([
+ { at: 'src/entry.ts:2:16', imports: './a' },
+ { at: 'src/a.ts:3:23', imports: './b' },
+ { at: 'src/b.ts:4:27', imports: './secret.server' },
+ ])
+ },
+ timeout,
+ )
+
+ test(
+ 'the code frame shows the import and the use, pointing at the use',
+ async () => {
+ expect((await reportOfChain()).code).toEqual({
+ line: 4,
+ pointsAt: 'secret()',
+ shows: expect.arrayContaining([
+ `import { secret } from './secret.server'`,
+ 'export const leak = () => secret()',
+ ]),
+ location: 'src/b.ts:4:27',
+ })
+ },
+ timeout,
+ )
+
+ test(
+ 'the trace is the shortest import chain',
+ async () => {
+ const { trace } = await buildReport(bundler, {
+ ...secretServer,
+ 'src/s.ts': `import { secret } from './secret.server'
+export const s = () => secret()
+`,
+ 'src/c.ts': `import { s } from './s'\nexport const c = () => s()\n`,
+ 'src/a.ts': `import { c } from './c'
+import { s } from './s'
+export const page = () => c() + s()
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ })
+ expect(modulesOf(trace).slice(-3)).toEqual([
+ 'src/entry.ts',
+ 'src/a.ts',
+ 'src/s.ts',
+ ])
+ },
+ timeout,
+ )
+
+ // Bug (Vite): the trace of a Vite build starts at the first module of the
+ // trace that is in the source directory and not in `node_modules`: the
+ // Vite adapter does not record the imports of other modules in its import
+ // graph (vite/import-protection-plugin/plugin.ts). Rsbuild traces through
+ // them (and from the harness's client entry, outside `src`).
+ // Impact: the trace of a Vite build leaves out how the client reaches the
+ // importer when the chain goes through a module outside `src` (a shared
+ // directory of a monorepo, a dependency).
+ // Source: round 3 mutation testing (ip-location B2).
+ const outsideSrcTest = bundler === 'vite' ? test.fails : test
+ outsideSrcTest(
+ 'the trace goes through modules outside the source directory',
+ async () => {
+ const { trace } = await buildReport(bundler, {
+ ...leakingB,
+ 'shared/a.ts': `import { leak } from '../src/b'
+export const page = () => leak()
+`,
+ 'src/entry.ts': `export * from '../shared/a'\n`,
+ })
+ expect(modulesOf(trace).slice(-3)).toEqual([
+ 'src/entry.ts',
+ 'shared/a.ts',
+ 'src/b.ts',
+ ])
+ },
+ timeout,
+ )
+ },
+)
+
+// The trace locates each import on its line in the user's file, whatever
+// compiling the module moves.
+const movedImports: Record<
+ string,
+ { files: Record; step: string; line: number }
+> = {
+ 'a leading comment': {
+ files: {
+ ...leakingB,
+ 'src/a.ts': `import { leak } from './b'\nexport const page = () => leak()\n`,
+ 'src/entry.ts': `// entry
+export const zero = 0
+export * from './a'
+`,
+ },
+ step: 'src/entry.ts',
+ line: 3,
+ },
+ 'a blank line': {
+ files: {
+ ...leakingB,
+ 'src/a.ts': `export const two = 2
+
+import { leak } from './b'
+export const page = () => leak()
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ },
+ step: 'src/a.ts',
+ line: 3,
+ },
+ 'a type-only import': {
+ files: {
+ ...leakingB,
+ 'src/types.ts': `export type Count = number\n`,
+ 'src/a.ts': `import type { Count } from './types'
+export const count: Count = 1
+import { leak } from './b'
+export const page = () => leak() + count
+`,
+ 'src/entry.ts': `export * from './a'\n`,
+ },
+ step: 'src/a.ts',
+ line: 3,
+ },
+}
+
+/** The rows of `movedImports` named `names`, built with `bundler`. */
+const movedImportRows = (
+ bundler: StartBundler,
+ sourcemap: boolean,
+ names: Array,
+) =>
+ names.map((name) => ({
+ name,
+ build: `${bundler} build${sourcemap ? ' with source maps' : ''}`,
+ bundler,
+ sourcemap,
+ ...movedImports[name]!,
+ }))
+
+describe('the trace locates each import on its line after compiling moved it', () => {
+ async function stepLine({
+ bundler,
+ sourcemap,
+ files,
+ step,
+ }: ReturnType[number]) {
+ const { trace } = await buildReport(bundler, files, { sourcemap })
+ const at = trace.find((candidate) => candidate.at.startsWith(`${step}:`))
+ return Number(at?.at.split(':')[1])
+ }
+
+ test.each([
+ ...movedImportRows('rsbuild', true, Object.keys(movedImports)),
+ ...movedImportRows('rsbuild', false, ['a leading comment']),
+ ])(
+ '$build: after $name',
+ async (row) => {
+ expect(await stepLine(row)).toBe(row.line)
+ },
+ timeout,
+ )
+
+ // Bug: a trace step's location is the import's position in the compiled
+ // code of the module unless import protection captured a source map of
+ // it: the Vite adapter captures one only for modules whose code names a
+ // denied file or specifier pattern or a marker
+ // (`shouldCaptureDiagnosticSourceMap` in
+ // vite/import-protection-plugin/plugin.ts), the Rsbuild adapter only when
+ // the build emits source maps. Compiling drops comments (Vite), blank
+ // lines and type-only imports, which moves the imports after them up.
+ // Impact: the trace points at the wrong line of the modules between the
+ // entry and the importer.
+ // Source: round 3 mutation testing (ip-location B1).
+ test.fails.each([
+ ...movedImportRows('vite', false, Object.keys(movedImports)),
+ ...movedImportRows('rsbuild', false, [
+ 'a blank line',
+ 'a type-only import',
+ ]),
+ ])(
+ '$build: after $name',
+ async (row) => {
+ expect(await stepLine(row)).toBe(row.line)
+ },
+ timeout,
+ )
+})
From d360472adc681060a1fa0e4900e2d709dee3f494 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:53:25 +0200
Subject: [PATCH 0289/1425] test(start-plugin-core): pin import-protection
build bugs: dynamic import, non-JS and CommonJS denied files
Pins (known-bugs.test.ts, appended):
- REPORT #1 / ip-misc P1 (Rsbuild): a client dynamic import('db-lib') of a
denied specifier builds without a report (red: the build succeeds).
Vite row is a normal test (the build fails with one client violation).
- REPORT #3 / ip-vite B1 (both bundlers): in mock mode a denied
.server.json ships its real content (red: hasRealCode true, read
MARK_SECRET, warned); a denied .server.cjs fails the build (red: Vite
PARSE_ERROR on the ESM mock, Rspack ESModulesLinkingError for the named
import). Intended: build succeeds, client gets the mock, warning.
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/known-bugs.test.ts | 124 ++++++++++++++++++
1 file changed, 124 insertions(+)
diff --git a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
index a8f30085454..10466f2b925 100644
--- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts
@@ -10,6 +10,7 @@ import { runRsbuildClient, startRsbuildDevServer } from '../rsbuild-dev-server'
import { evaluateWithMock, isMock, selfMock } from './mock-helpers'
import {
factoryTopLevelCall,
+ rejection,
reportedViolations,
serverFnFactoryApp,
startProtectedDevServer,
@@ -447,3 +448,126 @@ describe('Rsbuild dev', () => {
15_000,
)
})
+
+describe.each(startBundlers)('%s build', (bundler) => {
+ // Bug (Rsbuild): a dynamic `import('db-lib')` of a denied specifier is
+ // neither reported nor mocked: the Rsbuild loader only rewrites static
+ // imports and re-exports (`rewriteDeniedImports`,
+ // import-protection/rewrite.ts), and the build reports specifier
+ // violations only through the mock modules of rewritten imports
+ // (`processAssets` in rsbuild/import-protection.ts). Vite fails the build.
+ // Impact: the real module ships to the client in a lazy chunk and runs
+ // there, without any error or warning.
+ // Source: round 3 mutation testing (ip-misc P1).
+ const dynamicImportTest = bundler === 'rsbuild' ? test.fails : test
+ dynamicImportTest(
+ 'a client dynamic import of a denied specifier fails the build, reporting one client violation',
+ async () => {
+ const error = await rejection(
+ buildStart({
+ bundler,
+ files: {
+ 'node_modules/db-lib/package.json': JSON.stringify({
+ name: 'db-lib',
+ type: 'module',
+ exports: { '.': './index.js' },
+ }),
+ 'node_modules/db-lib/index.js': `export const secret = () => 'MARK_SECRET'\n`,
+ 'src/entry.ts': `export const read = () => import('db-lib').then((m) => m.secret())\n`,
+ },
+ entry: 'src/entry.ts',
+ start: { importProtection: { client: { specifiers: ['db-lib'] } } },
+ }),
+ )
+ expect(
+ reportedViolations(error.message).map(({ environment }) => environment),
+ ).toEqual(['client'])
+ },
+ 60_000,
+ )
+
+ /**
+ * What the client gets in a mock-mode build of an entry that imports
+ * `secret` from the denied `file` (control: the mock-mode rows of
+ * build.test.ts, same harness).
+ */
+ async function mockModeOutcome(
+ file: string,
+ code: string,
+ importStatement: string,
+ ) {
+ const { client } = await buildStart({
+ bundler,
+ files: {
+ [`src/${file}`]: code,
+ 'src/entry.ts': `${importStatement}
+export const read = () => String(secret)
+`,
+ },
+ entry: 'src/entry.ts',
+ start: { importProtection: { behavior: 'mock' } },
+ })
+ return {
+ warned: reportedViolations(client.warnings).length > 0,
+ hasRealCode: client.where('MARK_SECRET') !== 'nowhere',
+ read: (await client.run(`return entry.read()`)).result,
+ }
+ }
+
+ // Bug: in mock mode, a denied file that is not JavaScript or TypeScript
+ // keeps its content: Vite replaces a denied file with its mock in a
+ // transform hook whose filter only lets JS/TS modules through
+ // (`/\.[cm]?[tj]sx?($|\?)/`, transform-cache plugin of
+ // vite/import-protection-plugin/plugin.ts), and the Rsbuild loader rule
+ // only applies to JS/TS modules (`test: /\.[cm]?[tj]sx?$/`,
+ // rsbuild/import-protection.ts). Both warn.
+ // Impact: a mock-mode build ships the real content of a denied
+ // `*.server.json` (e.g. server configuration or credentials) to the client.
+ // Source: round 3 mutation testing (ip-vite B1).
+ test.fails(
+ 'in mock mode, a denied .server.json file is mocked, with a warning',
+ async () => {
+ expect(
+ await mockModeOutcome(
+ 'secret.server.json',
+ `{ "secret": "MARK_SECRET" }\n`,
+ `import data from './secret.server.json'
+const secret = data.secret`,
+ ),
+ ).toEqual({
+ warned: true,
+ hasRealCode: false,
+ read: '[import-protection mock]',
+ })
+ },
+ 60_000,
+ )
+
+ // Bug: in mock mode, a denied CommonJS file (`.server.cjs`) fails the
+ // build: the mock that replaces the file is an ES module, which Vite's
+ // parser rejects in a `.cjs` file (`export default mock`), and whose named
+ // exports are the ES exports of the denied file, which a CommonJS file has
+ // none of, so Rspack cannot link the named import (`export 'secret' ...
+ // was not found`). With import protection off, both build the named
+ // import of the real module.
+ // Impact: a mock-mode build of a project with a `*.server.cjs` the client
+ // imports fails instead of mocking it.
+ // Source: round 3 mutation testing (ip-vite B1).
+ test.fails(
+ 'in mock mode, a denied .server.cjs file is mocked, with a warning',
+ async () => {
+ expect(
+ await mockModeOutcome(
+ 'secret.server.cjs',
+ `exports.secret = () => 'MARK_SECRET'\n`,
+ `import { secret } from './secret.server.cjs'`,
+ ),
+ ).toEqual({
+ warned: true,
+ hasRealCode: false,
+ read: '[import-protection mock]',
+ })
+ },
+ 60_000,
+ )
+})
From a08644480607aeadb89e8e82f8de7b78679eeed9 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:55:13 +0200
Subject: [PATCH 0290/1425] test(start-plugin-core): source maps of a module
whose denied import is mocked
Round 3 mutation gap ip-misc P11 (start-build-source-maps.test.ts, both
bundlers): in a mock-mode build, a throw in a module whose denied import
import protection rewrote maps to its line in the source file.
Kills: spc:7985, 8002, 8004
Co-Authored-By: Claude Opus 5.5
---
.../tests/start-build-source-maps.test.ts | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/packages/start-plugin-core/tests/start-build-source-maps.test.ts b/packages/start-plugin-core/tests/start-build-source-maps.test.ts
index ccf24e661d9..8f4730c487e 100644
--- a/packages/start-plugin-core/tests/start-build-source-maps.test.ts
+++ b/packages/start-plugin-core/tests/start-build-source-maps.test.ts
@@ -58,8 +58,62 @@ describe.each(startBundlers)('%s build', (bundler) => {
},
timeout,
)
+
+ // Import protection rewrites the denied import of the module (mock mode),
+ // and compiling drops the type-only lines before the throw.
+ test(
+ 'client, mock mode: a throw in a module whose denied import is mocked maps to its line in the source file (minified)',
+ async () => {
+ const { client } = await buildStart({
+ bundler,
+ minify: true,
+ sourcemap: true,
+ files: {
+ 'node_modules/db-lib/package.json': JSON.stringify({
+ name: 'db-lib',
+ type: 'module',
+ exports: { '.': './index.js' },
+ }),
+ 'node_modules/db-lib/index.js': `export const secret = 1\n`,
+ 'src/fail.ts': mockedImporter,
+ 'src/entry.ts': `export { fail } from './fail'\n`,
+ },
+ entry: 'src/entry.ts',
+ start: {
+ importProtection: {
+ behavior: 'mock',
+ client: { specifiers: ['db-lib'] },
+ },
+ },
+ })
+ const { result: stack } = await client.run(
+ `try { entry.fail() } catch (error) { return error.stack }`,
+ )
+ expect(stack).toMatch(/MARK_THROW/)
+ const { file, line } = originalFrameOf(client, stack as string) ?? {}
+ expect({ file, line }).toEqual({
+ file: 'src/fail.ts',
+ line: lineOf(mockedImporter, 'throw new Error'),
+ })
+ },
+ timeout,
+ )
})
+const mockedImporter = `import { secret } from 'db-lib'
+interface Options {
+ a: string
+ b: number
+}
+type Unused = Options
+export function fail(options?: Options): string {
+ if (!options) {
+ throw new Error('MARK_THROW' + String(secret))
+ }
+ return options.a
+}
+`
+
const route = `import { createFileRoute } from '@tanstack/react-router'
import { prefix } from '../prefix'
export const Route = createFileRoute('/')({ component: Page })
From 05e408c92796e8c190e0666f041d2c4fbcdefce8 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 06:59:07 +0200
Subject: [PATCH 0291/1425] test(start-plugin-core): type the rows of the new
import-protection build tables
test:types rejected the inferred union of the row objects (optional
undefined file keys) as Record.
Co-Authored-By: Claude Opus 5.5
---
.../tests/importProtection/build-options.test.ts | 7 ++++++-
.../start-plugin-core/tests/importProtection/build.test.ts | 2 +-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/packages/start-plugin-core/tests/importProtection/build-options.test.ts b/packages/start-plugin-core/tests/importProtection/build-options.test.ts
index 7f0550dc67e..4491344e724 100644
--- a/packages/start-plugin-core/tests/importProtection/build-options.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/build-options.test.ts
@@ -314,7 +314,12 @@ export const secret = () => 'MARK_SECRET'
describe('onViolation', () => {
// Only `false` allows an import: a callback that returns nothing (one
// that logs, say) leaves the import denied.
- test.each([
+ test.each<{
+ name: string
+ files: Record
+ reported: RegExp
+ type: Violation['type']
+ }>([
{
name: 'a denied file',
files: { ...secretServer, 'src/entry.ts': userOf('./secret.server') },
diff --git a/packages/start-plugin-core/tests/importProtection/build.test.ts b/packages/start-plugin-core/tests/importProtection/build.test.ts
index 0c152b56465..99bb0c0b70f 100644
--- a/packages/start-plugin-core/tests/importProtection/build.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/build.test.ts
@@ -133,7 +133,7 @@ export const value = 1
// Source: oracle-gaps H2 / gap IP1 (the default rules were never built in
// error mode: every build test replaced `client.files`).
describe.each(startBundlers)('%s build, default rules', (bundler) => {
- test.each([
+ test.each<{ name: string; files: Record; reported: string }>([
{
name: 'a client import of a *.server.* file',
files: appUsing('a denied file'),
From 558995ff5a26ced13cd3955de7520e1711ac7bda Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 07:22:46 +0200
Subject: [PATCH 0292/1425] test(router-plugin): root route ids come from the
route generator, not the harness
The root route HMR tests registered the root route file under '/__root' by
hand, and hotUpdate decided root-ness from that literal, so a generator that
registered the root file under another id (or none) went unnoticed by the
route HMR suites (round-3 mutation testing harness gap).
- regression-helpers: `generatedRouteId(file, files)` runs the router
plugin's own route generator plugin (configResolved, as the Vite plugin
does) on a temporary routes directory and returns the id it registered
the file under; hotUpdate treats a route created by `createRootRoute` as
the tree's root and reads the live route by the id the route tree gave it.
- regression-helpers.test.ts uses the generated id for its root route row.
Mutants verified killed through the converted root tests (route-hmr,
handle-route-update; source-aliased vitest because generator dist builds
fail type-checking under these mutants): rg:941, rg:942, rg:954.
Co-Authored-By: Claude Opus 5.5
---
.../tests/regression-helpers.test.ts | 3 +-
.../router-plugin/tests/regression-helpers.ts | 74 +++++++++++++++++--
2 files changed, 70 insertions(+), 7 deletions(-)
diff --git a/packages/router-plugin/tests/regression-helpers.test.ts b/packages/router-plugin/tests/regression-helpers.test.ts
index 6f6053c3802..7c3ea59527d 100644
--- a/packages/router-plugin/tests/regression-helpers.test.ts
+++ b/packages/router-plugin/tests/regression-helpers.test.ts
@@ -11,6 +11,7 @@ import {
componentChunk,
expectRegisteredRouteOption,
expectValidModules,
+ generatedRouteId,
head,
hotUpdate,
importRequests,
@@ -340,7 +341,7 @@ export const Route = createFileRoute('/')({
) => `import { createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({ loader: () => '${loader}' })`
const update = await hotUpdate(root('old'), {
- routeId: '/__root',
+ routeId: await generatedRouteId('__root.tsx'),
updated: root('new'),
})
expect((update.live.loader as () => string)()).toBe('new')
diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts
index bf020590fbf..ad368797305 100644
--- a/packages/router-plugin/tests/regression-helpers.ts
+++ b/packages/router-plugin/tests/regression-helpers.ts
@@ -4,7 +4,9 @@
* follow the imports of its output to every module it emits, evaluate those
* modules like a runtime, and inspect an emitted module.
*/
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
+import { fileURLToPath } from 'node:url'
import {
createModuleGraph,
jsxPragma,
@@ -26,6 +28,7 @@ import { parseSync, transformWithOxc } from 'vite'
import { expect, vi } from 'vitest'
import { createMemoryHistory } from '../../history/src'
import { createRouterCodeSplitterPlugin } from '../src/core/router-code-splitter-plugin'
+import { createRouterGeneratorPlugin } from '../src/core/router-generator-plugin'
import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin'
import { createRouterPluginContext } from '../src/core/router-plugin-context'
import { normalizePath } from '../src/core/utils'
@@ -39,6 +42,8 @@ import type { ESTree } from 'vite'
export { declaredNames } from '@tanstack/compiler-test-utils/validate-module'
+const harnessDir = path.dirname(fileURLToPath(import.meta.url))
+
export const head = `import { createFileRoute } from '@tanstack/react-router'\n`
/**
@@ -676,6 +681,56 @@ export async function createCodeSplitterTransforms(
}
}
+/**
+ * The route id the router plugin registers a route file under: runs the
+ * plugin's route generator, as the Vite plugin does when the dev server
+ * starts, on a temporary routes directory holding `files` (route files by
+ * path relative to it; an empty root route unless given), and returns the
+ * route id it registered `file` (relative to the routes directory) under.
+ */
+export async function generatedRouteId(
+ file: string,
+ files: Record = {},
+) {
+ const root = await mkdtemp(path.join(harnessDir, '.generated-route-id-'))
+ try {
+ const routes = {
+ '__root.tsx': `import { createRootRoute } from '@tanstack/react-router'
+export const Route = createRootRoute({})
+`,
+ ...files,
+ }
+ for (const [name, code] of Object.entries(routes)) {
+ const target = path.join(root, 'routes', name)
+ await mkdir(path.dirname(target), { recursive: true })
+ await writeFile(target, code)
+ }
+ const context = createRouterPluginContext()
+ const plugin = createRouterGeneratorPlugin(
+ {
+ target: 'react',
+ routesDirectory: './routes',
+ generatedRouteTree: './routeTree.gen.ts',
+ disableLogging: true,
+ },
+ context,
+ ) as UnpluginOptions
+ await hookHandler(plugin.vite!.configResolved)!.call(
+ {} as never,
+ { root } as never,
+ )
+ const routeId = context.routesByFile.get(
+ normalizePath(path.join(root, 'routes', file)),
+ )?.routeId
+ if (routeId === undefined) {
+ throw new Error(`the route generator registered no route for ${file}`)
+ }
+ return routeId
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+}
+
/**
* Runs the route HMR plugin (used when automatic code splitting is off) on
* the route file `file`, registered under `routeId`.
@@ -1131,8 +1186,9 @@ export type HotUpdateOptions = {
/** Default `react`. */
targetFramework?: TargetFramework
/**
- * Route id the route file is registered under. Default `/`; `/__root`
- * for a root route, which is then the route tree's root.
+ * Route id the route file is registered under. Default `/`. A root route
+ * (`createRootRoute`) is the route tree's root; its file is registered
+ * under the id the route generator gives it (`generatedRouteId`).
*/
routeId?: string
/** The edited route file the update loads. Default: `code` again. */
@@ -1274,8 +1330,13 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) {
route.isRoot = false
return route
}
- const createRootRoute = (routeOptions: Record) =>
- new BaseRootRoute(routeOptions as never)
+ // Root routes the modules created: the route tree's root.
+ const rootRoutes = new WeakSet()
+ const createRootRoute = (routeOptions: Record) => {
+ const route = new BaseRootRoute(routeOptions as never)
+ rootRoutes.add(route)
+ return route
+ }
const lazyStubs = createRouterStub(lazy)
const routerPackage = `@tanstack/${targetFramework}-router`
// The route classes of the framework packages add hooks to the router-core
@@ -1321,8 +1382,7 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) {
try {
const firstGraph = load(first)
const firstModule = await firstGraph.import(first.id)
- const isRoot = routeId === '/__root'
- const liveId = isRoot ? '__root__' : routeId
+ const isRoot = rootRoutes.has(firstModule.Route as object)
let routeTree: AnyRoute
if (isRoot) {
routeTree = firstModule.Route as AnyRoute
@@ -1344,6 +1404,8 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) {
getStoreConfig,
)
window.__TSR_ROUTER__ = router
+ // The id the route tree gave the route (`__root__` for the root)
+ const liveId = (firstModule.Route as AnyRoute).id
const liveOptions = () =>
(
router.routesById[liveId] as unknown as {
From d16d032bf8b38a307977140cfad9109c0a5e8ffe Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 07:23:01 +0200
Subject: [PATCH 0293/1425] test(router-plugin): cover code-splitter outcomes
found missing by mutation testing
Round-3 mutation testing (FIX-PLAN R3-router: ranked items 15-18, medium and
cheap low-tier Splitting proposals). Outcome assertions only: rendered
output, loader values, init counts, identity, real-build stack frames.
- plugin-options: two code splitters scoped to the client and SSR
environments of one dev server (Start's wiring, shared router plugin
context) split a route whose loader and component share a binding;
a non-function splitBehavior is refused.
- module-state: shared binding stays one instance when the component calls
a hook with an options object; exported classes keep identity next to
shared state; class dependency, array destructurings, JSX member object
and partly shadowed module names reach the shared code; a local named
like a Route-reading helper (21 forms) or component (3 forms) does not
tie the shared binding to Route; destructured sibling of a split
component; exported object destructuring created once; .jsx route files:
state only split options read is created lazily, once, and the shared
module does not pull in component-only imports.
- imports-exports: the route module keeps the other specifiers of a split
component's import; `export default Page` row in the exported-options
table; a default export of another binding or an exported destructuring
does not stop splitting; exported helpers next to an exported option
component; an exported option component is not copied into another
option's chunk; auto-accessor fields compile.
- route-options: literal/method options grouped with a split option stay
as written; a split loader's module loads with the loader only; a local
function loader is split without a warning; root route id from the
generator.
- plugin-pipeline: an edited route recompiled by the same instance works
after a version whose loader and component shared a binding; plugin
order errors for SWC, OXC, vite:react-refresh and vite-plugin-solid.
- build-source-maps: throws in the route module and in shared code map to
their route-file line (Vite).
Mutants verified killed (applied to src, test run through Nx; router-utils
mutants through the source-aliased vitest config because their dist build
fails type-checking):
rp:79 rp:187 rp:189 rp:190 rp:191 rp:192 rp:222 rp:963 rp:965 rp:967
rp:973 rp:975 rp:976 rp:977 rp:980 rp:984 rp:985 rp:986 rp:989 rp:990
rp:992 rp:751 rp:752 rp:753 rp:492 rp:493 rp:495 rp:498 rp:499 rp:500
rp:501 rp:787 rp:788 rp:790 rp:793 rp:794 rp:795 rp:796 rp:851
rp:473 rp:1041-1047 rp:1024 rp:1078 rp:1099
rp:584 rp:585 rp:591 rp:1059 rp:1094 rp:1100 rp:1110 rp:1122 (sample of 34)
rp:566 rp:576 rp:580 rp:581 rp:714 rp:715 rp:717 rp:718
rp:417 rp:418 rp:419 rp:420
rp:1709 rp:1712 rp:1715 rp:1716 rp:1758 rp:1759 rp:1826
rp:1580 rp:1581 rp:1481 rp:1485 rp:1486 rp:1487 rp:1491 rp:1492 rp:1493
rp:1496 rp:1498 rp:1499
rp:529 rp:530 rp:857 rp:858
ru:585 ru:587 ru:588 ru:641 ru:434 ru:435
ru:208 ru:210 ru:328 ru:329 ru:398 ru:402 ru:403 ru:404 ru:194 ru:196
ru:352 ru:300 ru:303
ru:178 ru:180 ru:315 ru:336 ru:359 ru:361 ru:364 ru:367 ru:389 (sample of
the 78-key shadow table) ru:15
Co-Authored-By: Claude Opus 5.5
---
.../tests/build-source-maps.test.ts | 62 +++
.../code-splitter-imports-exports.test.ts | 143 +++++-
.../tests/code-splitter-module-state.test.ts | 423 ++++++++++++++++++
.../code-splitter-plugin-pipeline.test.ts | 63 +++
.../tests/code-splitter-route-options.test.ts | 74 ++-
.../tests/plugin-options.test.ts | 98 +++-
6 files changed, 858 insertions(+), 5 deletions(-)
diff --git a/packages/router-plugin/tests/build-source-maps.test.ts b/packages/router-plugin/tests/build-source-maps.test.ts
index c0a7722f8ae..1eef6136763 100644
--- a/packages/router-plugin/tests/build-source-maps.test.ts
+++ b/packages/router-plugin/tests/build-source-maps.test.ts
@@ -95,6 +95,68 @@ describe('vite', () => {
(options) => expectThrowMapsToRouteFile('vite', options),
timeout,
)
+
+ // Code the splitter leaves in the route module, and code it moves into the
+ // module the route module and a chunk share, map back the same way.
+ test.each([
+ {
+ name: 'the route module (an unsplit loader)',
+ route: `${routeHead()}function Page() {
+ return page
+}
+export const Route = createFileRoute('/')({
+ loader: () => {
+ throw new Error('MARK_LOADER_THROW')
+ },
+ component: Page,
+})
+`,
+ marker: 'MARK_LOADER_THROW',
+ },
+ {
+ name: 'a function the loader and the split component share',
+ route: `${routeHead()}import { label } from '../lib/label'
+function fail(): never {
+ throw new Error('MARK_SHARED_THROW ' + label)
+}
+function Page() {
+ return {typeof fail}
+}
+export const Route = createFileRoute('/')({
+ loader: () => fail(),
+ component: Page,
+})
+`,
+ marker: 'MARK_SHARED_THROW',
+ },
+ ])(
+ 'a throw in $name maps to its line in the route file',
+ async ({ route: source, marker }) => {
+ const app = await build({
+ bundler: 'vite',
+ env: 'client',
+ minify: false,
+ sourcemap: true,
+ files: routeApp(source, {
+ env: 'client',
+ files: {
+ 'lib/label.ts': `export const label = 'MARK_LABEL'\n`,
+ 'entry.ts': `export * from './routes/index'\n`,
+ },
+ }),
+ })
+ const { result: stack } = await app.run(
+ `try { entry.Route.options.loader() } catch (error) { return error.stack }`,
+ )
+ expect(stack).toMatch(marker)
+ const { file, line } = originalFrameOf(app, stack as string) ?? {}
+ expect({ file, line }).toEqual({
+ file: 'routes/index.tsx',
+ line: lineOf(source, `throw new Error('${marker}`),
+ })
+ },
+ timeout,
+ )
})
// Bug: unplugin's webpack and Rspack transform loaders drop the source map
diff --git a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts
index 6b5bcc80431..11b42fca20d 100644
--- a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts
+++ b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts
@@ -4,6 +4,7 @@ import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module'
import {
compileRouteModules,
componentChunk,
+ declaredNames,
evaluateModule,
expectValidModules,
head,
@@ -77,6 +78,23 @@ export const Route = createFileRoute('/')({
])
})
+ it('keeps the other specifiers of the import a split component comes from', async () => {
+ const route = await loadRoute(
+ `${head}import { Page, title } from './page'
+export const Route = createFileRoute('/')({
+ loader: () => title,
+ component: Page,
+})
+`,
+ { stubs: { './page': { Page: () => 'page
', title: 'title' } } },
+ )
+ await expectValidModules(route.modules)
+ expect(route.isLazy('component')).toBe(true)
+ expect([route.options.loader(), (await route.load('component'))()]).toEqual(
+ ['title', 'page
'],
+ )
+ })
+
it('moves a specifier with a string name into the chunk', async () => {
const route = await loadRoute(
`${head}import { "kebab-name" as Kebab } from './icons'
@@ -450,9 +468,24 @@ export const Route = createFileRoute('/_layout')({
binding: 'loaderFn',
value: { foo: 'bar' },
},
+ {
+ name: 'a component default-exported by name, with the errorComponent split',
+ code: `${head}const Page = () => page
+export default Page
+export const Route = createFileRoute('/')({
+ component: Page,
+ errorComponent: () => error
,
+})
+`,
+ split: 'errorComponent',
+ option: 'component',
+ binding: 'default',
+ declared: 'Page',
+ value: 'page
',
+ },
])(
'keep working, and are reported once when not code-split: $name',
- async ({ code, split, option, binding, value }) => {
+ async ({ code, split, option, binding, value, ...row }) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
onTestFinished(() => warn.mockRestore())
const { ids } = compileRouteModules(code)
@@ -468,7 +501,12 @@ export const Route = createFileRoute('/_layout')({
expect(loaded).toBe(route.exports[binding])
}
if (!route.isLazy(option)) {
- const name = binding === 'default' ? 'Home' : binding
+ const name =
+ 'declared' in row
+ ? row.declared
+ : binding === 'default'
+ ? 'Home'
+ : binding
expect(warnings).toEqual([expect.stringContaining(ids.reference!)])
expect(warnings[0]).toMatch(
new RegExp(String.raw`\b(?:${name}|${option})\b`),
@@ -476,6 +514,93 @@ export const Route = createFileRoute('/_layout')({
}
},
)
+
+ it.each([
+ {
+ name: 'a default export of another binding',
+ exports: `const meta = { title: 'home' }
+export default meta`,
+ },
+ {
+ name: 'an exported object destructuring',
+ exports: `const config = { title: 't' }
+export const { title } = config`,
+ },
+ ])(
+ 'do not keep an unexported component from being split: $name',
+ async ({ exports }) => {
+ const route = await loadRoute(`${head}${exports}
+function Page() {
+ return page
+}
+export const Route = createFileRoute('/')({ component: Page })
+`)
+ await expectValidModules(route.modules)
+ expect(route.isLazy('component')).toBe(true)
+ expect((await route.load('component'))()).toBe('page
')
+ },
+ )
+
+ it.each([
+ {
+ name: 'export function',
+ declaration: `export function helper() {\n return 'h'\n}`,
+ },
+ { name: 'export const', declaration: `export const helper = () => 'h'` },
+ ])(
+ 'stay readable by a split component next to an exported option component ($name)',
+ async ({ declaration }) => {
+ const route = await loadRoute(`${head}${declaration}
+export function NotFound() {
+ return nf
+}
+export const Route = createFileRoute('/')({
+ component: () => {helper()}
,
+ notFoundComponent: NotFound,
+})
+`)
+ expect((await route.load('component'))()).toBe('h
')
+ expect(route.exports.helper()).toBe('h')
+ },
+ )
+
+ // The chunk of a split option holds only what that option needs: an
+ // exported component of another option is not copied into it.
+ it.each([
+ {
+ name: 'export default function',
+ declaration: `export default function NotFound() {\n return MARK_NF \n}`,
+ },
+ {
+ name: 'export default by name',
+ declaration: `function NotFound() {\n return MARK_NF \n}\nexport default NotFound`,
+ },
+ {
+ name: 'export function',
+ declaration: `export function NotFound() {\n return MARK_NF \n}`,
+ },
+ {
+ name: 'export const',
+ declaration: `export const NotFound = () => MARK_NF `,
+ },
+ ])(
+ "are not copied into another option's chunk ($name)",
+ async ({ declaration }) => {
+ const route = await loadRoute(`${head}${declaration}
+export const Route = createFileRoute('/')({
+ component: () => page
,
+ notFoundComponent: NotFound,
+})
+`)
+ expect((await route.load('component'))()).toBe('page
')
+ expect((await route.load('notFoundComponent'))()).toBe('MARK_NF ')
+ const chunk = route.chunkOf('component')
+ expect({
+ declares: declaredNames(chunk).has('NotFound'),
+ marker: chunk.includes('MARK_NF'),
+ }).toEqual({ declares: false, marker: false })
+ },
+ )
})
describe('TypeScript-only syntax', () => {
@@ -768,4 +893,18 @@ export const Route = createFileRoute('/')({ component: () =>
})
`).modules
expect(reference).toMatch(/@observable\s+export class Store\b/)
})
+
+ // Auto-accessor fields (TypeScript 4.9+, ES decorators; MobX 6 and Lit use
+ // them). Compiled only: the module evaluator does not run `accessor`.
+ it('compiles a class with an auto-accessor field that the split component uses', async () => {
+ const { modules } =
+ compileRouteModules(`${head}class Store { accessor count = 1 }
+const store = new Store()
+export const Route = createFileRoute('/')({
+ loader: () => store,
+ component: () => {store.count}
,
+})
+`)
+ await expectValidModules(modules)
+ })
})
diff --git a/packages/router-plugin/tests/code-splitter-module-state.test.ts b/packages/router-plugin/tests/code-splitter-module-state.test.ts
index 77fb821be8c..cfaab5f0039 100644
--- a/packages/router-plugin/tests/code-splitter-module-state.test.ts
+++ b/packages/router-plugin/tests/code-splitter-module-state.test.ts
@@ -649,3 +649,426 @@ ${options.join('\n')}
},
)
})
+
+// A binding the loader (route module) and the split component both read
+// (directly or through what they call) is created once, and both get it.
+describe('a binding the loader and the split component share', () => {
+ // `useQuery({ ... })` in a split component: only `createFileRoute(...)`
+ // gives the route options.
+ it('is created once when the component calls a hook with an options object', async () => {
+ const route = await loadRoute(
+ `${head}import { useThing } from './thing'
+const state = { count: 0 }
+export const Route = createFileRoute('/')({
+ loader: () => ++state.count,
+ component: () => {
+ const value = useThing({ label: 'x' })
+ return {state.count}{value.label}
+ },
+})
+`,
+ { stubs: { './thing': { useThing: (options: unknown) => options } } },
+ )
+ expect([route.options.loader(), (await route.load('component'))()]).toEqual(
+ [1, '1x
'],
+ )
+ })
+
+ it('keeps exported classes next to it importable, with one identity', async () => {
+ const route = await loadRoute(`${head}export class AppError extends Error {}
+export class Cache {
+ value = 'cached'
+}
+class Local {
+ value = 'local'
+}
+export const Route = createFileRoute('/')({
+ loader: () => ({ AppError, cached: new Cache().value, local: new Local().value }),
+ component: () => AppError,
+})
+`)
+ await expectValidModules(route.modules)
+ const imported = await route.importFromRoute(
+ `export { AppError, Cache } from '#route'\n`,
+ )
+ const loaded = route.options.loader()
+ expect({
+ sameInLoader: loaded.AppError === imported.AppError,
+ sameInComponent: (await route.load('component'))() === imported.AppError,
+ cached: loaded.cached,
+ local: loaded.local,
+ cache: new imported.Cache().value,
+ }).toEqual({
+ sameInLoader: true,
+ sameInComponent: true,
+ cached: 'cached',
+ local: 'local',
+ cache: 'cached',
+ })
+ })
+
+ /**
+ * Loads a route whose loader passes `loader` and whose split component
+ * passes `component` (expressions over `declarations`) to `see`, and
+ * checks that both ran with what the route file declares: `init` (called
+ * once by `declarations`) ran once, and the loader and the component got
+ * the same value when they read the same expression.
+ */
+ async function expectSharedOnce({
+ declarations,
+ loader = 'value',
+ component = 'value',
+ }: {
+ declarations: string
+ loader?: string
+ component?: string
+ }) {
+ let inits = 0
+ const seen: Array = []
+ const route = await loadRoute(
+ `${head}import { init, see, UI } from './state'
+${declarations}
+export const Route = createFileRoute('/')({
+ loader: () => see(${loader}),
+ component: () => {String(see(${component}))}
,
+})
+`,
+ {
+ stubs: {
+ './state': {
+ init: (value: T) => {
+ inits++
+ return value
+ },
+ see: (value: any) => {
+ seen.push(value)
+ return typeof value?.read === 'function'
+ ? value.read().name
+ : 'ok'
+ },
+ UI: { Title: () => 'title' },
+ },
+ },
+ },
+ )
+ await expectValidModules(route.modules)
+ const loaded = route.options.loader()
+ expect((await route.load('component'))()).toBe(`${loaded}
`)
+ expect(seen).toHaveLength(2)
+ if (loader === component) {
+ expect(seen[0]).toBe(seen[1])
+ }
+ expect(inits).toBe(1)
+ }
+
+ it.each([
+ {
+ name: 'a class that reads another binding',
+ declarations: `const config = init({ name: 'config' })
+class Store { settings = config }
+const value = new Store()`,
+ },
+ {
+ name: 'an array destructuring',
+ declarations: `const [a, b] = init([{ id: 'a' }, { id: 'b' }])`,
+ loader: 'a',
+ component: 'b',
+ },
+ {
+ name: 'an array destructuring with a rest element',
+ declarations: `const [first, ...rest] = init([1, 2, 3])`,
+ loader: 'first',
+ component: 'rest',
+ },
+ {
+ name: 'an object read as a JSX member tag',
+ declarations: `const Parts = init({ Title: () => 'title' })
+const Header = () => `,
+ loader: 'Header()',
+ component: 'Header()',
+ },
+ {
+ name: 'a helper with a block-scoped local of the same name in one branch',
+ declarations: `const config = init({ name: 'config' })
+function read() {
+ if (Math.random() > 2) { const config = { name: 'local' }; return config }
+ return config
+}
+const value = { read }`,
+ },
+ {
+ name: 'an object with a var of the same name in a sibling method',
+ declarations: `const config = init({ name: 'config' })
+const value = {
+ local() { var config = 'local'; return config },
+ read() { return config },
+}`,
+ },
+ ])('reaches the split component through $name', expectSharedOnce)
+
+ // Each row declares a module-level `title` (or component) that reads
+ // `Route`, and a local of the same name inside the shared value: the local
+ // is not a read of the module-level binding.
+ it.each([
+ ['an arrow function parameter', `init({ format: (title) => title })`],
+ [
+ 'a function expression parameter',
+ `init({ format: function (title) { return title } })`,
+ ],
+ ['an object method parameter', `init({ format(title) { return title } })`],
+ [
+ 'a class method parameter',
+ `init(new (class { format(title) { return title } })())`,
+ ],
+ [
+ 'a private method parameter',
+ `init(new (class { #format(title) { return title } run() { return this.#format(1) } })())`,
+ ],
+ ['a destructured parameter', `init({ format: ({ title }) => title })`],
+ ['a rest parameter', `init({ f: (...title) => title })`],
+ [
+ 'a catch parameter',
+ `init({ run: () => { try { return 1 } catch (title) { return title } } })`,
+ ],
+ ['a local const', `init({ run: () => { const title = 1; return title } })`],
+ [
+ 'a const in a nested block',
+ `init({ run: () => { if (Math.random() < 2) { const title = 1; return title } return 0 } })`,
+ ],
+ [
+ 'a var in a nested block',
+ `init({ run: () => { if (Math.random() < 2) { var title = 1 } return title } })`,
+ ],
+ [
+ 'a function declared after its use',
+ `init({ run: () => { return title(); function title() { return 1 } } })`,
+ ],
+ [
+ 'a const read by a closure before its declaration',
+ `init({ run: () => { const get = () => title; const title = 1; return get() } })`,
+ ],
+ [
+ 'a class declared after its use',
+ `init({ run: () => { const make = () => new title(); class title {} return make() } })`,
+ ],
+ [
+ 'a for-of binding',
+ `init({ run: () => { for (const title of [1]) { return title } } })`,
+ ],
+ [
+ 'the name of a class expression',
+ `init(class title { static self = () => title })`,
+ ],
+ [
+ 'a class declared in a function',
+ `init({ run: () => { class title {} return new title() } })`,
+ ],
+ [
+ 'the name of a function expression',
+ `init({ run: function title() { return title } })`,
+ ],
+ [
+ 'a function declared in a function',
+ `init({ run: () => { function title() { return 1 } return title() } })`,
+ ],
+ [
+ 'a local type alias',
+ `init({ run: () => { type title = number; const x: title = 1; return x } })`,
+ ],
+ [
+ 'a local enum',
+ `init({ run: () => { enum title { A } return title.A } })`,
+ ],
+ ])(
+ 'is not tied to a module-level helper reading Route by %s of the same name',
+ (_, value) =>
+ expectSharedOnce({
+ declarations: `const title = () => Route.id
+const value = ${value}`,
+ }),
+ )
+
+ it.each([
+ [
+ 'a destructured prop rendered as JSX',
+ `const Icon = () => {Route.id}
+const value = init({ render: ({ Icon }) => })`,
+ ],
+ [
+ 'the property of a JSX member tag',
+ `const Title = () => {Route.id}
+const value = init({ render: () => })`,
+ ],
+ [
+ 'a JSX attribute name',
+ `const Title = () => Route.id
+const value = init({ render: () =>
})`,
+ ],
+ ])(
+ 'is not tied to a module-level component reading Route by %s of the same name',
+ (_, declarations) => expectSharedOnce({ declarations }),
+ )
+})
+
+// The route module keeps what the rest of the app imports from the route
+// file working when a split component comes from the same declaration.
+describe('a split component declared next to other bindings', () => {
+ it.each([
+ { name: 'plain', pattern: '{ Panel, usePanel }' },
+ {
+ name: 'with defaults',
+ pattern: '{ Panel = Fallback, usePanel = useFallback }',
+ },
+ ])(
+ 'keeps the sibling of a destructuring working ($name)',
+ async ({ pattern }) => {
+ const route = await loadRoute(
+ `${head}import { createPanel } from './panel'
+const Fallback = () => fallback
+const useFallback = () => 'fallback'
+const ${pattern} = createPanel()
+export function usePanelState() {
+ return usePanel()
+}
+export const Route = createFileRoute('/')({ component: Panel })
+`,
+ {
+ stubs: {
+ './panel': {
+ createPanel: () => ({
+ Panel: () => 'panel
',
+ usePanel: () => 'state',
+ }),
+ },
+ },
+ },
+ )
+ await expectValidModules(route.modules)
+ expect([
+ route.exports.usePanelState(),
+ (await route.load('component'))(),
+ ]).toEqual(['state', 'panel
'])
+ },
+ )
+
+ it('reads an exported object destructuring created once', async () => {
+ let calls = 0
+ const route = await loadRoute(
+ `${head}import { getConfig } from './config'
+export const { title } = getConfig()
+export const Route = createFileRoute('/')({
+ component: () => {title}
,
+})
+`,
+ {
+ stubs: {
+ './config': {
+ getConfig: () => {
+ calls++
+ return { title: 't' }
+ },
+ },
+ },
+ },
+ )
+ await expectValidModules(route.modules)
+ expect([
+ (await route.load('component'))(),
+ calls,
+ route.exports.title,
+ ]).toEqual(['t
', 1, 't'])
+ })
+})
+
+// In a JavaScript route file, imports are not elided by a type-aware
+// transform, so the route module itself must not import what only split
+// options read.
+describe('state only split options read, in a .jsx route file', () => {
+ const stateRoute = (options: string) => `${head}import { init } from './init'
+const store = init({ label: 'shared' })
+export const Route = createFileRoute('/')({
+${options}
+})
+`
+
+ it('is created once, when the first of them loads', async () => {
+ let inits = 0
+ const route = await loadRoute(
+ stateRoute(` component: () => {store.label}
,
+ errorComponent: () => {store.label}
,`),
+ {
+ filename: 'route.jsx',
+ stubs: {
+ './init': {
+ init: (value: unknown) => {
+ inits++
+ return value
+ },
+ },
+ },
+ },
+ )
+ const atLoad = inits
+ const rendered = [
+ (await route.load('component'))(),
+ (await route.load('errorComponent'))(),
+ ]
+ expect({ atLoad, rendered, inits }).toEqual({
+ atLoad: 0,
+ rendered: ['shared
', 'shared
'],
+ inits: 1,
+ })
+ })
+
+ it('is not created by loading a split option that does not read it', async () => {
+ let inits = 0
+ const route = await loadRoute(
+ stateRoute(` component: () => {store.label}
,
+ errorComponent: () => {store.label}
,
+ notFoundComponent: () => nf
,`),
+ {
+ filename: 'route.jsx',
+ stubs: {
+ './init': {
+ init: (value: unknown) => {
+ inits++
+ return value
+ },
+ },
+ },
+ },
+ )
+ expect((await route.load('notFoundComponent'))()).toBe('nf
')
+ expect(inits).toBe(0)
+ expect((await route.load('component'))()).toBe('shared
')
+ expect(inits).toBe(1)
+ })
+
+ it('does not load what only the split component imports with the state the loader shares', async () => {
+ const loaded: Array = []
+ const route = await loadRoute(
+ `${head}import { createStore } from './store'
+import { Chart } from './chart'
+const store = createStore()
+export const Route = createFileRoute('/')({
+ loader: () => store.get(),
+ component: () => ,
+})
+`,
+ {
+ filename: 'route.jsx',
+ stubs: {
+ './store': { createStore: () => ({ get: () => 'v' }) },
+ './chart': {
+ get Chart() {
+ loaded.push('./chart')
+ return ({ data }: { data: string }) => `${data}
`
+ },
+ },
+ },
+ },
+ )
+ expect([route.options.loader(), loaded]).toEqual(['v', []])
+ expect((await route.load('component'))()).toBe('v
')
+ })
+})
diff --git a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
index 7989f23a7db..32408179004 100644
--- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
+++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
@@ -3,8 +3,10 @@ import {
compileRouteModules,
createCodeSplitterTransforms,
createRouteHmrTransform,
+ createRouteLoader,
evaluateModule,
expectValidModules,
+ head,
hotUpdate,
importRequests,
importSources,
@@ -27,6 +29,9 @@ export const Route = createFileRoute('/${name}')({
})
`
+const referencePluginName =
+ 'tanstack-router:code-splitter:compile-reference-file'
+
/** Ids of the modules of `file` a compiled module imports, by kind. */
function routeModuleRequests(code: string, file: string) {
const requests = importRequests(code).filter(
@@ -188,6 +193,64 @@ export const Route = createFileRoute('/other')({ component: () =>
})
).rejects.toThrow('Plugin order error')
})
+ // Each JSX plugin the router plugin must come before, named by its package.
+ it.each([
+ ['vite:react-swc:resolve-runtime', '@vitejs/plugin-react-swc'],
+ ['vite:react-swc', '@vitejs/plugin-react-swc'],
+ ['vite:react-oxc:config', '@vitejs/plugin-react-oxc'],
+ ['vite:react-oxc:refresh-runtime', '@vitejs/plugin-react-oxc'],
+ ['vite:react-refresh', '@vitejs/plugin-react'],
+ ])('refuses %s placed before the router plugin', async (name, pkg) => {
+ await expect(
+ createCodeSplitterTransforms({}, {}, [
+ { name },
+ { name: referencePluginName },
+ ]),
+ ).rejects.toThrow(pkg)
+ })
+
+ it('refuses vite-plugin-solid placed before the router plugin of a Solid app', async () => {
+ const { default: solid } = await import('vite-plugin-solid')
+ const solidPlugins = [solid()].flat() as Array<{ name: string }>
+ await expect(
+ createCodeSplitterTransforms({ target: 'solid' }, {}, [
+ ...solidPlugins,
+ { name: referencePluginName },
+ ]),
+ ).rejects.toThrow(/Plugin order error[\s\S]*vite-plugin-solid/)
+ await expect(
+ createCodeSplitterTransforms({ target: 'solid' }, {}, [
+ { name: referencePluginName },
+ ...solidPlugins,
+ ]),
+ ).resolves.toBeDefined()
+ })
+
+ // A dev server compiles each edit of a route file with the same plugin
+ // instance.
+ it('compiles an edited route like a fresh one after a version whose loader and component shared a binding', async () => {
+ const load = createRouteLoader({
+ stubs: { './label': { label: { text: 'imported' } } },
+ })
+ const before = await load(`${head}const label = { text: 'before' }
+export const Route = createFileRoute('/')({
+ loader: () => label,
+ component: () => {label.text}
,
+})
+`)
+ expect((await before.load('component'))()).toBe('before
')
+ const after = await load(`${head}import { label } from './label'
+export const Route = createFileRoute('/')({
+ loader: () => 'data',
+ component: () => {label.text}
,
+})
+`)
+ await expectValidModules(after.modules)
+ expect([after.options.loader(), (await after.load('component'))()]).toEqual(
+ ['data', 'imported
'],
+ )
+ })
+
// Each framework's route module imports the lazy route helpers from its
// own router package, and no module imports another framework's router.
describe.each(['solid', 'vue'] as const)('a %s route', (framework) => {
diff --git a/packages/router-plugin/tests/code-splitter-route-options.test.ts b/packages/router-plugin/tests/code-splitter-route-options.test.ts
index 52eae944f38..13764b91bb0 100644
--- a/packages/router-plugin/tests/code-splitter-route-options.test.ts
+++ b/packages/router-plugin/tests/code-splitter-route-options.test.ts
@@ -1,8 +1,9 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
compileRouteModules,
declaredNames,
expectValidModules,
+ generatedRouteId,
head,
hotUpdate,
importSources,
@@ -96,6 +97,28 @@ export const Route = createFileRoute('/posts')(options)
expect(route.isLazy(option)).toBe(false)
})
+ // A documented grouping: every option in one chunk.
+ it('keep literal and method options grouped with a split option as written', async () => {
+ const route =
+ await loadRoute(`${head}export const Route = createFileRoute('/')({
+ codeSplitGroupings: [['loader', 'component', 'errorComponent', 'pendingComponent', 'notFoundComponent']],
+ loader() { return 'data' },
+ component: () => page
,
+ errorComponent: false,
+ pendingComponent: null,
+ notFoundComponent: undefined,
+})
+`)
+ await expectValidModules(route.modules)
+ expect((await route.load('component'))()).toBe('page
')
+ expect([
+ await (await route.load('loader'))(),
+ route.options.errorComponent,
+ route.options.pendingComponent,
+ route.options.notFoundComponent,
+ ]).toEqual(['data', false, null, undefined])
+ })
+
// A `var` declared in a block at module level is a module-level binding,
// so the route it creates is no route created inside a function.
// Source: #8504 (differential fuzzing).
@@ -243,6 +266,53 @@ export const Route = createFileRoute('/')({
expect(await route.options.loader()).toBe(loaded)
}
})
+
+ it('load the module a split loader is imported from with the loader only', async () => {
+ const log: Array = []
+ const route = await loadRoute(
+ `${head}import { loadData } from './loader'
+export const Route = createFileRoute('/')({
+ codeSplitGroupings: [['loader'], ['component']],
+ loader: loadData,
+ component: () => page
,
+})
+`,
+ {
+ stubs: {
+ './loader': {
+ get loadData() {
+ log.push('./loader')
+ return () => 'data'
+ },
+ },
+ },
+ },
+ )
+ expect(route.isLazy('loader')).toBe(true)
+ const before = [...log]
+ expect([before, await route.options.loader(), log]).toEqual([
+ [],
+ 'data',
+ ['./loader'],
+ ])
+ })
+
+ it('split a loader declared as a local function without a warning', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ onTestFinished(() => warn.mockRestore())
+ const route = await loadRoute(`${head}function load() {
+ return 'data'
+}
+export const Route = createFileRoute('/')({
+ codeSplitGroupings: [['loader'], ['component']],
+ loader: load,
+ component: () => page
,
+})
+`)
+ expect(route.isLazy('loader')).toBe(true)
+ expect(await route.options.loader()).toBe('data')
+ expect(warn.mock.calls).toEqual([])
+ })
})
describe('route option values', () => {
@@ -527,7 +597,7 @@ export const Route = ${call}
`
const update = await hotUpdate(source(1), {
codeSplitting: true,
- routeId: '/__root',
+ routeId: await generatedRouteId('__root.tsx'),
updated: source(2),
})
expect([update.previous.staticData, update.live.staticData]).toEqual([
diff --git a/packages/router-plugin/tests/plugin-options.test.ts b/packages/router-plugin/tests/plugin-options.test.ts
index df4acf2fa39..7e71c2176c7 100644
--- a/packages/router-plugin/tests/plugin-options.test.ts
+++ b/packages/router-plugin/tests/plugin-options.test.ts
@@ -4,8 +4,14 @@
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
-import { describe, expect, test } from 'vitest'
+import { fileURLToPath } from 'node:url'
+import {
+ createDevServer,
+ evaluateServed,
+} from '@tanstack/compiler-test-utils/dev-server'
+import { describe, expect, onTestFinished, test } from 'vitest'
import { build, routeApp, routeHead } from './build-harness'
+import { loadRouteOption, rootRouteOf, routerPackageStub } from './dev-server'
import { compileRouteModules, head } from './regression-helpers'
import type {
BuildResult,
@@ -15,6 +21,8 @@ import type { RouterPluginOptions } from './build-harness'
const timeout = 120_000
+const harnessDir = path.dirname(fileURLToPath(import.meta.url))
+
const bundlers = ['vite', 'rspack', 'webpack'] as const
/** A route whose component is split by default. */
@@ -115,6 +123,94 @@ describe('plugin.vite.environmentName', () => {
)
})
+// TanStack Start splits each environment with its own code splitter, scoped
+// with `environmentName` and sharing one router plugin context
+// (start-plugin-core's start router plugin): both environments compile the
+// route with the same plugin instances, and each instance compiles the route
+// file, its chunks and its shared module in its own environment only.
+test(
+ 'two code splitters scoped to the client and SSR environments of one dev server each split a route whose loader and component share a binding',
+ async () => {
+ const { createRouterPluginContext } = await import('../src/context')
+ const { tanStackRouterCodeSplitter, tanstackRouterGenerator } =
+ await import('../src/vite')
+ const server = await createDevServer({
+ dir: harnessDir,
+ prefix: '.router-dev-server-',
+ registerCleanup: onTestFinished,
+ files: {
+ 'routes/__root.tsx': rootRouteOf('react'),
+ 'routes/index.tsx': `${routeHead()}const label = { text: 'MARK_SHARED' }
+function Page() {
+ return 'MARK_COMPONENT ' + label.text
+}
+export const Route = createFileRoute('/')({
+ loader: () => label,
+ component: Page,
+})
+`,
+ },
+ plugins: (app) => {
+ const context = createRouterPluginContext()
+ const config: RouterPluginOptions = {
+ target: 'react',
+ autoCodeSplitting: true,
+ routesDirectory: path.join(app.root, 'routes'),
+ generatedRouteTree: path.join(app.root, 'routeTree.gen.ts'),
+ codeSplittingOptions: { addHmr: false },
+ }
+ return [
+ tanstackRouterGenerator(config, context),
+ ...(['client', 'ssr'] as const).map((environmentName) =>
+ tanStackRouterCodeSplitter(
+ { ...config, plugin: { vite: { environmentName } } },
+ context,
+ ),
+ ),
+ routerPackageStub('react'),
+ ]
+ },
+ })
+ /** Loads the route's split component and its loader's value. */
+ const read = async (routeModule: Record) => {
+ const { options } = routeModule.Route
+ const component = (await loadRouteOption(
+ options.component,
+ )) as () => string
+ return [component(), options.loader()]
+ }
+ const expected = ['MARK_COMPONENT MARK_SHARED', { text: 'MARK_SHARED' }]
+ expect(await read(await server.import('/routes/index.tsx'))).toEqual(
+ expected,
+ )
+ // The browser gets the router package stub from Vite's virtual module
+ // URL; evaluated here from the same SSR-loaded module.
+ const routerPackage = await server.import('@tanstack/react-router')
+ expect(
+ await read(
+ await evaluateServed(server, '/routes/index.tsx', {
+ skip: /^\/@id\//,
+ stubs: {
+ '/@id/__x00__@tanstack/react-router-stub': routerPackage,
+ },
+ }),
+ ),
+ ).toEqual(expected)
+ },
+ timeout,
+)
+
+describe('codeSplittingOptions.splitBehavior', () => {
+ test('that is not a function is refused when the plugin is created, naming the option', async () => {
+ const { tanstackRouter } = await import('../src/vite')
+ expect(() =>
+ tanstackRouter({
+ codeSplittingOptions: { splitBehavior: [['loader']] as any },
+ }),
+ ).toThrow(/splitBehavior/)
+ })
+})
+
describe('repeated options in split groupings', () => {
test('in defaultBehavior are refused with an error naming the option', async () => {
const { tanstackRouter } = await import('../src/vite')
From 09e8f37f5429a12799b2ce2a8d9b7e5c16a74e57 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 07:23:09 +0200
Subject: [PATCH 0294/1425] test(router-plugin): pin code-splitter main bugs
found by mutation testing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix-agnostic .fails pins (REPORT section 6, bugs 6-9, plus low-tier pin
rows), each verified red on main for the stated reason by a scratch run
without .fails:
- exported option component reading Route rendered by a split component
(export function / export const): ReferenceError "NotFound is not defined".
- split option values outside the virtual compiler's allowlist (namespace
member, local member loader, `||`, `??`, tagged template, class
expression): "Unexpected splitNode type ☝️: MemberExpression /
LogicalExpression / TaggedTemplateExpression / ClassExpression".
- `export default function` called by the loader and a split component:
TypeError "Duplicate declaration \"bump\"".
- `createFileRoute<'/'>('/')` is never split (code filter misses type
arguments): component not lazy.
- a module whose path contains `tsr-split`: build fails with "The split
value for the virtual route ... was not found".
- split loader also exported through `export { load }`: "Export 'load' is
not defined".
- binding read by a computed-key option and a split option: created twice.
- `export @dec class` (TS 5 decorators): "Decorators must be placed
*before* the 'export' keyword".
Controls (passing): `{...} as any`, `(createFileRoute('/') as any)(...)` and
`createFileRoute('/')(makeOptions())` routes still render.
Mutants verified: control rows kill rp:80 rp:83; pins flip (killed) under
rp:1068 rp:1069 rp:1071 rp:1073 rp:1074 rp:1076 rp:1077 rp:1079 rp:1080
rp:1081 rp:1082 rp:426 rp:427 rp:428 rp:137 rp:1636 ru:13. rp:1613 and
rp:1614 do not flip the tsr-split pin: they only change how the bug fails.
Co-Authored-By: Claude Opus 5.5
---
.../tests/known-bugs-code-splitter.test.ts | 287 ++++++++++++++++++
1 file changed, 287 insertions(+)
diff --git a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts
index 289fa14d3c2..ea8a4ca20b1 100644
--- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts
+++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts
@@ -74,6 +74,35 @@ export const Route = createFileRoute('/')({
]).toEqual([1, '1
', 'error
', 1, 1])
})
+ // Bug: a binding that an option with a computed key and a split option
+ // both read is created twice: the computed-key option is not seen as a
+ // reader of the route module.
+ // Source: round-3 mutation testing (compilers-A).
+ // Impact: module state is created twice (two stores, registrations run
+ // twice).
+ test.fails(
+ 'a binding read by a computed-key option and a split option is created once',
+ async () => {
+ const state = stateStub()
+ const route = await loadRoute(
+ `${head}import { init } from './state'
+const store = init({ count: 1 })
+const key = 'staticData'
+export const Route = createFileRoute('/')({
+ [key]: { read: () => store },
+ loader: () => 'data',
+ component: () => {store.count}
,
+})
+`,
+ { stubs: { './state': state } },
+ )
+ expect([(await route.load('component'))(), state.calls]).toEqual([
+ '1
',
+ 1,
+ ])
+ },
+ )
+
// Bug: a `var` nested in a statement, an enum or a namespace is not shared.
// The `var` is missed because module bindings are collected from top-level
// declarations only (`collectLocalBindingsFromStatement`); same root cause
@@ -1077,6 +1106,107 @@ function Page() {
expect((await loaded.load('component'))()).toBe('page
')
})
+ // Controls for the pins above and in "route factories written in other
+ // shapes": these routes keep working (split or not), so the pins fail only
+ // because the component is not split.
+ test.each([
+ `createFileRoute('/')({ ssr: false, component: Page } as any)`,
+ `(createFileRoute('/') as any)({ ssr: false, component: Page })`,
+ `createFileRoute('/')(makeOptions())`,
+ ])('a route written as %s renders its component', async (route) => {
+ const loaded = await loadRoute(`${head}function Page() {
+ return page
+}
+const makeOptions = () => ({ component: Page })
+export const Route = ${route}
+`)
+ await expectValidModules(loaded.modules)
+ expect((await loaded.load('component'))()).toBe('page
')
+ })
+
+ // Bug: the virtual compiler accepts only a fixed list of expression types
+ // for the value of a split option; any other expression the route module
+ // splits makes the build throw "Unexpected splitNode type ☝️:
+ // MemberExpression" (LogicalExpression, TaggedTemplateExpression,
+ // ClassExpression). These are our limitations, so the goal is a split
+ // option (decision D11).
+ // Source: round-3 mutation testing (compilers-B, bug B).
+ // Impact: a route whose component is a namespace member (`Pages.Home`), a
+ // fallback (`A ?? B`), a styled-components tagged template or a class
+ // cannot be built once code splitting is on.
+ test.fails.each<{
+ name: string
+ option?: string
+ value: string
+ render: (value: any) => unknown
+ expected: unknown
+ }>([
+ {
+ name: 'a member of a namespace import',
+ value: 'Pages.Home',
+ render: (component) => component(),
+ expected: 'home
',
+ },
+ {
+ name: 'a member of a local object (loader)',
+ option: 'loader',
+ value: 'api.load',
+ render: (loader) => loader(),
+ expected: 'data',
+ },
+ {
+ name: 'a logical fallback',
+ value: 'Pages.Missing || Fallback',
+ render: (component) => component(),
+ expected: 'fallback
',
+ },
+ {
+ name: 'a nullish fallback',
+ value: 'Pages.Missing ?? Fallback',
+ render: (component) => component(),
+ expected: 'fallback
',
+ },
+ {
+ name: 'a tagged template',
+ value: 'styled`home`',
+ render: (component) => component(),
+ expected: 'styled home
',
+ },
+ {
+ name: 'a class expression',
+ value: `class { render() { return 'class
' } }`,
+ render: (component) => new component().render(),
+ expected: 'class
',
+ },
+ ])(
+ 'an option written as $name is split and works',
+ async ({ option = 'component', value, render, expected }) => {
+ const route = await loadRoute(
+ `${head}import * as Pages from './pages'
+import { styled } from './styled'
+const Fallback = () => 'fallback
'
+const api = { load: () => 'data' }
+export const Route = createFileRoute('/')({
+ codeSplitGroupings: [['loader'], ['component']],
+ loader: ${option === 'loader' ? value : `() => 'data'`},
+ component: ${option === 'component' ? value : `() => page
`},
+})
+`,
+ {
+ stubs: {
+ './pages': { Home: () => 'home
' },
+ './styled': {
+ styled: (strings: TemplateStringsArray) => () =>
+ `styled ${strings[0]}
`,
+ },
+ },
+ },
+ )
+ expect(route.isLazy(option)).toBe(true)
+ expect(render(await route.load(option))).toBe(expected)
+ },
+ )
+
// Bug: a component option written as a call with JSX inside it (React's
// `memo()`, Vue's `defineComponent()`) cannot be split: the virtual
// compiler parses the option without JSX ("Support for the experimental
@@ -1162,6 +1292,25 @@ function Page() {
},
)
+ // Bug: the code splitter only compiles files whose code matches
+ // `createFileRoute(`, so a route whose factory call has a type argument
+ // (`createFileRoute<'/'>('/')`) is left as is, and nothing says so. Our
+ // limitation, so the goal is a split component (decision D11).
+ // Source: round-3 mutation testing (splitter-hosts X1).
+ // Impact: the route's components load with the entry chunk, silently.
+ test.fails(
+ 'a component is split when createFileRoute has a type argument',
+ async () => {
+ const route = await loadRoute(`${head}function Page() {
+ return page
+}
+export const Route = createFileRoute<'/'>('/')({ component: Page })
+`)
+ expect(route.isLazy('component')).toBe(true)
+ expect((await route.load('component'))()).toBe('page
')
+ },
+ )
+
// Bug: route options returned by a call (`createFileRoute('/')(
// makeOptions())`) are neither split nor reported. The user can write the
// options inline, so a warning naming the file is an acceptable outcome
@@ -2060,6 +2209,102 @@ export const Route = createFileRoute('/')({ component: Bar, errorComponent: NoFo
},
)
+ // Bug: the code splitter deletes the exported declaration of an option's
+ // component from every chunk, so a split component that renders the
+ // exported component of another option, which reads `Route`, renders an
+ // undeclared binding ("NotFound is not defined").
+ // Source: round-3 mutation testing (compilers-B, bug A).
+ // Impact: the route crashes when its component renders.
+ test.fails.each([
+ {
+ name: 'export function',
+ declaration: `export function NotFound() {
+ return {Route.options.staticData.label}
+}`,
+ },
+ {
+ name: 'export const',
+ declaration: `export const NotFound = () => {Route.options.staticData.label} `,
+ },
+ ])(
+ 'a split component renders the exported component of another option that reads Route ($name)',
+ async ({ declaration }) => {
+ const route = await loadRoute(`${head}${declaration}
+export const Route = createFileRoute('/')({
+ component: () =>
,
+ staticData: { label: 'nf' },
+ notFoundComponent: NotFound,
+})
+`)
+ expect((await route.load('component'))()).toBe('nf
')
+ },
+ )
+
+ // Bug: a default-exported function declaration that the loader and a split
+ // component both call cannot be compiled: Babel throws "Duplicate
+ // declaration" for it.
+ // Source: round-3 mutation testing (router-utils).
+ // Impact: the route cannot be built once code splitting is on.
+ test.fails(
+ 'a default-exported function the loader and the split component call works on one state',
+ async () => {
+ let inits = 0
+ const route = await loadRoute(
+ `${head}import { init } from './state'
+const store = init({ n: 0 })
+export default function bump() {
+ return ++store.n
+}
+export const Route = createFileRoute('/')({
+ loader: () => bump(),
+ component: () => {bump()}
,
+})
+`,
+ {
+ stubs: {
+ './state': {
+ init: (value: unknown) => {
+ inits++
+ return value
+ },
+ },
+ },
+ },
+ )
+ expect([
+ route.options.loader(),
+ (await route.load('component'))(),
+ route.exports.default(),
+ inits,
+ ]).toEqual([1, '2
', 3, 1])
+ },
+ )
+
+ // Bug: a split loader that the route file also exports through an export
+ // list (`export { load }`) loses its declaration in the route module:
+ // "Export 'load' is not defined".
+ // Source: round-3 mutation testing (compilers-A).
+ // Impact: the route cannot be built once code splitting is on.
+ test.fails(
+ 'a split loader exported through an export list keeps working',
+ async () => {
+ const route = await loadRoute(`${head}function load() {
+ return 'data'
+}
+export { load }
+export const Route = createFileRoute('/')({
+ codeSplitGroupings: [['loader'], ['component']],
+ loader: load,
+ component: () => page
,
+})
+`)
+ expect([await route.options.loader(), route.exports.load()]).toEqual([
+ 'data',
+ 'data',
+ ])
+ },
+ )
+
/**
* An entry that imports `Route` and, like another module of the app,
* `imports` from the route file, then runs `setup` before any split chunk
@@ -2641,6 +2886,21 @@ export { routeTree }
})
describe('TypeScript-only syntax', () => {
+ // Bug: the code splitter rejects an ES decorator after `export` (`export
+ // @dec class`, valid since TypeScript 5.0): "Decorators must be placed
+ // before the export keyword".
+ // Source: round-3 mutation testing (router-utils P6).
+ // Impact: a route file with such a class cannot be built once code
+ // splitting is on.
+ test.fails('a decorator after `export` compiles', async () => {
+ const { modules } =
+ compileRouteModules(`${head}import { observable } from 'mobx'
+export @observable class Store {}
+export const Route = createFileRoute('/')({ component: () =>
})
+`)
+ await expectValidModules(modules)
+ })
+
// Bug: the code splitter cannot parse a constructor parameter decorator
// (TypeScript `experimentalDecorators`, as dependency injection libraries
// use them) and fails the build. Source: the #8504 confidence campaign
@@ -2840,6 +3100,33 @@ describe('app paths', () => {
30_000,
)
+ // Bug: the code splitter compiles every module whose id contains
+ // `tsr-split` (path text, not its query) as a split chunk, and throws "The
+ // split value for the virtual route ... was not found" for a module of the
+ // app named like `tsr-split-label.ts`.
+ // Source: round-3 mutation testing (splitter-hosts P3).
+ // Impact: an app with such a file name cannot be built.
+ test.fails(
+ 'an app with a module whose path contains tsr-split builds',
+ async () => {
+ expect(
+ await buildAndRun(
+ `${head}import { label } from '../tsr-split-label'
+export const Route = createFileRoute('/')({
+ component: () => {label}
,
+})`,
+ script,
+ {
+ files: {
+ 'tsr-split-label.ts': `export const label = 'label'\n`,
+ },
+ },
+ ),
+ ).toBe('label
')
+ },
+ 30_000,
+ )
+
// Bug: the route module's chunk importers are built by pasting the
// absolute file path into a single-quoted string
// (`import('${splitUrl}')`), so a `'` in the path ends the string early.
From 754aa967197ad3adaef22f587b894ed963edd901 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 07:23:19 +0200
Subject: [PATCH 0295/1425] test(router-plugin): cover route HMR outcomes found
missing by mutation testing
Round-3 mutation testing (FIX-PLAN R3-router: ranked items 22-24, medium
and low-tier Route HMR proposals).
- route-hmr: every kept split component still renders its own component
after an update (not only `component`); unsplit routes drop a removed
component option and render an added one (Vite, webpack); Solid/Vue
unsplit routes render the new component; options built by a factory call
are hot-updated; root route tests take the generator's root route id.
New dev-server section (autoCodeSplitting off): `.ts` route files get
route HMR, `plugin.vite.environmentName` limits it to that environment,
`plugin.hmr.style` alone works, `tsr.config.json` in the Vite root sets
the target, function plugin options work, and served React/Solid/Vue
route modules map throws to their route-file line.
- known-bugs-route-hmr: controls for an inline-components React route edit
being a hot update with @vitejs/plugin-react, a lowercase function loader
shared with the split component, and an inline loader reading a renamed
lowercase component. New pin (verified red: the edit sends
`full-reload`): an edit of a React `.ts` route module with
autoCodeSplitting off is not a hot update (same root cause as the pinned
Vite HMR boundaries bug).
- handle-route-update: an update of a route the live router does not have
leaves the router as is (no throw); root route test takes the generated
root route id.
- react-refresh-route-components: a `$`-prefixed lowercase function
component is renamed and registered.
Mutants verified killed (through Nx): rp:1299 rp:1428 rp:1408 rp:1346
rp:1348 rp:1287 rp:1413 rp:1453 rp:1454 rp:1403 rp:1405 rp:1425 rp:1467
rp:1325 rp:1326 rp:1439 rp:1466 rp:1469 rp:1470 rp:1471 rp:1472 rp:1462
rp:1463 rp:1199 rp:1203 rp:1206 rp:1212 rp:1214.
Co-Authored-By: Claude Opus 5.5
---
.../tests/handle-route-update.test.ts | 41 ++-
.../tests/known-bugs-route-hmr.test.ts | 91 +++++-
.../react-refresh-route-components.test.ts | 7 +
.../router-plugin/tests/route-hmr.test.ts | 259 +++++++++++++++++-
4 files changed, 385 insertions(+), 13 deletions(-)
diff --git a/packages/router-plugin/tests/handle-route-update.test.ts b/packages/router-plugin/tests/handle-route-update.test.ts
index 80892587d8a..594e98cf311 100644
--- a/packages/router-plugin/tests/handle-route-update.test.ts
+++ b/packages/router-plugin/tests/handle-route-update.test.ts
@@ -26,6 +26,7 @@ import { Scripts } from '../../react-router/src/Scripts'
import { Router as ReactRouter } from '../../react-router/src/router'
import {
evaluateModule,
+ generatedRouteId,
routeFile,
transformWithRouteHmrPlugin,
} from './regression-helpers'
@@ -133,7 +134,8 @@ function withWindowRouter(router: RouterCore) {
/**
* Applies a hot update the way a dev server does: compiles the edited route
* file (`source`, its other imports given by `stubs`) with the route HMR
- * plugin for `routeId` (`/__root` for a root route) and evaluates it next to
+ * plugin for `routeId` (for a root route, the id the route generator
+ * registers its file under: `generatedRouteId`) and evaluates it next to
* the live `router`, with Vite's hot API, so that its HMR code updates the
* live route. Returns the route module's exports.
*/
@@ -287,6 +289,41 @@ describe('handleRouteUpdate', () => {
expect(router.getMatchedRoutes('/items/abc')[2]?.id).toBe(itemRoute.id)
})
+ // E.g. a page with several routers, or a route the live route tree does
+ // not include: the update leaves the router as it is.
+ it('ignores an update of a route the live router does not have', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const router = createTestRouter(rootRoute)
+ const code = transformWithRouteHmrPlugin(
+ routeSource(`{ loader: () => 'x' }`),
+ { target: 'react' },
+ { file: routeFile('hot'), routeId: '/hot' },
+ )
+ const accepted: Array<(module: unknown) => void> = []
+ const hot = {
+ data: {},
+ accept: (callback: (module: unknown) => void) => accepted.push(callback),
+ dispose() {},
+ }
+ const stubs = {
+ '@tanstack/react-router': {
+ createFileRoute: () => (options: any) => new BaseRoute(options),
+ },
+ }
+ const restoreWindow = withWindowRouter(router)
+ try {
+ await evaluateModule(code, stubs, { importMeta: { hot } })
+ const next = await evaluateModule(code, stubs, {
+ importMeta: { hot: { ...hot, accept() {} } },
+ })
+ expect(accepted).toHaveLength(1)
+ expect(() => accepted[0]!(next)).not.toThrow()
+ expect(Object.keys(router.routesById)).toEqual([rootRoute.id])
+ } finally {
+ restoreWindow()
+ }
+ })
+
it('hydrates the hot module route export with generated route tree state', async () => {
const rootRoute = new BaseRootRoute({})
const itemRoute = new BaseRoute({
@@ -402,7 +439,7 @@ export const Route = createFileRoute('/items/$itemId')({}).lazy(newImporter)
await hotUpdateRoute(
router,
- '/__root',
+ await generatedRouteId('__root.tsx'),
`import { createRootRoute } from '@tanstack/react-router'
import { newLoader } from './loader'
export const Route = createRootRoute({ loader: newLoader })
diff --git a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts
index b2305896266..b0f77c103fa 100644
--- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts
+++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts
@@ -441,6 +441,40 @@ export const Route = createFileRoute('/')({ component: page })`,
return Route as unknown as { options: Record unknown> }
}
+ // Control for the lowercase shared component pins above: a lowercase
+ // `function` loader that the split component also calls.
+ test('a lowercase function loader the split component calls loads from its chunk', async () => {
+ const route = await loadRoute(
+ `${head}function getData() {
+ return 'data'
+}
+export const Route = createFileRoute('/')({
+ loader: getData,
+ component: () => {getData()}
,
+ codeSplitGroupings: [['loader'], ['component']],
+})`,
+ { hmr: true },
+ )
+ await expectValidModules(route.modules)
+ expect(route.isLazy('loader')).toBe(true)
+ expect(await route.options.loader()).toBe('data')
+ expect((await route.load('component'))()).toBe('data
')
+ })
+
+ // Control for the hoisted inline component pin below: an inline option
+ // that is not a component, reading a lowercase component a later option
+ // passes.
+ test('an inline loader reading a lowercase component a later option passes runs it', async () => {
+ const Route =
+ await evaluateUnsplitRoute(`${head}const page = () => page
+export const Route = createFileRoute('/')({
+ loader: () => page(),
+ component: page,
+})`)
+ expect(Route.options.loader!()).toBe('page
')
+ expect(Route.options.component!()).toBe('page
')
+ })
+
// Control for the hoisted inline component pin below (same harness).
test('an inline component reading a lowercase component renders it', async () => {
const Route =
@@ -598,33 +632,41 @@ describe('Vite HMR boundaries', () => {
target: 'react' | 'solid' | 'vue',
autoCodeSplitting: boolean,
plugins: Array = [],
- ) {
- const route = (
- text: string,
- ) => `import { createFileRoute } from '@tanstack/${target}-router'
+ {
+ file = 'routes/index.tsx',
+ route = (
+ text,
+ ) => `import { createFileRoute } from '@tanstack/${target}-router'
export const Route = createFileRoute('/')({
loader: () => 'data',
component: Page,
})
function Page() {
return '${text}'
-}`
+}`,
+ }: { file?: string; route?: (text: string) => string } = {},
+ ) {
const server = await routerDevServer({
pluginOptions: { target, autoCodeSplitting },
plugins: [routerPackageStub(target), ...plugins],
files: {
- 'routes/index.tsx': route('page'),
+ [file]: route('page'),
'entry.ts': `export { routeTree } from './routeTree.gen'`,
},
})
// The router package stub is a virtual module (`/@id/...`).
await server.load('/entry.ts', { skip: /^\/@id\// })
- const messages = await server.edit({ 'routes/index.tsx': route('edited') })
+ const messages = await server.edit({ [file]: route('edited') })
return describeHmr(
messages.filter((message) => message.environment === 'client'),
)
}
+ /** Whether the HMR messages are a hot update without a full reload. */
+ const isHotUpdate = (messages: Array) =>
+ messages.some((message) => message.startsWith('client: update')) &&
+ !messages.some((message) => message.startsWith('client: full-reload'))
+
// Control for the pin below (same harness): with `@vitejs/plugin-react`,
// React Refresh makes a React route module an HMR boundary.
// Source: gap H6a.
@@ -642,6 +684,41 @@ function Page() {
30_000,
)
+ // Control (same harness): the route HMR plugin runs before React Refresh,
+ // so a route whose components are all inline arrows still becomes a
+ // boundary.
+ test('an edit of a React route with only inline components is a hot update with @vitejs/plugin-react (autoCodeSplitting: false)', async () => {
+ const messages = await editRoute('react', false, [react()], {
+ route: (text) => `import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({
+ loader: () => '${text}',
+ component: () => ${text}
,
+})`,
+ })
+ expect(isHotUpdate(messages)).toBe(true)
+ }, 30_000)
+
+ // Bug: same root cause as the pin below, for a React route file without
+ // JSX (`.ts`): React Refresh registers no component in it, so it is not a
+ // boundary either.
+ // Source: round-3 mutation testing (route HMR side finding).
+ // Impact: every edit of a React `.ts` route file reloads the page in Vite
+ // dev when automatic code splitting is off.
+ test.fails(
+ 'an edit of a React .ts route module is a hot update with @vitejs/plugin-react (autoCodeSplitting: false)',
+ async () => {
+ const messages = await editRoute('react', false, [react()], {
+ file: 'routes/index.ts',
+ route: (
+ text,
+ ) => `import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/')({ loader: () => '${text}' })`,
+ })
+ expect(isHotUpdate(messages)).toBe(true)
+ },
+ 30_000,
+ )
+
// Bug: the Vite route HMR code calls `accept` on a local alias
// (`const hot = import.meta.hot`), and Vite only marks a module
// self-accepting when its code contains a literal `import.meta.hot.accept(`.
diff --git a/packages/router-plugin/tests/react-refresh-route-components.test.ts b/packages/router-plugin/tests/react-refresh-route-components.test.ts
index 8e25ef436a4..e3c57a3d80b 100644
--- a/packages/router-plugin/tests/react-refresh-route-components.test.ts
+++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts
@@ -144,6 +144,13 @@ export const Route = createRootRouteWithContext()({
await expectRegisteredRouteOption(code, 'component')
})
+ it('declared as a lowercase function whose name starts with $, by renaming it', async () => {
+ const code = transformWithRouteHmrPlugin(
+ `${head}function $page() { return hi
}\nexport const Route = createFileRoute('/')({ component: $page })`,
+ )
+ await expectRegisteredRouteOption(code, 'component')
+ })
+
// Source: ReactFreshBabelPlugin-test "registers top-level exported function
// declarations" and "registers top-level exported named arrow functions"
it.each([
diff --git a/packages/router-plugin/tests/route-hmr.test.ts b/packages/router-plugin/tests/route-hmr.test.ts
index 659f4cab9dc..632e6a782ac 100644
--- a/packages/router-plugin/tests/route-hmr.test.ts
+++ b/packages/router-plugin/tests/route-hmr.test.ts
@@ -4,13 +4,20 @@
* them without a remount, and nothing of it ships when HMR is off.
*
* Hot updates run with `hotUpdate` (a real `RouterCore` and route classes);
- * compiled modules are evaluated with `loadRoute` / `evaluateModule`.
+ * compiled modules are evaluated with `loadRoute` / `evaluateModule`; what
+ * the route HMR plugin serves is read from a Vite dev server.
*/
-import { describe, expect, it, test, vi } from 'vitest'
+import { SourceMap } from 'node:module'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createDevServer } from '@tanstack/compiler-test-utils/dev-server'
+import { describe, expect, it, onTestFinished, test, vi } from 'vitest'
+import { rootRouteOf, routerDevServer, routerPackageStub } from './dev-server'
import {
compileRouteModules,
evaluateModule,
expectValidModules,
+ generatedRouteId,
head,
hotUpdate,
importSources,
@@ -18,6 +25,8 @@ import {
} from './regression-helpers'
import type { TargetFramework } from './regression-helpers'
+const harnessDir = path.dirname(fileURLToPath(import.meta.url))
+
/** A route file whose loader returns `data`, with its other options. */
const fileRoute = (
data: string,
@@ -90,6 +99,12 @@ const splitRoutes = [
},
]
+/** What each component option of `splitRoutes` renders. */
+const rendered: Record = {
+ component: 'posts
',
+ errorComponent: 'Error loading posts
',
+}
+
/** The value a loader of the live route returns, split or not. */
async function loaderData(update: Awaited>) {
return (await update.load('loader')).call(undefined)
@@ -114,9 +129,10 @@ describe('a hot update of a split route', () => {
for (const option of components) {
expect(update.isLazy(option), option).toBe(true)
expect(update.live[option], option).toBe(update.previous[option])
+ // Each kept component still renders its own component
+ expect((await update.load(option))(), option).toBe(rendered[option])
}
expect(await loaderData(update)).toBe('new')
- expect((await update.load('component'))()).toBe('posts
')
},
)
})
@@ -230,7 +246,7 @@ function RootComponent() {
style,
codeSplitting: true,
refresh: true,
- routeId: '/__root',
+ routeId: await generatedRouteId('__root.tsx'),
updated: root('new'),
})
expect(update.invalidated).toBe(false)
@@ -288,6 +304,71 @@ describe('a hot update of an unsplit route', () => {
expect((update.live.component as () => string)()).toBe('new
')
},
)
+
+ it.each(['vite', 'webpack'] as const)(
+ '%s: drops a component option the update removes',
+ async (style) => {
+ const update = await hotUpdate(
+ route('old', ['component', 'errorComponent']),
+ { style, refresh: true, updated: route('new', ['component']) },
+ )
+ expect(update.live.errorComponent).toBeUndefined()
+ expect(update.live.component).toBe(update.previous.component)
+ },
+ )
+
+ it.each(['vite', 'webpack'] as const)(
+ '%s: renders a component option the update adds',
+ async (style) => {
+ const update = await hotUpdate(route('old', ['component']), {
+ style,
+ refresh: true,
+ updated: route('new', ['component', 'errorComponent'], 'added'),
+ })
+ expect((update.live.errorComponent as () => string)()).toBe(
+ 'added
',
+ )
+ },
+ )
+
+ // Solid and Vue have no React Refresh to patch a kept component in place,
+ // so the update renders the new one.
+ test.each(['solid', 'vue'] as const)(
+ '%s: renders the new component',
+ async (framework) => {
+ const source = (text: string) =>
+ fileRoute(text, `component: () => ${text}
,`, '', framework)
+ const update = await hotUpdate(source('old'), {
+ targetFramework: framework,
+ updated: source('new'),
+ })
+ expect(update.invalidated).toBe(false)
+ expect((update.live.component as () => string)()).toBe('new
')
+ expect(await loaderData(update)).toBe('new')
+ expect(Object.keys(update.window)).toEqual(['__TSR_ROUTER__'])
+ },
+ )
+
+ it.each(['vite', 'webpack'] as const)(
+ '%s: applies route options a factory call builds',
+ async (style) => {
+ const source = (
+ data: string,
+ ) => `import { createFileRoute } from '@tanstack/react-router'
+import { makeOptions } from './options'
+export const Route = createFileRoute('/')(makeOptions('${data}'))`
+ const update = await hotUpdate(source('old'), {
+ style,
+ updated: source('new'),
+ stubs: {
+ './options': {
+ makeOptions: (data: string) => ({ loader: () => data }),
+ },
+ },
+ })
+ expect(await loaderData(update)).toBe('new')
+ },
+ )
})
describe('the route a hot update applies to', () => {
@@ -480,3 +561,173 @@ function RootComponent() {
)
})
})
+
+// The route HMR plugin that `tanstackRouter` adds in development when
+// automatic code splitting is off, in a Vite dev server.
+describe('route HMR in a Vite dev server without automatic code splitting', () => {
+ const timeout = 30_000
+ // Vite's SSR transform serves `import.meta` as `__vite_ssr_import_meta__`
+ const hasRouteHmr = (code: string) =>
+ /(?:import\.meta|__vite_ssr_import_meta__)\.hot\b/.test(code)
+ const loaderRoute = (target: TargetFramework = 'react') =>
+ `import { createFileRoute } from '@tanstack/${target}-router'
+export const Route = createFileRoute('/')({ loader: () => 'x' })`
+
+ test(
+ 'reaches route files without JSX (.ts)',
+ async () => {
+ const server = await routerDevServer({
+ pluginOptions: { autoCodeSplitting: false },
+ plugins: [routerPackageStub('react')],
+ files: { 'routes/index.ts': loaderRoute() },
+ })
+ expect(
+ hasRouteHmr(await server.transform('/routes/index.ts', 'client')),
+ ).toBe(true)
+ },
+ timeout,
+ )
+
+ test(
+ 'is limited to the environment plugin.vite.environmentName names',
+ async () => {
+ const server = await routerDevServer({
+ pluginOptions: {
+ autoCodeSplitting: false,
+ plugin: { vite: { environmentName: 'client' } },
+ },
+ plugins: [routerPackageStub('react')],
+ files: { 'routes/index.tsx': loaderRoute() },
+ })
+ expect(
+ hasRouteHmr(await server.transform('/routes/index.tsx', 'client')),
+ ).toBe(true)
+ expect(
+ hasRouteHmr(await server.transform('/routes/index.tsx', 'ssr')),
+ ).toBe(false)
+ },
+ timeout,
+ )
+
+ test(
+ 'works with plugin.hmr.style alone',
+ async () => {
+ const server = await routerDevServer({
+ pluginOptions: {
+ autoCodeSplitting: false,
+ plugin: { hmr: { style: 'vite' } },
+ },
+ plugins: [routerPackageStub('react')],
+ files: { 'routes/index.tsx': loaderRoute() },
+ })
+ expect(
+ hasRouteHmr(await server.transform('/routes/index.tsx', 'client')),
+ ).toBe(true)
+ },
+ timeout,
+ )
+
+ // Solid and Vue route modules only get the route HMR code; React ones are
+ // also compiled for React Refresh.
+ test(
+ 'compiles for the target of a tsr.config.json in the Vite root',
+ async () => {
+ const server = await routerDevServer({
+ pluginOptions: null,
+ plugins: [routerPackageStub('solid')],
+ files: {
+ 'tsr.config.json': JSON.stringify({
+ target: 'solid',
+ routesDirectory: './routes',
+ generatedRouteTree: './routeTree.gen.ts',
+ }),
+ 'routes/index.tsx': `import { createFileRoute } from '@tanstack/solid-router'
+export const Route = createFileRoute('/')({ loader: () => 'x', component: () => x
})`,
+ },
+ })
+ const routeModule = await server.import('/routes/index.tsx')
+ expect(Object.keys(routeModule)).toEqual(['Route'])
+ expect(await routeModule.Route.options.loader()).toBe('x')
+ },
+ timeout,
+ )
+
+ test(
+ 'takes plugin options given as a function',
+ async () => {
+ const { tanstackRouter } = await import('../src/vite')
+ const { getConfig } = await import('../src/core/config')
+ const server = await createDevServer({
+ dir: harnessDir,
+ prefix: '.router-dev-server-',
+ registerCleanup: onTestFinished,
+ files: {
+ 'routes/__root.tsx': rootRouteOf('solid'),
+ 'routes/index.tsx': loaderRoute('solid'),
+ },
+ plugins: (app) => [
+ tanstackRouter(() =>
+ getConfig(
+ {
+ target: 'solid',
+ autoCodeSplitting: false,
+ routesDirectory: path.join(app.root, 'routes'),
+ generatedRouteTree: path.join(app.root, 'routeTree.gen.ts'),
+ },
+ app.root,
+ ),
+ ),
+ routerPackageStub('solid'),
+ ],
+ })
+ expect(
+ hasRouteHmr(await server.transform('/routes/index.tsx', 'client')),
+ ).toBe(true)
+ const routeModule = await server.import('/routes/index.tsx')
+ expect(await routeModule.Route.options.loader()).toBe('x')
+ },
+ timeout,
+ )
+
+ // Stack traces and breakpoints of route files in development.
+ test.each(['react', 'solid', 'vue'] as const)(
+ 'maps the served %s route module to the lines of the route file',
+ async (target) => {
+ const route = `import { createFileRoute } from '@tanstack/${target}-router'
+
+
+// comment
+
+export const Route = createFileRoute('/')({
+ loader: () => {
+ const value = [1, 2, 3].map((n) => n * 2).filter((n) => n > 2)
+
+
+ throw new Error('MARK_THROW ' + value.length)
+ },
+})
+`
+ const server = await routerDevServer({
+ pluginOptions: { target, autoCodeSplitting: false },
+ plugins: [routerPackageStub(target)],
+ files: { 'routes/index.tsx': route },
+ })
+ const result =
+ await server.vite.environments.client.transformRequest(
+ '/routes/index.tsx',
+ )
+ const code = result!.code
+ const before = code.slice(0, code.indexOf('throw new Error'))
+ const line = before.split('\n').length - 1
+ const column = before.length - before.lastIndexOf('\n') - 1
+ const entry = new SourceMap(result!.map as any).findEntry(
+ line,
+ column,
+ ) as { originalLine: number }
+ expect(entry.originalLine + 1).toBe(
+ route.slice(0, route.indexOf('throw new Error')).split('\n').length,
+ )
+ },
+ timeout,
+ )
+})
From d9dad4804b914b25d6f62b1102901ef1f11935d8 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 07:23:26 +0200
Subject: [PATCH 0296/1425] test(router-plugin): cover the webpack and Rspack
generator watch mode
The webpack branch of the route generator host had no coverage, and nothing
checked that closing the compiler's watcher stops the generator's own
chokidar watcher (route-tree hosts, round-3 mutation testing item G).
In `compiler.watch` mode, per bundler: a created empty route file is
scaffolded and added to the route tree; after `watching.close()`, a route
file created later is left alone. The negative check needs a fixed wait
(the generator's watcher closes asynchronously and nothing signals it);
the test waits for the compiles to settle before closing, and cleans up
only after the watcher closed. Passed on base in every run (5 runs).
Mutants verified killed (through Nx): rp:1903 rp:1905 rp:1906 rp:1912
rp:1913 rp:1914 rp:1918 rp:1920 rp:1923 rp:1925 rp:1926; rp:1919 killed in
1 of 2 runs (webpack sometimes reports the created file through its own
watcher too).
Co-Authored-By: Claude Opus 5.5
---
.../rsbuild-dev-server-generator.test.ts | 118 +++++++++++++++++-
1 file changed, 115 insertions(+), 3 deletions(-)
diff --git a/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts b/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts
index 03ce15f110e..005f96db99c 100644
--- a/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts
+++ b/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts
@@ -5,16 +5,20 @@
* the rest of the file untouched), the route tree in line with the route
* files and the emitted client chunks in line with the route tree, without
* compile errors; a new route's component is emitted in a chunk of its own.
- * The Rsbuild counterpart of dev-server-generator.test.ts.
+ * The Rsbuild counterpart of dev-server-generator.test.ts. Also the
+ * generator entries of webpack and Rspack in their compilers' watch mode.
*/
-import { stat } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
-import { afterEach, describe, expect, test, vi } from 'vitest'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest'
import { routerRsbuildDevServer } from './rsbuild-dev-server'
import type { RouterRsbuildDevServer } from './rsbuild-dev-server'
const timeout = 60_000
+const harnessDir = path.dirname(fileURLToPath(import.meta.url))
+
/** A route file whose component renders `text`. */
const route = (path: string, text: string) =>
`import { createFileRoute } from '@tanstack/react-router'
@@ -287,3 +291,111 @@ describe('route generator failures in Rsbuild dev', () => {
timeout,
)
})
+
+// The generator entries of webpack and Rspack in the compiler's own watch
+// mode (`compiler.watch`, as webpack-dev-server and `--watch` use it).
+describe('route generator in webpack and Rspack watch mode', () => {
+ /** Waits for `check` to hold, polling. */
+ async function until(check: () => Promise | boolean, what: string) {
+ const deadline = Date.now() + 15_000
+ while (!(await check())) {
+ if (Date.now() > deadline) {
+ throw new Error(`timed out waiting for ${what}`)
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+ }
+
+ test.each(['webpack', 'rspack'] as const)(
+ '%s: a created route file is scaffolded and added to the route tree, and closing the watcher stops the generator',
+ async (bundler) => {
+ const root = await mkdtemp(path.join(harnessDir, '.generator-watch-'))
+ let close: () => Promise = () => Promise.resolve()
+ onTestFinished(async () => {
+ await close()
+ await rm(root, { recursive: true, force: true })
+ })
+ const file = (relative: string) => path.join(root, relative)
+ const read = (relative: string) => readFile(file(relative), 'utf8')
+ await mkdir(file('routes'), { recursive: true })
+ await writeFile(
+ file('routes/__root.js'),
+ `import { createRootRoute } from '@tanstack/react-router'\nexport const Route = createRootRoute({})\n`,
+ )
+ await writeFile(
+ file('main.js'),
+ `import { routeTree } from './routeTree.gen.js'\nconsole.log(routeTree)\n`,
+ )
+ const pluginOptions = {
+ target: 'react' as const,
+ routesDirectory: file('routes'),
+ generatedRouteTree: file('routeTree.gen.js'),
+ disableTypes: true,
+ disableLogging: true,
+ }
+ const config = {
+ context: root,
+ mode: 'development',
+ entry: './main.js',
+ output: { path: file('dist') },
+ externals: {
+ '@tanstack/react-router': 'commonjs @tanstack/react-router',
+ },
+ infrastructureLogging: { level: 'none' },
+ stats: 'none',
+ }
+ let compiler: any
+ if (bundler === 'webpack') {
+ const { default: webpack } = await import('webpack')
+ const { TanStackRouterGeneratorWebpack } =
+ await import('../src/webpack')
+ compiler = webpack({
+ ...config,
+ plugins: [TanStackRouterGeneratorWebpack(pluginOptions)],
+ } as any)
+ } else {
+ const { rspack } = await import('@rsbuild/core')
+ const { TanStackRouterGeneratorRspack } = await import('../src/rspack')
+ compiler = rspack({
+ ...config,
+ plugins: [TanStackRouterGeneratorRspack(pluginOptions)],
+ } as any)
+ }
+ let builds = 0
+ const watching = compiler.watch({ aggregateTimeout: 50 }, () => {
+ builds++
+ })
+ close = () => new Promise((resolve) => watching.close(resolve))
+ await until(() => builds > 0, 'the first build')
+
+ await writeFile(file('routes/posts.js'), '')
+ await until(
+ async () =>
+ (await read('routes/posts.js')).includes("createFileRoute('/posts')"),
+ 'the created route file to be scaffolded',
+ )
+ await until(
+ async () => (await read('routeTree.gen.js')).includes("path: '/posts'"),
+ 'the route tree to have the created route',
+ )
+
+ // Let the compiles the scaffolding caused finish, then close.
+ let seen = -1
+ await until(async () => {
+ const quiet = seen === builds
+ seen = builds
+ await new Promise((resolve) => setTimeout(resolve, 700))
+ return quiet && seen === builds
+ }, 'the compiles to settle')
+ await close()
+ close = () => Promise.resolve()
+ // The generator's own watcher closes asynchronously after the
+ // compiler's; nothing signals it.
+ await new Promise((resolve) => setTimeout(resolve, 300))
+ await writeFile(file('routes/after.js'), '')
+ await new Promise((resolve) => setTimeout(resolve, 1500))
+ expect(await read('routes/after.js')).toBe('')
+ },
+ 30_000,
+ )
+})
From 835b7b72a33b21caff09b22e3077f4f5fa51729c Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 14:47:38 +0200
Subject: [PATCH 0297/1425] test(compiler-test-utils): child runs never outlive
the test worker; lost build errors fail fast
Harness hygiene (mutation run r3b follow-up 1): a built entry run by the
build harness (`run()`, e.g. the server of an Rsbuild Start build) ran in a
`node --eval` child with no time limit and nothing tying it to the test
worker. When a vitest worker was killed on a timeout, a child stuck in a
loop was reparented to init and spun at 100% CPU for hours.
- New `run-node.ts` (`runNodeProgram`): runs the program with a time limit
(default 30 s, SIGKILL, clear error) and a watchdog thread (`--import`)
that SIGKILLs the child once its parent process is gone, which works even
while the program's thread spins in a synchronous loop. Used by the build
harness's `run()` and router-plugin's code-splitter runtime test.
- `withBuildEnvironment`: an error no one handles while a build runs fails
the build (a throw in a sync Rspack `compile` hook leaves `rsbuild.build()`
pending forever).
- Rsbuild dev harness: a compile Rspack gives up on (`compiler.hooks.failed`,
e.g. a throw in `processAssets`) rejects the compile wait with its error at
once instead of after `compileTimeout`.
Verified: SIGKILLing the vitest worker while an Rsbuild Start build's
server spins leaves no process (with the watchdog disabled, the child is
reparented to systemd and keeps spinning). Control tests in run-node.test.ts
and rsbuild-dev-server.test.ts.
Fail fast instead of a time limit: spc:6793 (Rsbuild builds failed after the
60 s test timeout, now at once on an assertion), spc:7471 (Rsbuild dev
compile wait of 20 s, now the real TypeError at once).
Co-Authored-By: Claude Opus 5.5
---
packages/compiler-test-utils/package.json | 1 +
.../compiler-test-utils/src/build-harness.ts | 41 ++++---
.../src/rsbuild-dev-server.ts | 41 ++++++-
packages/compiler-test-utils/src/run-node.ts | 94 ++++++++++++++++
.../tests/rsbuild-dev-server.test.ts | 28 +++++
.../tests/run-node.test.ts | 106 ++++++++++++++++++
.../tests/code-splitter-runtime.test.ts | 13 +--
7 files changed, 301 insertions(+), 23 deletions(-)
create mode 100644 packages/compiler-test-utils/src/run-node.ts
create mode 100644 packages/compiler-test-utils/tests/run-node.test.ts
diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json
index dc992dae6b6..9aeae672113 100644
--- a/packages/compiler-test-utils/package.json
+++ b/packages/compiler-test-utils/package.json
@@ -9,6 +9,7 @@
"./dev-server": "./src/dev-server.ts",
"./module-linker": "./src/module-linker.ts",
"./rsbuild-dev-server": "./src/rsbuild-dev-server.ts",
+ "./run-node": "./src/run-node.ts",
"./validate-module": "./src/validate-module.ts",
"./package.json": "./package.json"
},
diff --git a/packages/compiler-test-utils/src/build-harness.ts b/packages/compiler-test-utils/src/build-harness.ts
index a78410e560f..3c088aa5aa9 100644
--- a/packages/compiler-test-utils/src/build-harness.ts
+++ b/packages/compiler-test-utils/src/build-harness.ts
@@ -9,7 +9,6 @@
* Plugin-agnostic: the caller supplies the plugin of each build, and the
* directory its apps live in (where their imports resolve).
*/
-import { execFile } from 'node:child_process'
import {
mkdir,
mkdtemp,
@@ -21,7 +20,8 @@ import {
import { SourceMap as NodeSourceMap, createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
-import { format, promisify } from 'node:util'
+import { format } from 'node:util'
+import { runNodeProgram } from './run-node'
export type Bundler = 'vite' | 'rspack' | 'webpack' | 'esbuild'
@@ -226,7 +226,6 @@ export type BundlerPlugins = Partial unknown>>
const harnessDir = path.dirname(fileURLToPath(import.meta.url))
const oxcLoader = path.join(harnessDir, 'build-harness-oxc-loader.mjs')
const require = createRequire(import.meta.url)
-const runNode = promisify(execFile)
const defaultPackageJson = { name: 'app', private: true, type: 'module' }
@@ -972,15 +971,11 @@ process.stdout.write(JSON.stringify({ result, logs${client ? ', html: document.b
process.exit(0)
`
try {
- const { stdout } = await runNode(
- process.execPath,
- ['--input-type=module', '--eval', program],
- {
- cwd: path.dirname(input.entryFile),
- env: { ...process.env, NODE_ENV: input.mode },
- maxBuffer: 64 * 1024 * 1024,
- },
- )
+ // In a child process that never outlives the test worker (run-node.ts)
+ const { stdout } = await runNodeProgram(program, {
+ cwd: path.dirname(input.entryFile),
+ env: { ...process.env, NODE_ENV: input.mode },
+ })
return JSON.parse(stdout) as RunResult
} catch (error) {
const failure = error as { stderr?: string }
@@ -1010,7 +1005,10 @@ function swcOptions(
* Runs a build the way a build command does (`NODE_ENV` set to the mode by
* default, which decides e.g. the JSX runtime and the `development` export
* condition; `null` unsets it) and records `console.warn`/`console.error`
- * output.
+ * output. An error no one handles while the build runs fails the build: a
+ * bundler that loses a plugin's error (e.g. a throw in a hook Rspack calls
+ * without catching it) would otherwise never finish the build, and the test
+ * would wait for its timeout.
*/
export async function withBuildEnvironment(
buildNodeEnv: string | null,
@@ -1032,9 +1030,24 @@ export async function withBuildEnvironment(
messages.push(format(...args))
original.error.apply(console, args)
}
+ let onUnhandled!: (error: unknown) => void
+ const unhandled = new Promise((_, reject) => {
+ onUnhandled = (error) => {
+ reject(
+ new Error(
+ `the build failed with an error no one handled: ${errorMessage(error)}`,
+ { cause: error },
+ ),
+ )
+ }
+ })
+ process.on('unhandledRejection', onUnhandled)
+ process.on('uncaughtException', onUnhandled)
try {
- return await run()
+ return await Promise.race([run(), unhandled])
} finally {
+ process.off('unhandledRejection', onUnhandled)
+ process.off('uncaughtException', onUnhandled)
console.warn = original.warn
console.error = original.error
if (nodeEnv === undefined) {
diff --git a/packages/compiler-test-utils/src/rsbuild-dev-server.ts b/packages/compiler-test-utils/src/rsbuild-dev-server.ts
index 72a8407d664..2402d1d33e4 100644
--- a/packages/compiler-test-utils/src/rsbuild-dev-server.ts
+++ b/packages/compiler-test-utils/src/rsbuild-dev-server.ts
@@ -80,9 +80,18 @@ export async function createRsbuildDevServer(
const rebuilt: Array = []
const errors: Array = []
const warnings: Array = []
- const waiters: Array<{ after: number; resolve: () => void }> = []
+ const waiters: Array<{
+ after: number
+ resolve: () => void
+ reject: (error: Error) => void
+ }> = []
/** Compiles start and finish in order: `compiles` counts the started ones. */
let finished = 0
+ /**
+ * The error of the last compile Rspack gave up on (a plugin hook threw or
+ * rejected), which never finishes: waits for it fail with this error.
+ */
+ let failure: { after: number; error: Error } | undefined
/** Output directory of each environment. */
const distPaths: Record = {}
const harnessPlugin: RsbuildPlugin = {
@@ -91,6 +100,28 @@ export async function createRsbuildDevServer(
api.onBeforeDevCompile(() => {
compiles++
})
+ api.onAfterCreateCompiler(({ compiler }) => {
+ const compilers =
+ 'compilers' in compiler ? compiler.compilers : [compiler]
+ for (const environmentCompiler of compilers) {
+ environmentCompiler.hooks.failed.tap(
+ 'rsbuild-dev-server-harness',
+ (cause) => {
+ const error = new Error(
+ `Rsbuild failed to compile the app: ${cause.message}`,
+ { cause },
+ )
+ failure = { after: compiles - 1, error }
+ for (const waiter of [...waiters]) {
+ if (compiles > waiter.after) {
+ waiters.splice(waiters.indexOf(waiter), 1)
+ waiter.reject(error)
+ }
+ }
+ },
+ )
+ }
+ })
api.onDevCompileDone(({ stats, environments }) => {
finished = compiles
for (const [name, environment] of Object.entries(environments)) {
@@ -132,6 +163,10 @@ export async function createRsbuildDevServer(
resolve()
return
}
+ if (failure && failure.after >= after) {
+ reject(failure.error)
+ return
+ }
const timer = setTimeout(() => {
waiters.splice(waiters.indexOf(waiter), 1)
reject(
@@ -146,6 +181,10 @@ export async function createRsbuildDevServer(
clearTimeout(timer)
resolve()
},
+ reject: (error: Error) => {
+ clearTimeout(timer)
+ reject(error)
+ },
}
waiters.push(waiter)
})
diff --git a/packages/compiler-test-utils/src/run-node.ts b/packages/compiler-test-utils/src/run-node.ts
new file mode 100644
index 00000000000..5bb953a412f
--- /dev/null
+++ b/packages/compiler-test-utils/src/run-node.ts
@@ -0,0 +1,94 @@
+/**
+ * Runs an ES module program in a child Node process (`node --eval`), for
+ * tests that execute built output outside the test worker. The child never
+ * outlives the test worker:
+ * - it is killed when it runs longer than `timeout` (the run then fails with
+ * a clear error instead of hanging the test until its timeout);
+ * - a watchdog thread inside the child kills it as soon as the process that
+ * started it is gone (e.g. a vitest worker killed on a timeout), even
+ * while the program's own thread is stuck in a synchronous loop.
+ */
+import { execFile } from 'node:child_process'
+
+export interface RunNodeOptions {
+ /** Node options placed before the program (e.g. `--enable-source-maps`). */
+ nodeArgs?: Array
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ /** How long the program may run, in milliseconds. Default 30 000. */
+ timeout?: number
+ /** Largest stdout/stderr accepted, in bytes. Default 64 MiB. */
+ maxBuffer?: number
+}
+
+export interface RunNodeResult {
+ stdout: string
+ stderr: string
+}
+
+/** The error a failed run rejects with. */
+export interface RunNodeError extends Error {
+ stdout: string
+ stderr: string
+}
+
+/**
+ * A module run before the program (`--import`): a thread that kills the
+ * process once its parent process (`parentPid`) is gone.
+ */
+function watchdog(parentPid: number) {
+ const thread = `const parent = ${parentPid}
+setInterval(() => {
+ if (process.ppid !== parent) {
+ process.kill(process.pid, 'SIGKILL')
+ }
+}, 250)`
+ const module = `import { Worker } from 'node:worker_threads'
+new Worker(${JSON.stringify(thread)}, { eval: true }).unref()
+`
+ return `data:text/javascript,${encodeURIComponent(module)}`
+}
+
+/** Runs `program` (an ES module) with Node; resolves with its output. */
+export function runNodeProgram(
+ program: string,
+ options: RunNodeOptions = {},
+): Promise {
+ const timeout = options.timeout ?? 30_000
+ return new Promise((resolve, reject) => {
+ execFile(
+ process.execPath,
+ [
+ ...(options.nodeArgs ?? []),
+ '--import',
+ watchdog(process.pid),
+ '--input-type=module',
+ '--eval',
+ program,
+ ],
+ {
+ cwd: options.cwd,
+ env: options.env,
+ maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
+ timeout,
+ killSignal: 'SIGKILL',
+ },
+ (error, stdout, stderr) => {
+ if (!error) {
+ resolve({ stdout, stderr })
+ return
+ }
+ const timedOut = error.killed && error.signal === 'SIGKILL'
+ const failure = new Error(
+ timedOut
+ ? `the program did not finish within ${timeout} ms (killed)`
+ : error.message,
+ { cause: error },
+ ) as RunNodeError
+ failure.stdout = stdout
+ failure.stderr = stderr
+ reject(failure)
+ },
+ )
+ })
+}
diff --git a/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts b/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts
index b34b7e600e6..e7c99ac15c4 100644
--- a/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts
+++ b/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts
@@ -28,3 +28,31 @@ test('rebuilds the edited module and emits the edit', async () => {
expect(await joined()).toContain('MARK_V2')
expect(await joined()).not.toContain('MARK_V1')
}, 30_000)
+
+test('a compile a plugin makes fail rejects the wait with its error, without waiting for the timeout', async () => {
+ let compile = 0
+ const server = await createRsbuildDevServer({
+ dir: testsDir,
+ registerCleanup: onTestFinished,
+ compileTimeout: 20_000,
+ files: { 'src/entry.ts': `console.log('MARK_V1')\n` },
+ plugins: [
+ {
+ name: 'fails-the-second-compile',
+ setup(api) {
+ api.processAssets({ stage: 'summarize' }, () => {
+ compile++
+ if (compile === 2) {
+ throw new Error('MARK_PLUGIN_FAILURE')
+ }
+ })
+ },
+ },
+ ],
+ })
+ const started = Date.now()
+ await expect(
+ server.edit({ 'src/entry.ts': `console.log('MARK_V2')\n` }),
+ ).rejects.toThrow(/Rsbuild failed to compile[\s\S]*MARK_PLUGIN_FAILURE/)
+ expect(Date.now() - started).toBeLessThan(10_000)
+}, 30_000)
diff --git a/packages/compiler-test-utils/tests/run-node.test.ts b/packages/compiler-test-utils/tests/run-node.test.ts
new file mode 100644
index 00000000000..4701e98014b
--- /dev/null
+++ b/packages/compiler-test-utils/tests/run-node.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Control tests of run-node.ts: a program run in a child Node process gives
+ * its output, fails fast past its timeout, and never outlives the process
+ * that started it (a test worker killed on a timeout must not leave it
+ * spinning).
+ */
+import { spawn } from 'node:child_process'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { expect, onTestFinished, test } from 'vitest'
+import { runNodeProgram } from '../src/run-node'
+
+const runNodeModule = pathToFileURL(
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '../src/run-node.ts'),
+).href
+
+/** Whether a process is running. */
+function isRunning(pid: number) {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/** Polls `read` every 50 ms until it returns a value, for `within` ms. */
+async function poll(
+ read: () => Promise,
+ what: string,
+ within = 10_000,
+): Promise {
+ const deadline = Date.now() + within
+ for (;;) {
+ const value = await read()
+ if (value !== undefined) {
+ return value
+ }
+ if (Date.now() > deadline) {
+ throw new Error(`${what} within ${within} ms`)
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+}
+
+test('gives the output of the program', async () => {
+ const { stdout } = await runNodeProgram(`process.stdout.write(String(6 * 7))`)
+ expect(stdout).toBe('42')
+})
+
+test('a program that runs past its timeout is killed, and the run says so', async () => {
+ const started = Date.now()
+ await expect(
+ runNodeProgram(`while (true) {}`, { timeout: 500 }),
+ ).rejects.toThrow('the program did not finish within 500 ms')
+ expect(Date.now() - started).toBeLessThan(10_000)
+}, 20_000)
+
+test('a program stuck in a synchronous loop dies with the process that started it', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'run-node-'))
+ const pidFile = path.join(dir, 'pid')
+ const program = `import { writeFileSync } from 'node:fs'
+writeFileSync(${JSON.stringify(pidFile)}, String(process.pid))
+while (true) {}
+`
+ // The process that starts the program, killed like a test worker on a
+ // timeout (SIGKILL: no exit handler runs)
+ const starter = spawn(
+ process.execPath,
+ [
+ '--input-type=module',
+ '--eval',
+ `import { runNodeProgram } from ${JSON.stringify(runNodeModule)}
+runNodeProgram(${JSON.stringify(program)}, { timeout: 120_000 }).catch(() => {})
+`,
+ ],
+ { stdio: 'ignore' },
+ )
+ let pid: number | undefined
+ onTestFinished(async () => {
+ starter.kill('SIGKILL')
+ if (pid !== undefined && isRunning(pid)) {
+ process.kill(pid, 'SIGKILL')
+ }
+ await rm(dir, { recursive: true, force: true })
+ })
+ pid = await poll(
+ () =>
+ readFile(pidFile, 'utf8').then(
+ (text) => (text ? Number(text) : undefined),
+ () => undefined,
+ ),
+ 'the program did not start',
+ )
+ expect(isRunning(pid)).toBe(true)
+
+ starter.kill('SIGKILL')
+
+ await poll(
+ async () => (isRunning(pid!) ? undefined : true),
+ 'the program still runs after the process that started it was killed',
+ )
+ expect(isRunning(pid)).toBe(false)
+}, 30_000)
diff --git a/packages/router-plugin/tests/code-splitter-runtime.test.ts b/packages/router-plugin/tests/code-splitter-runtime.test.ts
index 443057d8f09..4eeb954a3c9 100644
--- a/packages/router-plugin/tests/code-splitter-runtime.test.ts
+++ b/packages/router-plugin/tests/code-splitter-runtime.test.ts
@@ -1,14 +1,12 @@
-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 { runNodeProgram } from '@tanstack/compiler-test-utils/run-node'
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, 'test-files/shared-runtime.tsx')
const groupings: Array<{ name: string; groupings: CodeSplitGroupings }> = [
@@ -111,10 +109,8 @@ export function render() {
// When `createComponentLabel()` runs (with the route module or with the
// component's chunk) is free; that every initialiser runs exactly once,
// and the ones the route module reads before it is used, is not.
- const { stdout } = await runNode(process.execPath, [
- '--enable-source-maps',
- '--input-type=module',
- '--eval',
+ // In a child process that never outlives the test worker
+ const { stdout } = await runNodeProgram(
`import assert from 'node:assert/strict'
const messages = []
console.log = (message) => messages.push(message)
@@ -140,7 +136,8 @@ assert.throws(render, (error) => {
return true
})
process.stdout.write('route contracts passed')`,
- ])
+ { nodeArgs: ['--enable-source-maps'] },
+ )
expect(stdout).toBe('route contracts passed')
} finally {
await rm(root, { recursive: true, force: true })
From e0e3e0945b6117b70135fff31d95d0cdbc90b67e Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 14:47:43 +0200
Subject: [PATCH 0298/1425] test(router-plugin): webpack/Rspack watch test
asserts the scaffolded route instead of timing out
The watch-mode test (d9dad4804b) waited for the created route file to be
scaffolded and for the route tree to list it with a helper that throws
"timed out waiting for ..." after 15 s, so a missing scaffold only showed as
a time limit. It now polls for at most 10 s and then asserts the file
contents, so the test fails with what was read. Same assertions.
Kills (now on an AssertionError, about 10 s): rp:1912, rp:1913, rp:1918,
rp:1919, rp:1920.
Co-Authored-By: Claude Opus 5.5
---
.../rsbuild-dev-server-generator.test.ts | 33 ++++++++++++++-----
1 file changed, 24 insertions(+), 9 deletions(-)
diff --git a/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts b/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts
index 005f96db99c..6071b391efb 100644
--- a/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts
+++ b/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts
@@ -306,6 +306,21 @@ describe('route generator in webpack and Rspack watch mode', () => {
}
}
+ /**
+ * Polls `read` until its value contains `expected`, for at most 10 s, and
+ * returns the last value: the caller asserts on it, so that a missing
+ * outcome fails the test with what was read instead of a timeout.
+ */
+ async function readUntil(read: () => Promise, expected: string) {
+ const deadline = Date.now() + 10_000
+ let value = await read()
+ while (!value.includes(expected) && Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ value = await read()
+ }
+ return value
+ }
+
test.each(['webpack', 'rspack'] as const)(
'%s: a created route file is scaffolded and added to the route tree, and closing the watcher stops the generator',
async (bundler) => {
@@ -369,15 +384,15 @@ describe('route generator in webpack and Rspack watch mode', () => {
await until(() => builds > 0, 'the first build')
await writeFile(file('routes/posts.js'), '')
- await until(
- async () =>
- (await read('routes/posts.js')).includes("createFileRoute('/posts')"),
- 'the created route file to be scaffolded',
- )
- await until(
- async () => (await read('routeTree.gen.js')).includes("path: '/posts'"),
- 'the route tree to have the created route',
- )
+ expect(
+ await readUntil(
+ () => read('routes/posts.js'),
+ "createFileRoute('/posts')",
+ ),
+ ).toContain("createFileRoute('/posts')")
+ expect(
+ await readUntil(() => read('routeTree.gen.js'), "path: '/posts'"),
+ ).toContain("path: '/posts'")
// Let the compiles the scaffolding caused finish, then close.
let seen = -1
From 10e55299a0d9f432eec9849fafa573425d44a317 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Sat, 17 Oct 2026 14:47:49 +0200
Subject: [PATCH 0299/1425] test(start-plugin-core): tell dev import
protection's log 'always' from 'once' without an edit
Regressions of mutation run r3b: the only test that told `log: 'always'`
from `'once'` became a `.fails` pin in 20c891a337 (an edit drops the import
graph edges, a main bug). The new tests reach the same violation again
without an edit: Vite reloads every module when `tsconfig.json` changes
(as when it re-optimizes dependencies), without changing any module, and
the client loads the app again. With 'once' nothing is logged again; with
'always' the violation is logged again, at its use. The edit pin stays.
`startProtectedDevServer` takes extra plugins and Vite config (additive).
Kills: spc:4462, spc:4463, spc:4464, spc:4465, spc:4469, spc:4856
Co-Authored-By: Claude Opus 5.5