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
    {posts.length}
+ }`, + }, + { + 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 + '' +} +` + 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: '
  • A
  • B
', + }, + { + 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 + '' +} +` + 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 +}`) + 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 + '' +} +` + 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 + '' +} +` + +/** + * 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('localmodule:importedlocal-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 }) => + ``, + }, + './format': { + format: (value: string) => `[${value}]`, + default: 'dark', + }, + }, + ), + ).toBe('') + }) + + // 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('')}

    +} +`, + ) + expect(await getModuleErrors(chunks[0]!)).toEqual([]) + expect(await renderChunk(chunks[0]!)).toBe('

    ab

    ') + }) + + // 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( + '
    • 0
    • 1
    ', + ) + 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 <> +} +`, + }, + { + 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 +} +`, + }, + ])( + '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 + '' +} +` + +/** + * 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
      {children}
    } +Object.assign(Tabs, { Tab: () =>
  • tab
  • })`, + children: '', + rendered: '
    • tab
    ', + }, + { + 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( + '
    • >a
    • >b
    ', + ) + }, + ) + + // 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 + + +` + +/** 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

    solid

    + return

    ${framework}

    }`, - { framework: 'solid', chunksFromSource: true }, - ) - expect(module.Page()).toBe('[
    [

    solid

    ]
    ]') - expect(lazyChunks.map((chunk) => chunk.rendered)).toEqual([true, true]) - }) + { framework, chunksFromSource: true }, + ) + expect(module.Page()).toBe(`[
    [

    ${framework}

    ]
    ]`) + 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( - '[homeitem]', - ) + 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 --- .../tests/importProtection/dev.test.ts | 54 +++++++++++++++++++ .../tests/importProtection/start-apps.ts | 20 +++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index 90238c1bbc2..8423f7ad04e 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -219,6 +219,60 @@ export const other = 1 timeout, ) + /** + * The violations logged when the client loads the app, and those logged + * when it loads it again after the dev server reloaded every module + * without any module changing: Vite does so when `tsconfig.json` changes + * (as when it re-optimizes dependencies), and the client reloads the page. + */ + async function logsAroundAReload(log: 'once' | 'always') { + const server = await startProtectedDevServer( + { ...deniedFileApp, 'tsconfig.json': `{}\n` }, + { log }, + ) + await server.load('/src/entry.ts') + const first = reportedViolations(server.logs) + const reload = await server.edit({ + 'tsconfig.json': `{ "compilerOptions": {} }\n`, + }) + expect( + reload.filter( + (message) => + message.environment === 'client' && message.type === 'full-reload', + ), + ).not.toEqual([]) + await server.load('/src/entry.ts') + return { + first, + afterReload: reportedViolations(server.logs).slice(first.length), + } + } + + // Source: mutation run R3c (`log` regressions spc:4462-4465, 4469, 4856: + // the edit-based test above is a pin). + test( + "'once' does not log a violation again when the dev server reloads every module", + async () => { + const { first, afterReload } = await logsAroundAReload('once') + expect(first).not.toEqual([]) + expect(afterReload).toEqual([]) + }, + timeout, + ) + + // Source: mutation run R3c, as above. + test( + "'always' logs a violation again when the dev server reloads every module", + async () => { + const { first, afterReload } = await logsAroundAReload('always') + expect(first).not.toEqual([]) + expect(distinct(afterReload.map((v) => v.importer))).toEqual([ + deniedFileUse, + ]) + }, + timeout, + ) + // Source: mutation run R3 ip-vite V15. test( "'always' does not log a violation again when the client loads other modules", diff --git a/packages/start-plugin-core/tests/importProtection/start-apps.ts b/packages/start-plugin-core/tests/importProtection/start-apps.ts index 9015d5de299..9fb37937f24 100644 --- a/packages/start-plugin-core/tests/importProtection/start-apps.ts +++ b/packages/start-plugin-core/tests/importProtection/start-apps.ts @@ -7,11 +7,13 @@ * text it prints. */ import path from 'node:path' +import { mergeConfig } from 'vite' import { importProtectionPlugin } from '../../src/vite/import-protection-plugin/plugin' import { startDevServer } from '../dev-server' 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' +import type { InlineConfig, PluginOption } from 'vite' export interface ProtectedDevServerOptions { /** @@ -27,6 +29,10 @@ export interface ProtectedDevServerOptions { * a third, server environment of the dev server. */ providerEnvironment?: string + /** Other plugins of the app, after import protection. */ + plugins?: Array + /** Vite configuration of the app (e.g. `server.warmup`). */ + config?: InlineConfig } /** @@ -40,13 +46,20 @@ export function startProtectedDevServer( importProtection: ImportProtectionOptions = {}, options: ProtectedDevServerOptions = {}, ): Promise { - const { router = 'src/entry.ts', providerEnvironment = 'ssr' } = options + const { + router = 'src/entry.ts', + providerEnvironment = 'ssr', + plugins = [], + config = {}, + } = options return startDevServer({ files, config: providerEnvironment === 'ssr' - ? undefined - : { environments: { [providerEnvironment]: { consumer: 'server' } } }, + ? config + : mergeConfig(config, { + environments: { [providerEnvironment]: { consumer: 'server' } }, + }), plugins: ({ root }) => [ importProtectionPlugin({ getConfig: (() => ({ @@ -70,6 +83,7 @@ export function startProtectedDevServer( ], providerEnvName: providerEnvironment, }), + ...plugins, ], }) } From 565e59a08dcc345cc6858aa2459f98b4602e779a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 14:53:30 +0200 Subject: [PATCH 0300/1425] test(start-plugin-core): dev import protection when importer edges lack transform data Mutation run r3b: the Vite dev 'unknown'-reachability fallback (vite/import-protection-plugin/plugin.ts, `processPendingViolations`) lost all coverage once the dev harness registered Start's entries (20c891a337). Reachability: real dev reaches it. Some edge to the module then lacks transform data: the module is served while its only importer is still being transformed (overlapping requests: Vite's `server.warmup`, parallel requests, a slow resolver). The new test holds the importer's resolution of another import with a resolver plugin, serves the module, then lets the importer finish; a probe confirmed six passes through the branch with the router entry registered. Outcome: the module's denied imports are mocked and logged at their uses. Also: V7 (spc:5415), a server fn factory module (whose violations wait for its compiled imports) calling a denied file and then a denied specifier at the top level is logged at both uses. Pins (NEW main bugs, each verified red for the right reason): - a module transformed before any importer of it is known (`server.warmup`, or any request that comes first) has its violations discarded as unreachable, and its transform is cached: never logged, even once the app loads it (red: mocked, nothing logged). - a denied import reached from the app's client entry (`src/client.tsx`) is not logged: the client entry is not a registered entry and the browser loads it from Start's dev client entry, a virtual module whose imports are not recorded (red: loaded through the dev client entry and mocked, nothing logged). Control: the same leak through the router entry is logged. - with log 'always', one import of a denied file is logged three times per load: twice as written (resolveId and the transform-cache check both find it) and once as the resolved path (red: 3 reports instead of 1). Kills: spc:4727, spc:5415 Survive (outcome-equivalent): spc:4704, 4706, 4723 (only when the warning appears: as the module is served, or when its importer finishes; same logs); spc:4725, 4747 (only add more copies of one 'always' report before the importer finishes; main already logs one import three times, pinned). Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/dev.test.ts | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index 8423f7ad04e..d29dad8c172 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -9,9 +9,11 @@ import path from 'node:path' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterEach, describe, expect, test, vi } from 'vitest' +import { startDevServer } from '../dev-server' import { distinct, factoryTopLevelCall, + locationIn, reactRuntimeFiles, rejection, reportedViolations, @@ -20,6 +22,7 @@ import { } from './start-apps' import type { ImportProtectionOptions } from '../../src/schema' import type { ViolationInfo } from '../../src/import-protection/trace' +import type { Plugin } from 'vite' const timeout = 30_000 @@ -316,6 +319,29 @@ export { b } from './b' }, timeout, ) + + // Bug: with `log: 'always'`, one client import of a denied file is logged + // three times when the client loads its module once: twice for the import + // as written, because import protection finds the same violation both + // when the import resolves (`resolveId`) and when it checks the module's + // compiled imports (transform-cache import loop), and 'always' does not + // dedupe them (vite/import-protection-plugin/plugin.ts `hasSeen`), plus + // once for the resolved path (the 'once' pin above). + // Impact: with 'always', the dev server output repeats each violation of + // a denied file three times per load of its module. + // Source: found by mutation run R3c (the 'always' rows of the reload + // tests above). + test.fails( + "'always' logs one warning for one import each time its module is loaded", + async () => { + const server = await startProtectedDevServer(deniedFileApp, { + log: 'always', + }) + await server.load('/src/entry.ts') + expect(reportedViolations(server.logs)).toHaveLength(1) + }, + timeout, + ) }) describe('a client import of a denied specifier', () => { @@ -615,6 +641,223 @@ describe('reachability from the router entry', () => { }, timeout, ) + + // Source: mutation run R3 ip-vite V7 (spc:5415). The factory module's + // violations wait for its compiled imports (the Start compiler looked it + // up), and its denied file import comes before its denied specifier. + test( + 'a server fn factory module calling a denied file and a denied specifier at the top level is logged at both uses', + async () => { + const factory = `import { createMiddleware, createServerFn } from '@tanstack/react-start' +import { getSecret } from './secret.server' +import { getRequest } from '@tanstack/react-start/server' +const mw = createMiddleware({ type: 'function' }).server(({ next }) => { + return next({ context: { secret: getSecret(), req: getRequest() } }) +}) +export const createSecretFn = createServerFn().middleware([mw]) +export const secret = getSecret() +export const request = getRequest() +` + const server = await startProtectedDevServer({ + ...serverFnFactoryApp(), + 'src/factory.ts': factory, + 'src/entry.ts': `export { fn } from './usage'\n`, + }) + const client = await server.load('/src/entry.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs).sort()).toEqual([ + `client ${locationIn('src/factory.ts', factory, 8, 'getSecret')}`, + `client ${locationIn('src/factory.ts', factory, 9, 'getRequest')}`, + ]) + }, + timeout, + ) + + /** + * `src/p.ts` uses the denied file `src/secret.server.ts` and the denied + * specifier `@tanstack/react-start/server`; its only importer `src/q.ts`, + * which the router entry re-exports, also imports `src/slow.ts`. + */ + const overlapApp = { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/p.ts': `import { secret } from './secret.server' +import { getRequest } from '@tanstack/react-start/server' +export const read = () => secret() +export const request = () => getRequest() +`, + 'src/slow.ts': `export const slow = 1\n`, + 'src/q.ts': `import { read, request } from './p' +import { slow } from './slow' +export const q = () => [read(), request(), slow] +`, + 'src/entry.ts': `export * from './q'\n`, + } + + /** + * Serves `src/p.ts` while its only importer `src/q.ts` is still being + * transformed, as overlapping requests do (Vite's `server.warmup`, + * parallel requests, a slow resolver): a resolver plugin holds the + * resolution of `src/q.ts`'s import of `./slow` until `src/p.ts` and its + * imports are served. Then the client loads the app. Returns the modules + * served for `src/p.ts`. + */ + async function serveWhileItsImporterTransforms() { + let release!: () => void + const released = new Promise((resolve) => { + release = resolve + }) + let holding!: () => void + const held = new Promise((resolve) => { + holding = resolve + }) + const slowResolver: Plugin = { + name: 'slow-resolver', + enforce: 'pre', + async resolveId(source, importer) { + if (source === './slow' && importer?.endsWith('/src/q.ts')) { + holding() + await released + } + return null + }, + } + const server = await startProtectedDevServer( + overlapApp, + {}, + { plugins: [slowResolver] }, + ) + await server.transform('/src/entry.ts') + const importer = server.transform('/src/q.ts') + await held + const served = await server.load('/src/p.ts') + release() + await importer + await server.load('/src/entry.ts') + return { server, served } + } + + // Source: mutation run R3c (the dev 'unknown'-reachability fallback, + // spc:4704-4727: some edges to the module lack transform data). + test( + 'a module served while its only importer is still being transformed has its denied imports mocked and logged at their uses', + async () => { + const { server, served } = await serveWhileItsImporterTransforms() + expect(modulesContaining(served, 'MARK_SECRET')).toEqual([]) + const p = overlapApp['src/p.ts'] + expect(loggedViolations(server.logs).sort()).toEqual([ + `client ${locationIn('src/p.ts', p, 3, 'secret()')}`, + `client ${locationIn('src/p.ts', p, 4, 'getRequest()')}`, + ]) + }, + timeout, + ) + + // Bug: a module the dev server transforms before any importer of it is + // known (Vite's `server.warmup`, which transforms the listed files as the + // server starts, or any request that comes first) has its violations + // discarded as unreachable from Start's entries + // (vite/import-protection-plugin/plugin.ts `processPendingViolations`, + // `'unreachable'`; only bundled dev keeps them pending), and Vite caches + // its transform, so they are never checked again, even once the app loads + // the module from its entry. + // Impact: in dev (mock mode), denied imports of warmed-up modules are + // mocked without any warning (the build still reports them). + // Source: found by mutation run R3c (looking for edges without transform + // data); the control is "a client import of a denied file is mocked, and + // the dev server logs a warning naming the importer at the use". + test.fails( + 'a denied import of a module warmed up before its importers is logged once the client loads the app', + async () => { + const server = await startProtectedDevServer( + deniedFileApp, + {}, + { config: { server: { warmup: { clientFiles: ['./src/a.ts'] } } } }, + ) + // (waits for the warmup's transform of the module) + await server.transform('/src/a.ts') + const client = await server.load('/src/entry.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([`client ${deniedFileUse}`]) + }, + timeout, + ) +}) + +describe("an app's client entry, with Start's whole plugin", () => { + /** + * An app whose `src/leak.ts` uses the denied file `src/secret.server.ts`, + * imported by the app's client entry `src/client.tsx` or by its router + * entry `src/router.ts`. + */ + const leakApp = (importer: 'src/client.tsx' | 'src/router.ts') => ({ + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/leak.ts': `import { secret } from './secret.server' +export const leak = () => secret() +`, + 'src/client.tsx': + importer === 'src/client.tsx' + ? `import { leak } from './leak'\nexport const read = () => leak()\n` + : `export {}\n`, + 'src/router.ts': + importer === 'src/router.ts' + ? `import { leak } from './leak'\nexport const getRouter = () => leak\n` + : `export const getRouter = () => undefined\n`, + }) + + /** The use of `secret` in `src/leak.ts`. */ + const leakUse = locationIn( + 'src/leak.ts', + leakApp('src/client.tsx')['src/leak.ts'], + 2, + 'secret()', + ) + + /** + * Starts a dev server with Start's whole plugin (without HMR, so that its + * dev client entry imports the app's client entry statically). + */ + const startApp = (files: Record) => + startDevServer({ start: {}, files, config: { server: { hmr: false } } }) + + // Control for the pin below (same harness). + test( + 'a denied import of a module the router entry imports is mocked and logged', + async () => { + const server = await startApp(leakApp('src/router.ts')) + const client = await server.load('/src/router.ts') + expect(Object.keys(client)).toContain('/src/leak.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([`client ${leakUse}`]) + }, + timeout, + ) + + // Bug: dev import protection only logs violations it can reach from the + // entries it registers, Start's router entry and start entry + // (vite/import-protection-plugin/plugin.ts `registerEntries`). The app's + // client entry is not one, and the browser loads it from Start's dev + // client entry, a virtual module whose imports import protection does not + // record, so the modules only the client entry imports are unreachable + // and their violations are discarded. + // Impact: in dev (mock mode), a denied import reached from the app's + // client entry (`src/client.tsx`) is mocked without any warning (the build + // reports it). + // Source: found by mutation run R3c (looking for edges without transform + // data). + test.fails( + 'a denied import of a module the client entry imports is mocked and logged', + async () => { + const server = await startApp(leakApp('src/client.tsx')) + // (Start's dev client entry, which the page loads) + const client = await server.load( + 'virtual:tanstack-start-dev-client-entry', + ) + expect(Object.keys(client)).toContain('/src/leak.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([`client ${leakUse}`]) + }, + timeout, + ) }) describe('diagnostics', () => { From b15ec457a8bedb8994147632cdcb4b4d30a43d44 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 14:56:35 +0200 Subject: [PATCH 0301/1425] test(start-plugin-core): finish the original-source usage lookup rows and pin wrapped createServerOnlyFn Mutation run r3b gaps (ip-analysis, location only, low impact). Rows of `findOriginalUsageLocation`: an immediately invoked function, a computed `[handler]` member of a server fn builder, a private method's callback, `.handler`/`.server` of callees whose names only contain ServerFn/Middleware, a look-alike `.client` method on the server, loops inside createServerOnlyFn, and a type-only import specifier. Pin (ip-analysis P2, main bug, location only): the client compile replaces a whole `createServerOnlyFn(...)` call whatever its argument is, but the lookup only skips functions passed to it directly, so `createServerOnlyFn(wrap(() => getSecret()))` is reported instead of the real leak after it. Fix-agnostic: asserts the leak's line. Red: reports line 3 (the wrapped use) instead of 4. Kills: spc:503, 520, 521, 523, 529, 546, 565, 574, 877, 901, 326, 328; pin flips: spc:798, 799 Equivalent (the cluster range was over-included): spc:527, 528 (a non-computed member always has an Identifier or PrivateName property, and a PrivateName has no `name`), 543, 545 (`/ServerFn$/` matches createServerFn), 559, 561 (`/Middleware$/` matches createMiddleware), 548, 567 (the `?? ''` fallback never matches either regex). Co-Authored-By: Claude Opus 5.5 --- .../importProtection/sourceLocation.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts b/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts index ea14e56e5b5..0b1d1e9d206 100644 --- a/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts +++ b/packages/start-plugin-core/tests/importProtection/sourceLocation.test.ts @@ -501,6 +501,53 @@ export const b = run(() => getSecret())`, body: `export const a = new Api().run(() => getSecret())`, line: 3, }, + // Source: mutation run R3c (the rest of the ip-analysis "Rsbuild + // original-source usage lookup" cluster). + { + name: 'an immediately invoked function', + env: 'client', + body: `export const a = (() => getSecret())()`, + line: 3, + }, + { + name: 'a computed handler member of a server fn builder', + env: 'client', + body: `const handler = 'handler' +export const a = createServerFn()[handler](() => getSecret())`, + line: 4, + }, + { + name: 'a callback of a private method', + env: 'client', + body: `export class Api { #run(fn: () => unknown) { return fn() } go() { return this.#run(() => getSecret()) } }`, + line: 3, + }, + { + name: 'a .handler of a callee whose name only contains ServerFn', + env: 'client', + body: `export const a = ServerFnRegistry.handler(() => getSecret())`, + line: 3, + }, + { + name: 'a .server of a callee whose name only contains Middleware', + env: 'client', + body: `export const a = MiddlewareRegistry.server(() => getSecret())`, + line: 3, + }, + { + name: 'createIsomorphicFn().client, then another .client method', + env: 'server', + body: `export const a = createIsomorphicFn().client(() => getSecret()) +export const b = api.client(() => getSecret())`, + line: 4, + }, + { + name: 'loops in createServerOnlyFn, then an ordinary callback', + env: 'client', + body: `export const a = createServerOnlyFn(() => { for (const i of [1]) { getSecret() } for (let i = 0; i < 1; i++) { getSecret() } }) +export const b = run(() => getSecret())`, + line: 4, + }, ] as const)('$env: $name', ({ env, body, line }) => { const provider = makeProvider('export {}\n', imports + body) expect( @@ -512,6 +559,52 @@ export const b = run(() => getSecret())`, )?.line, ).toBe(line) }) + + // Source: mutation run R3c (spc:326, 328). + test('a type-only import specifier is not a use', () => { + const code = `import { createServerFn } from '@tanstack/react-start' +import { type Secret, getSecret } from './secret.server' +export const v: Secret = 1 as any +export const leak = getSecret` + const provider = makeProvider('export {}\n', code) + expect( + findOriginalUsageLocation( + provider, + '/test/src/a.ts', + './secret.server', + 'client', + )?.line, + ).toBe(4) + }) + + // Bug: the original-source usage lookup skips only functions passed + // directly to a compiler-safe boundary call (import-protection/analysis.ts + // `isInsideCompilerSafeBoundaryNodes`), but the client compile replaces a + // whole `createServerOnlyFn(...)` call, whatever its argument is (here a + // function wrapped in a call), so the use inside it never runs in the + // client; the lookup reports it instead of the real leak on the next line. + // Impact: where the location comes from the original source (Rsbuild; + // Vite when the compiled code has no use), the violation points at code + // the client never runs, not at the leak (location only). + // Source: mutation round 3 ip-analysis pin P2. + test.fails( + 'client: a wrapped createServerOnlyFn argument is not a use, the ordinary callback after it is', + () => { + const provider = makeProvider( + 'export {}\n', + `${imports}export const a = createServerOnlyFn(wrap(() => getSecret())) +export const b = run(() => getSecret())`, + ) + expect( + findOriginalUsageLocation( + provider, + '/test/src/a.ts', + './secret.server', + 'client', + )?.line, + ).toBe(4) + }, + ) }) describe('findPostCompileUsageLocation', () => { From 94964ca6b0dce186b08ed05f9dd974a6c9f9bc1d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 15:00:22 +0200 Subject: [PATCH 0302/1425] test(start-plugin-core): a throwing onViolation fails the Rsbuild dev compile (the pin was a harness artefact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin "an onViolation that throws ends the compile with its error" (c079f2e351, REPORT §6 #23) said the Rsbuild dev compile never finishes and the error is never reported. With the Rsbuild dev harness now failing its compile wait on `compiler.hooks.failed` (835b7b72a3), the test passes on main: Rspack fails the compile with the thrown error, and Rsbuild's dev watcher marks the build failed and logs the error (@rsbuild/core 2.2.5 dist/626.js, `compiler.watch` callback: `logger.error(error)`). The harness only waited for `done` and runs with `logLevel: 'silent'`, so the error looked lost. The pin becomes a normal test; no claimed kills depended on it. Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/known-bugs.test.ts | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) 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 10466f2b925..97338c9ee9f 100644 --- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts @@ -416,37 +416,32 @@ describe('Rsbuild dev', () => { 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') - }, + // In Rsbuild dev, an error thrown by `onViolation` (awaited in the + // `processAssets` hook, rsbuild/import-protection.ts `reportViolation`) + // fails the compile: Rspack gives up on it (`compiler.hooks.failed`, no + // `done`) and Rsbuild logs the error, like a build fails with it. + // Source: mutation run R3, ip-rsbuild (REPORT §6 #23). This was a pin + // ("never reported") while the dev harness only waited for `done`; the + // harness now fails the compile wait with the error Rspack reports + // (mutation run R3c). + test('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, - ) + }, + 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) }) describe.each(startBundlers)('%s build', (bundler) => { From dff84ff95d656f879dabb9746ae3df60abd28d59 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 15:03:07 +0200 Subject: [PATCH 0303/1425] test(start-plugin-core): type the dev harness config option with the harness's Vite types startProtectedDevServer's new `config` option used the package's own Vite `InlineConfig` and `mergeConfig`; the dev harness types its config with compiler-test-utils' Vite copy, so test:types failed. It now takes `StartDevServerOptions['config']` and adds the provider environment by spreading. Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/start-apps.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/start-plugin-core/tests/importProtection/start-apps.ts b/packages/start-plugin-core/tests/importProtection/start-apps.ts index 9fb37937f24..eb213bfd49a 100644 --- a/packages/start-plugin-core/tests/importProtection/start-apps.ts +++ b/packages/start-plugin-core/tests/importProtection/start-apps.ts @@ -7,13 +7,13 @@ * text it prints. */ import path from 'node:path' -import { mergeConfig } from 'vite' import { importProtectionPlugin } from '../../src/vite/import-protection-plugin/plugin' import { startDevServer } from '../dev-server' 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' -import type { InlineConfig, PluginOption } from 'vite' +import type { StartDevServerOptions } from '../dev-server' +import type { PluginOption } from 'vite' export interface ProtectedDevServerOptions { /** @@ -32,7 +32,7 @@ export interface ProtectedDevServerOptions { /** Other plugins of the app, after import protection. */ plugins?: Array /** Vite configuration of the app (e.g. `server.warmup`). */ - config?: InlineConfig + config?: StartDevServerOptions['config'] } /** @@ -57,9 +57,13 @@ export function startProtectedDevServer( config: providerEnvironment === 'ssr' ? config - : mergeConfig(config, { - environments: { [providerEnvironment]: { consumer: 'server' } }, - }), + : { + ...config, + environments: { + ...config.environments, + [providerEnvironment]: { consumer: 'server' }, + }, + }, plugins: ({ root }) => [ importProtectionPlugin({ getConfig: (() => ({ From a38a25938357608607a849c0a45240b4da2d95f4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 15:04:16 +0200 Subject: [PATCH 0304/1425] test(compiler-test-utils): lint fixes in the run-node control test Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/tests/run-node.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/compiler-test-utils/tests/run-node.test.ts b/packages/compiler-test-utils/tests/run-node.test.ts index 4701e98014b..514fecfd1fd 100644 --- a/packages/compiler-test-utils/tests/run-node.test.ts +++ b/packages/compiler-test-utils/tests/run-node.test.ts @@ -78,15 +78,11 @@ 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( + const pid = await poll( () => readFile(pidFile, 'utf8').then( (text) => (text ? Number(text) : undefined), @@ -94,12 +90,17 @@ runNodeProgram(${JSON.stringify(program)}, { timeout: 120_000 }).catch(() => {}) ), 'the program did not start', ) + onTestFinished(() => { + if (isRunning(pid)) { + process.kill(pid, 'SIGKILL') + } + }) expect(isRunning(pid)).toBe(true) starter.kill('SIGKILL') await poll( - async () => (isRunning(pid!) ? undefined : true), + () => Promise.resolve(isRunning(pid) ? undefined : true), 'the program still runs after the process that started it was killed', ) expect(isRunning(pid)).toBe(false) From 3a10f18be0056d13cdbe2df82fae2b02e070fcf0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:20:50 +0200 Subject: [PATCH 0305/1425] test(router-plugin): pin fuzz findings N1, N2, N3 and V3 of the code splitter Turns the oracle-mode fuzzing findings for the code splitter into tests (known-bugs-code-splitter.test.ts). Each pin was run once as a normal test and failed for the bug. - N1 (new main bug): an imported split component that other code still reads loses its import. Pins: head() reads PostsPage.title (loadRoute), an exported component renders the imported errorComponent, read through a real importer (adapted from examples/solid/start-supabase-basic posts.$postId.tsx), a pendingComponent split by custom groupings that an unsplit errorComponent renders, and real SSR builds on Vite and Rspack. Red: "PostsPage/ErrorComponent/Spinner is not defined". Control: the same head() route with the component declared in the route file passes. - N2 (new main bug): a top-level call reading a module-level binding runs again in every split chunk. Two rows in the "unreferenced top-level side effect" table (red: 3 calls instead of 1); the table's Bug comment now covers these calls. Control: calls reading only imports or literals run once. The harness body moved into expectEffectRunsOnce. - N3 (new main bug, upstream root cause): parenthesized JSX under `!` or a member call is printed without parentheses. Red: the split chunk does not parse. Root cause in @babel/generator 7.29.8; no workaround. Control: parenthesized JSX alone. - V3 (variant): rows for the split-option re-parse pin: an immediately invoked function, a typed arrow, `as any`, `!` and a type argument (the last one parses as comparisons and yields `false`). Red: Babel "Unexpected token" / "component is not a function". Co-Authored-By: Claude Opus 5.5 --- .../tests/known-bugs-code-splitter.test.ts | 375 +++++++++++++++--- 1 file changed, 328 insertions(+), 47 deletions(-) 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 ea8a4ca20b1..3d859231a81 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -17,6 +17,7 @@ import { importedNames, loadRoute, parseModule, + routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' import type { @@ -478,18 +479,66 @@ ${options} component: () =>

    ${render}

    , }, ) - // Bug: split chunks keep the top-level statements that nothing references - // unless they are expression statements: dead-code elimination only removes - // what the split made unreferenced, and + /** + * Loads a route with a component and an errorComponent next to the + * top-level `effect`, which calls `track` or `subscribe` (`./effects`), + * loads both chunks and asserts that the effect ran once, with the route + * module. + */ + async function expectEffectRunsOnce(effect: string) { + let calls = 0 + const route = await loadRoute( + `${head}import { subscribe, track } from './effects' +${effect} +export const Route = createFileRoute('/')({ + component: () =>

    index

    , + errorComponent: () =>

    error

    , +})`, + { + stubs: { + './effects': { + track: () => calls++, + subscribe: (listener: () => void) => { + calls++ + return listener + }, + }, + }, + }, + ) + const callsBeforeChunks = calls + const loaded: Array = [] + for (const option of ['component', 'errorComponent']) { + if (typeof (await route.load(option)) === 'function') { + loaded.push(option) + } + } + // Both chunks loaded, and the effect ran once, with the route module. + expect([loaded.join(), callsBeforeChunks, calls]).toEqual([ + 'component,errorComponent', + 1, + 1, + ]) + } + + // Bug: split chunks keep the top-level statements that nothing references, + // except expression statements that read only imports: dead-code + // elimination only removes what the split made unreferenced, and // `stripUnreferencedTopLevelExpressionStatements` only strips expression - // statements, so an `if` block, a declaration whose binding is never read - // or an `export * from` is copied into every split chunk. + // statements that name no module-level binding. So an `if` block, a + // declaration whose binding is never read, an `export * from`, or a call + // that reads a module-level binding (`register(...parts)`) is copied into + // every split chunk. // A reference cycle that nothing else reads keeps its own references, so a // fix that drops only declarations nothing references still copies it. + // Source: router-utils/src/compiler-helpers.ts:884-901 (called from + // compilers.ts:1278); the call rows: fuzzing (oracle mode, N2), e.g. + // `const parts = [BarController, BarElement]; Chart.register(...parts)`. // Impact: side effects run again for every chunk that loads, even a chunk - // holding only the errorComponent (duplicated subscriptions, logging); every - // chunk depends on and re-exports all of the `export *` module through the - // opaque namespace of its dynamic import, which defeats tree-shaking of it. + // holding only the errorComponent (duplicated subscriptions, logging, + // plugin registrations); every chunk depends on and re-exports all of the + // `export *` module through the opaque namespace of its dynamic import, + // which defeats tree-shaking of it. test.fails.each([ { name: 'an if statement', @@ -546,42 +595,32 @@ export function Card(props: { stop: typeof unsubscribe }) { effect: `let label = track() label = 'reassigned'`, }, + { + name: 'a call spreading a module-level array', + effect: `const names = ['a', 'b'] +track(...names)`, + }, + { + name: 'a call reading a module-level function', + effect: `function widget() {} +track(widget)`, + }, ])( 'an unreferenced top-level side effect ($name) runs once, with the route module', async ({ effect }) => { - let calls = 0 - const route = await loadRoute( - `${head}import { subscribe, track } from './effects' -${effect} -export const Route = createFileRoute('/')({ - component: () =>

    index

    , - errorComponent: () =>

    error

    , -})`, - { - stubs: { - './effects': { - track: () => calls++, - subscribe: (listener: () => void) => { - calls++ - return listener - }, - }, - }, - }, - ) - const callsBeforeChunks = calls - const loaded: Array = [] - for (const option of ['component', 'errorComponent']) { - if (typeof (await route.load(option)) === 'function') { - loaded.push(option) - } - } - // Both chunks loaded, and the effect ran once, with the route module. - expect([loaded.join(), callsBeforeChunks, calls]).toEqual([ - 'component,errorComponent', - 1, - 1, - ]) + await expectEffectRunsOnce(effect) + }, + ) + + // Control for the pin above (same harness): a top-level call that reads + // only imports and literals is stripped from the chunks. + test.each([ + { name: 'a call reading an import', effect: 'track(subscribe)' }, + { name: 'a call with literal arguments', effect: `track('a', 'b')` }, + ])( + 'an unreferenced top-level side effect ($name) runs once, with the route module', + async ({ effect }) => { + await expectEffectRunsOnce(effect) }, ) @@ -1207,14 +1246,21 @@ export const Route = createFileRoute('/')({ }, ) - // 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 - // syntax 'jsx' isn't currently enabled"). + // Bug: a component option written as a call cannot be split when its + // printed code does not parse on its own with Babel's default options: the + // virtual compiler prints the call and parses it again as a script + // statement, without JSX ("Support for the experimental syntax 'jsx' isn't + // currently enabled"), without TypeScript ("Unexpected token"), and with a + // leading `function` read as a declaration (an immediately invoked + // function). A type argument (`memo(Page)`) even parses, as + // comparisons, and the option silently becomes `false`. + // Source: compilers.ts:1081-1082 (`babel.parse(outputSplitNodeCode)`). // Sources: differential fuzzing; dev servers of the e2e apps, where the // `?tsr-split=component` module failed with HTTP 500; #8504 - // (code-splitter-jsx.test.ts), where they pass. - // Impact: the route cannot be built once code splitting is on. + // (code-splitter-jsx.test.ts), where the JSX rows pass; fuzzing (oracle + // mode, V3) for the TypeScript and function rows. + // Impact: the route cannot be built once code splitting is on, or renders + // a value that is not its component. test.fails.each<{ name: string component: string @@ -1245,6 +1291,35 @@ import { defineComponent } from 'vue'`, component: `defineComponent({\n setup: () => () =>

    page

    ,\n})`, render: (component) => component.setup()(), }, + { + name: 'an immediately invoked function', + imports: `${head}import { Page } from './page'`, + component: `(function (component) {\n return component\n})(Page)`, + render: (component) => component(), + }, + { + name: 'a call of a typed arrow function', + component: `memo((): string => '

    page

    ')`, + render: (component) => component(), + }, + { + name: 'a call with a type assertion', + imports: `${head}import { memo } from 'react'\nimport { Page } from './page'`, + component: `memo(Page as any)`, + render: (component) => component(), + }, + { + name: 'a call with a non-null assertion', + imports: `${head}import { memo } from 'react'\nimport { Page } from './page'`, + component: `memo(Page!)`, + render: (component) => component(), + }, + { + name: 'a call with a type argument', + imports: `${head}import { memo } from 'react'\nimport { Page } from './page'`, + component: `memo(Page)`, + render: (component) => component(), + }, ])( 'a component written as $name is split and renders', async ({ @@ -1262,6 +1337,7 @@ export const Route = createFileRoute('/')({ component: ${component} }) stubs: { react: { memo: (value: unknown) => value }, vue: { defineComponent: (value: unknown) => value }, + './page': { Page: () => '

    page

    ' }, }, }, ) @@ -3028,6 +3104,48 @@ export const Route = createFileRoute('/')({ component: Page }) }, ) + /** + * Compiles a route whose split component returns `expression`, checks that + * every emitted module is valid and returns what the component renders. + */ + async function renderReturned(expression: string) { + const code = `${head}function Page() { + return ${expression} +} +export const Route = createFileRoute('/')({ component: Page }) +` + await expectValidModules(compileRouteModules(code).modules) + return (await (await loadRoute(code)).load('component'))() + } + + // Control for the pin below (same harness): parenthesized JSX alone. + test('parenthesized JSX keeps working in a split component', async () => { + expect(await renderReturned('(

    page

    )')).toBe('

    page

    ') + }) + + // Bug: parenthesized JSX under a non-null assertion or a member access is + // printed without its parentheses (`

    page

    !`), which does not parse. + // The root cause is upstream: @babel/generator 7.29.8 drops the + // parentheses (no workaround here; router-utils/src/ast.ts:72 + // `generateFromAst` prints with it). + // Source: fuzzing (oracle mode, N3). + // Impact: the split component's chunk fails to compile ("Expected a + // semicolon"), so the build fails. + test.fails.each([ + { name: 'a non-null assertion', expression: '(

    page

    )!' }, + { + name: 'a non-null assertion of a fragment', + expression: '(<>)!', + rendered: '', + }, + { name: 'a member call', expression: '(

    page

    ).valueOf()' }, + ])( + 'parenthesized JSX under $name keeps working in a split component', + async ({ expression, rendered = '

    page

    ' }) => { + expect(await renderReturned(expression)).toBe(rendered) + }, + ) + /** A route whose `beforeLoad` and split loader both call `helper`. */ const sharedHelperRoute = (helper: string) => `${head}${helper} export { helper as helperAlias } @@ -3333,6 +3451,169 @@ register(Page) ]).toEqual([['
    page
    '], '
    page
    ']) }, ) + + // Control for the imported-component pins below (same harness): the split + // component is declared in the route file. + test('a split component declared in the route file renders, and head() reads it', async () => { + const route = await loadRoute(`${head}function PostsPage() { + return

    posts

    +} +PostsPage.title = 'Posts' +export const Route = createFileRoute('/posts')({ + component: PostsPage, + head: () => ({ meta: [{ title: PostsPage.title }] }), +}) +`) + expect([route.options.head(), (await route.load('component'))()]).toEqual([ + { meta: [{ title: 'Posts' }] }, + '

    posts

    ', + ]) + }) + + // Bug: when a split option names an imported binding, the code splitter + // deletes its import specifier from the route module + // (`removeIdentifierLiteral` removes the binding without checking for other + // reads), then the emptied import, even when other code there still reads + // it: an unsplit option, an exported function or anything else. Sibling of + // the pins above, which cover bindings declared in the route file. + // Source: compilers.ts:571-593 (`removeIdentifierLiteral`), :1555-1593 + // (the binding is removed), :768-777 (the emptied import is dropped); + // fuzzing (oracle mode, N1). + // Impact: the route throws "PostsPage is not defined" where the other code + // runs (head() on every navigation, an importer's render). + test.fails( + 'an imported split component that head() reads renders, and head() reads it', + async () => { + const route = await loadRoute( + `${head}import { PostsPage } from './posts-page' +export const Route = createFileRoute('/posts')({ + component: PostsPage, + head: () => ({ meta: [{ title: PostsPage.title }] }), +}) +`, + { + stubs: { + './posts-page': { + PostsPage: Object.assign(() => '

    posts

    ', { + title: 'Posts', + }), + }, + }, + }, + ) + expect([route.options.head(), (await route.load('component'))()]).toEqual( + [{ meta: [{ title: 'Posts' }] }, '

    posts

    '], + ) + }, + ) + + // Bug: same root cause as above, for an exported component that renders + // the imported split one, used by another module of the app (ruling P1: + // through a real importer, not the export list). Adapted from the + // repository's + // examples/solid/start-supabase-basic/src/routes/_authed/posts.$postId.tsx. + // Impact: rendering `PostErrorComponent` throws "ErrorComponent is not + // defined". + test.fails( + 'an imported split errorComponent that an exported component renders works for an importer', + async () => { + const route = await loadRoute( + `import { ErrorComponent, createFileRoute } from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' +export const Route = createFileRoute('/posts/$postId')({ + loader: ({ params: { postId } }) => postId, + errorComponent: ErrorComponent, + component: PostComponent, +}) +export function PostErrorComponent({ error }: ErrorComponentProps) { + return +} +function PostComponent() { + return

    post

    +} +`, + { + targetFramework: 'solid', + stubs: { + '@tanstack/solid-router': { + ErrorComponent: ({ error }: { error: Error }) => + `

    ${error.message}

    `, + }, + }, + }, + ) + const importer = await route.importFromRoute( + `import { PostErrorComponent } from '${routeSpecifier}' +export const render = (error) => PostErrorComponent({ error })`, + ) + const error = new Error('boom') + expect([ + importer.render(error), + (await route.load('errorComponent'))({ error }), + (await route.load('component'))(), + ]).toEqual(['

    boom

    ', '

    boom

    ', '

    post

    ']) + }, + ) + + // Bug: same root cause as above, for a pendingComponent that custom + // groupings split and an unsplit errorComponent renders. + // Impact: rendering the errorComponent throws "Spinner is not defined". + test.fails( + 'an imported pendingComponent split by custom groupings renders, and an unsplit option renders it', + async () => { + const route = await loadRoute( + `${head}import { Spinner } from './spinner' +export const Route = createFileRoute('/')({ + codeSplitGroupings: [['component'], ['pendingComponent']], + pendingComponent: Spinner, + errorComponent: () =>
    , + component: () =>

    page

    , +}) +`, + { stubs: { './spinner': { Spinner: () => 'spin' } } }, + ) + expect([ + (await route.load('errorComponent'))(), + (await route.load('pendingComponent'))(), + ]).toEqual(['
    spin
    ', 'spin']) + }, + ) + + // Bug: same root cause as above, in a real build. + // Impact: the server render of the route throws "PostsPage is not defined" + // when it runs head(). + test.fails.each(['vite', 'rspack'] as const)( + 'an imported split component that head() reads renders, and head() reads it (%s build)', + async (bundler) => { + const app = await build({ + bundler, + env: 'ssr', + minify: false, + files: routeApp( + `${head}import { PostsPage } from '../posts-page' +export const Route = createFileRoute('/')({ + component: PostsPage, + head: () => ({ meta: [{ title: PostsPage.title }] }), +}) +`, + { + files: { + 'posts-page.tsx': `export function PostsPage() { + return

    posts

    +} +PostsPage.title = 'Posts' +`, + }, + }, + ), + }) + const { result } = await app.run( + `return [entry.Route.options.head().meta[0].title, await entry.render(entry.Route.options.component)]`, + ) + expect(result).toEqual(['Posts', '

    posts

    ']) + }, + 60_000, + ) /** * Builds a route whose split component `Page` an unused module-level * object also names, and mounts the component in the client bundle. From e395052be0d7fb475e27da29d324ef2facb76579 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:20:56 +0200 Subject: [PATCH 0306/1425] test(router-generator): pin the second createFileRoute import for a wrapper module (fuzz V2) A route file that imports createFileRoute from a module of the app (`@/lib/router`) gets a second createFileRoute import from the router package, so the written file no longer parses. The pin runs the real generator on a moved route and asserts that the written file is a valid module (known-bugs.test.ts, "route constructor imports"). Red: "Identifier `createFileRoute` has already been declared". Co-Authored-By: Claude Opus 5.5 --- .../router-generator/tests/known-bugs.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/router-generator/tests/known-bugs.test.ts b/packages/router-generator/tests/known-bugs.test.ts index e2b26423e82..2f789714bde 100644 --- a/packages/router-generator/tests/known-bugs.test.ts +++ b/packages/router-generator/tests/known-bugs.test.ts @@ -5,6 +5,7 @@ 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 { describe, expect, test, vi } from 'vitest' import { Generator, getConfig } from '../src' import { routeIdOf } from './route-id' @@ -206,4 +207,28 @@ export const Route = createFileRoute('/old')({})`, expect(await routeIdOf(written)).toBe('/moved') }, ) + + // Bug: same root cause as above, for `createFileRoute` imported from a + // module of the app (a wrapper re-exporting it): no import from the + // router package names it, so the generator prepends one, next to the + // user's import of the same name. + // Source: router-generator/src/transform/transform.ts:502-565 + // (`normalizeRouteImports` adds the import when no import of the router + // package owns the constructor); fuzzing (oracle mode, V2), 7 unmodified + // corpus files. + // Impact: the written route file declares `createFileRoute` twice and no + // longer parses, so the app fails to build. + test.fails( + 'a moved route importing createFileRoute from a module of the app stays a valid module', + async () => { + const { written } = + await generateMovedRoute(`import { createFileRoute } from '@/lib/router' +export const Route = createFileRoute('/old')({})`) + const { errors } = parseSync('route.tsx', written, { + sourceType: 'module', + showSemanticErrors: true, + }) + expect(errors.map((error) => error.message)).toEqual([]) + }, + ) }) From 1667b65f3baea35fd5595859789629d0a19fd7d5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:21:06 +0200 Subject: [PATCH 0307/1425] test(start-plugin-core): pin fuzz findings N3, N4, N5, V1 and the classic JSX runtime at compiler level Each pin was run once as a normal test and failed for the bug. - N3 (upstream root cause): parenthesized JSX under `!` or a member call loses its parentheses in the client output, and in a Hydrate `fallback` (start-compiler-preserves-user-code.test.ts). Red: the module does not parse. Root cause in @babel/generator 7.29.8; no workaround. Control: parenthesized JSX alone. - N4 (new main bug): a `.validator()` on a builder stored in a variable is not stripped from the client. Server fn rows in createServerFn/caller-dead-code.test.ts (one and two handlers; red: the client imports ./schema; the provider still applies the validator) and a middleware row in start-compiler-factories.test.ts (red: the client imports ./check.server). Control: the `.server()` link of a middleware builder variable is stripped. - N5 (new main bug): a component bound inside the `` children (`const Icon = icons[id]` in a map callback) is passed as a capture (known-bugs-hydrate.test.ts). Red: the client parent references an undeclared `Icon`. Control: `Icon` bound outside the boundary renders the same on client and server. - V1 (upstream root cause): a validator schema that a type alias reads with `typeof` stays in the client (known-bugs-start-compiler.test.ts). Red: the client still imports zod. Root cause in babel-dead-code-elimination 1.0.12; no workaround. Control: the schema without the type alias leaves. - Classic JSX runtime: under `/** @jsxRuntime classic */`, the SSR output drops `import * as React` once the `` children are removed. Red: "`React` is referenced but never declared or imported". Control: the import stays when other kept code reads React. Co-Authored-By: Claude Opus 5.5 --- .../createServerFn/caller-dead-code.test.ts | 39 ++++++++ .../tests/known-bugs-hydrate.test.ts | 71 +++++++++++++++ .../tests/known-bugs-start-compiler.test.ts | 90 +++++++++++++++++++ .../tests/start-compiler-factories.test.ts | 49 ++++++++++ ...start-compiler-preserves-user-code.test.ts | 68 ++++++++++++++ 5 files changed, 317 insertions(+) diff --git a/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts b/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts index 85a0750f8ef..811d4e93911 100644 --- a/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts @@ -459,6 +459,45 @@ export const fn = ${wrap(`createServerFn().validator(schema)`)}.handler(async ({ ).toBe('data!') }, ) + + // Bug: a `.validator()` link on a builder stored in a variable is not + // stripped from the callers: the client follows the handler's chain only + // up to the identifier (`withPost.handler(...)`), so the variable's + // declaration keeps its validator. An inline chain is stripped ("when the + // handler only reads it through a validator or a middleware", above). + // Source: start-compiler/compiler.ts:1296-1330 (the chain walk stops at an + // identifier), start-compiler/handleCreateServerFn.ts:277-290; fuzzing (oracle + // mode, N4). + // Impact: the validator and its imports (schemas, server-only modules) + // ship to the client, which never runs them. + test.fails.each([ + { + name: 'one handler', + handlers: `export const fn = withPost.handler(async ({ data }) => data)`, + }, + { + name: 'two handlers', + handlers: `export const fn = withPost.handler(async ({ data }) => data) +export const other = withPost.handler(async ({ data }) => data)`, + }, + ])( + 'when the validator link is on a builder stored in a variable ($name)', + async ({ handlers }) => { + const compiled = + await compileAll(`${head}import { schema } from './schema' +const withPost = createServerFn({ method: 'POST' }).validator(schema) +${handlers}`) + expect(importSources(compiled.client)).toEqual([]) + expect( + await callProvider( + compiled, + 'fn', + { './schema': { schema: (data: string) => `${data}!` } }, + 'data', + ), + ).toBe('data!') + }, + ) }) // Source: @vitejs/plugin-rsc hoist/function-hoist-block.js, 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 1cc3e3737c0..c658ca9ae3f 100644 --- a/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts +++ b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts @@ -564,6 +564,77 @@ export function Page() { }, ) + /** `Icons` picks a component per id: `` renders it. */ + const iconStubs = { + './icons': { icons: { a: () => 'a', b: () => 'b' } }, + } + const renderIcons = (module: Record) => + module.Icons({ ids: ['a', 'b'] }) + + // Control for the pin below (same harness): a component the component body + // binds outside the boundary is a capture, passed to the chunk. + test('client: the split children render a component bound outside the boundary', async () => { + const code = `${head}import { icons } from './icons' +export function Icons({ ids }) { + const Icon = icons[ids[0]] + return ( + +
      {ids.map((id) =>
    • )}
    +
    + ) +}` + const server = await renderBoundaries('server', code, renderIcons, { + stubs: iconStubs, + }) + expect(server.html).toBe('[
    • a
    • a
    ]') + expect( + ( + await renderBoundaries('client', code, renderIcons, { + stubs: iconStubs, + }) + ).html, + ).toBe(server.html) + }) + + // Bug: a JSX tag naming a binding declared inside the children (`const Icon + // = icons[id]` in a `.map()` callback) is taken for a capture of the + // component: the client parent passes `Icon={Icon}` to the chunk, where no + // `Icon` exists. The identifier visitor checks where a binding is declared, + // the JSX tag visitor does not. + // Source: hydrate-when-transform.ts:439-447 (JSXIdentifier, without the + // declared-inside check of :426-434); fuzzing (oracle mode, N5). + // Impact: the parent throws "Icon is not defined" when it renders, so the + // page fails on the client. + test.fails( + 'client: the split children render a component bound inside them', + async () => { + const code = `${head}import { icons } from './icons' +export function Icons({ ids }) { + return ( + +
      + {ids.map((id) => { + const Icon = icons[id] + return
    • + })} +
    +
    + ) +}` + const server = await renderBoundaries('server', code, renderIcons, { + stubs: iconStubs, + }) + expect(server.html).toBe('[
    • a
    • b
    ]') + expect( + ( + await renderBoundaries('client', code, renderIcons, { + stubs: iconStubs, + }) + ).html, + ).toBe(server.html) + }, + ) + // Bug: property names, method keys and optional member names in the // children are taken for reads of the local of the same name, which the // parent then reads where the boundary renders. 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 f8c01d5940f..a2baaa04c58 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 @@ -1094,6 +1094,50 @@ export const Card = () =>
    ` }, ) + /** + * A module with a classic JSX runtime pragma whose `Hero` renders `Chart` + * in ``; `heroBody` runs first in `Hero`. + */ + const classicRuntimeModule = (heroBody: string) => `/** @jsxRuntime classic */ +import * as React from 'react' +import { ClientOnly } from '@tanstack/react-router' +export function Hero() { + ${heroBody} + return
    +} +function Chart() { + const ref = React.useRef(false) + return +}` + + // Control for the pin below (same harness): code the SSR output keeps + // still reads `React`. + test('ssr: a module with a classic JSX runtime pragma that reads React elsewhere stays valid', async () => { + const code = classicRuntimeModule('React.useId()') + expect( + await getModuleErrors((await compileCode('ssr', code)) ?? code), + ).toEqual([]) + }) + + // Bug: same family as the pin above: under a classic JSX runtime pragma + // (`/** @jsxRuntime classic */`, whose JSX reads `React`), the SSR output + // removes the `` children, then dead-code elimination drops + // `import * as React`, which only the JSX still reads. + // Source: start-compiler/handleClientOnlyJSX.ts:26-27 (children removed), + // start-compiler/compiler.ts:1349,1410 (dead-code elimination does not + // count the JSX as a read of `React`); fuzzing (oracle mode); one + // unmodified corpus file (a charts hero background). + // Impact: the server render throws "React is not defined". + test.fails( + 'ssr: a module with a classic JSX runtime pragma keeps its React import', + async () => { + const code = classicRuntimeModule('') + expect( + await getModuleErrors((await compileCode('ssr', code)) ?? code), + ).toEqual([]) + }, + ) + // Bug: a type-only import re-exported without `type` (`export { Settings }`, // which TypeScript accepts unless verbatimModuleSyntax is on) loses its // import in the client and SSR outputs, which keep the export that the @@ -1285,6 +1329,52 @@ export const fn = createServerFn().handler(async () => auth.get() as Session)` }, ) + /** A server fn whose validator is a module-level schema. */ + const schemaModule = (typeAlias: string) => `${head}import { z } from 'zod' +const themeValidator = z.union([z.literal('light'), z.literal('dark')]) +${typeAlias} +export const setTheme = createServerFn({ method: 'POST' }) + .validator(themeValidator) + .handler(async ({ data }) => data)` + + /** The modules a compiled module loads once TypeScript is erased. */ + async function erasedImportSources(code: string) { + const { code: javascript } = await transformWithOxc(code, 'module.tsx', { + lang: 'tsx', + }) + return importSources(javascript) + } + + // Control for the pin below (same harness): a schema only the validator + // reads leaves the client with its import. + test('client: a validator schema only the validator reads is not loaded', async () => { + const code = schemaModule('') + expect( + await erasedImportSources((await compileCode('client', code)) ?? code), + ).toEqual([]) + }) + + // Bug: same family as above, for a module-level declaration: a validator + // schema that a type alias also reads (`z.infer`) + // stays in the client output, with what its initializer imports, although + // types read no value at runtime. The root cause is upstream: + // babel-dead-code-elimination 1.0.12 counts a `typeof` type query as a + // reference (no workaround here; `deadCodeElimination` at + // router-utils/src/ast.ts:190 runs it). + // Source: fuzzing (oracle mode, V1); one unmodified corpus file. + // Impact: the schema and its library (or server-only modules) ship to the + // client, which never runs them (see the build pin in + // start-build-env-separation.test.ts). + test.fails( + 'client: a validator schema that a type alias reads with typeof is not loaded', + async () => { + const code = schemaModule('type Theme = z.infer') + expect( + await erasedImportSources((await compileCode('client', code)) ?? code), + ).toEqual([]) + }, + ) + // Bug: removing a server fn handler from the callers keeps the comments // attached to it, which move to the RPC that replaces it. Middleware, // isomorphic and server-only fns drop them. 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 1ec7612a444..c6403ada5d6 100644 --- a/packages/start-plugin-core/tests/start-compiler-factories.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-factories.test.ts @@ -297,6 +297,55 @@ export default createMiddleware().server(async ({ next }) => { ]) }, ) + + /** A module whose middleware chain continues a builder variable. */ + const builderVariableModule = (base: string, rest: string) => + [ + `import { createMiddleware } from '@tanstack/react-start'`, + `import { db } from './db.server'`, + base.includes('check(') && `import { check } from './check.server'`, + `const base = createMiddleware({ type: 'function' })${base}`, + `export const mw = base${rest}`, + ] + .filter(Boolean) + .join('\n') + + // Control for the pin below (same harness): the `.server()` link of a + // builder stored in a variable is stripped on the client. + test('the client strips a .server() link of a builder stored in a variable', async () => { + const code = builderVariableModule(serverLink, clientLink) + const client = await runLinks('client', code) + expect(importSources(client.compiled)).toEqual([]) + expect(client.compiled).not.toMatch(/SERVER_SECRET/) + expect(client.links).toEqual([['client', clientResult]]) + expect((await runLinks('ssr', code)).links).toEqual([ + ['server', serverResult], + ['client', clientResult], + ]) + }) + + // Bug: a `.validator()` link of a middleware builder stored in a variable + // is not stripped on the client: the chain walk from `base.server(...)` + // stops at the identifier, so the variable's declaration keeps its + // validator (its `.server()` link is stripped, the control above). + // Source: start-compiler/compiler.ts:1296-1330 (the chain walk stops at + // an identifier), start-compiler/handleCreateMiddleware.ts:21-32; + // fuzzing (oracle mode, N4). + // Impact: the validator and its server-only imports ship to the client. + test.fails( + 'the client strips a validator link of a builder stored in a variable', + async () => { + const code = builderVariableModule(validatorLink, serverLink) + const client = await runLinks('client', code) + expect(importSources(client.compiled)).toEqual([]) + expect(client.compiled).not.toMatch(/SERVER_SECRET|VALIDATOR_SECRET/) + expect(client.links).toEqual([]) + expect((await runLinks('ssr', code)).links).toEqual([ + ['validator', 'data:VALIDATOR_SECRET'], + ['server', serverResult], + ]) + }, + ) }) // Bug: an isomorphic chain link wrapped in `satisfies` / `as` is not diff --git a/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts b/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts index 589a56f828e..074ce7602d1 100644 --- a/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts @@ -340,6 +340,74 @@ export function bump() { expect((await evaluateModule(client!)).bump()).toBe(4) }) +/** Compiles a module whose `Page` returns `expression` for the client. */ +async function compilePageReturning(expression: string) { + const client = await compileCode( + 'client', + `import { createServerFn } from '@tanstack/react-start' +export const fn = createServerFn().handler(async () => 1) +export function Page() { + return ${expression} +}`, + ) + return client! +} + +// Control for the pin below (same harness): parenthesized JSX alone. +test('client: keeps parenthesized JSX working', async () => { + const client = await compilePageReturning('(

    page

    )') + expect(await getModuleErrors(client)).toEqual([]) + expect((await evaluateModule(client)).Page()).toBe('

    page

    ') +}) + +// Bug: the client output prints parenthesized JSX under a non-null assertion +// or a member access without its parentheses (`

    page

    !`). The root +// cause is upstream: @babel/generator 7.29.8 drops the parentheses (no +// workaround here; router-utils/src/ast.ts:72 `generateFromAst` prints with +// it). The code splitter has the same bug (pin in +// router-plugin/tests/known-bugs-code-splitter.test.ts). +// Impact: the module fails to parse ("Expected a semicolon"), so the build +// fails. +// Source: fuzzing (oracle mode, N3). +test.fails.each([ + { name: 'a non-null assertion', expression: '(

    page

    )!' }, + { + name: 'a non-null assertion of a fragment', + expression: '(<>)!', + rendered: '', + }, + { name: 'a member call', expression: '(

    page

    ).valueOf()' }, +])( + 'client: keeps parenthesized JSX under $name working', + async ({ expression, rendered = '

    page

    ' }) => { + const client = await compilePageReturning(expression) + expect(await getModuleErrors(client)).toEqual([]) + expect((await evaluateModule(client)).Page()).toBe(rendered) + }, +) + +// Bug: same root cause as above, for the `fallback` of a ``, which +// the client parent keeps. +// Impact: the client parent fails to parse, so the build fails. +// Source: fuzzing (oracle mode, N3). +test.fails( + 'client: keeps a parenthesized JSX fallback under a non-null assertion of a Hydrate working', + async () => { + const { parent, chunks } = await compileHydrate( + 'client', + `import { Hydrate } from '@tanstack/react-start' +export function Page() { + return wait
    )!}>

    page

    +}`, + ) + expect( + await Promise.all( + [parent, ...chunks].map((code) => getModuleErrors(code)), + ), + ).toEqual([[], []]) + }, +) + // Bundlers read these comments: minifiers keep legal comments, and Rollup and // Rolldown treat calls of functions annotated `__NO_SIDE_EFFECTS__` as pure. // Source: #8504 start-compiler-preserves-user-code.test.ts From 0965ccae2861302db54de5c0a45dc3d4ab700125 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:21:14 +0200 Subject: [PATCH 0308/1425] test(start-plugin-core): pin builder-variable and typeof-read validators in Vite and Rsbuild builds (fuzz N4, V1) Adds three constructs to the shared "server code of each construct" build of start-build-env-separation.test.ts (one build per bundler, no new build): a server fn validator on a builder variable, a middleware validator on a builder variable (N4), and a validator schema that a type alias reads with `typeof` (V1, upstream root cause in babel-dead-code-elimination). Each pin asserts that the validator module's marker is `nowhere` in the client and that the SSR build still applies the validator. Red on both bundlers: the marker is `eager`; the SSR half passes. Control row: a middleware `.server()` on a builder variable is stripped and runs in the SSR build. The middleware builders live in a module without a server fn factory: with one, the Rsbuild SSR build fails to resolve `createMiddleware` (already pinned as "a module with a middleware and an isomorphic fn builds ...", BS-6/K-13; a server fn triggers it too). The harness's stand-in `createServerFn` gains `.validator()`, which the real runtime has: the server runs the validator (a function) on the input before the handler. Additive; no existing build used it. Co-Authored-By: Claude Opus 5.5 --- .../start-plugin-core/tests/build-harness.ts | 13 ++- .../tests/start-build-env-separation.test.ts | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index d5dc3bd8ae0..156d52459a0 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -87,17 +87,26 @@ export const startRuntimeFiles: Record = { throw new Error('uncompiled Start factory: ' + name) } export function createServerFn() { + let validator const builder = { middleware: () => builder, inputValidator: () => builder, + validator(value) { + validator = value + return builder + }, handler(rpc, impl) { if (!impl) { return rpc } + // Like the real runtime, the server runs the validator (a function) + // on the input before the handler. + const run = (opts = {}) => + impl(validator ? { ...opts, data: validator(opts.data) } : opts) // Like the real runtime, the server fn carries what the extracted fn // carries (its id). - const serverFn = Object.assign((opts) => impl(opts ?? {}), rpc) - serverFn.__executeServer = (opts) => impl(opts ?? {}) + const serverFn = Object.assign((opts) => run(opts ?? {}), rpc) + serverFn.__executeServer = (opts) => run(opts ?? {}) return serverFn }, } 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 9b112e8043a..2f05d94b8de 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 @@ -167,6 +167,50 @@ export const ${name} = (..._args: Array) => '${marker}' ...dependency('routeServerFn', 'MARK_ROUTE_SERVER_FN'), ...dependency('serverRouteHandler', 'MARK_SERVER_ROUTE'), ...dependency('startMiddleware', 'MARK_START_MIDDLEWARE'), + ...dependency('builderValidator', 'MARK_BUILDER_VALIDATOR'), + ...dependency( + 'builderMiddlewareValidator', + 'MARK_BUILDER_MIDDLEWARE_VALIDATOR', + ), + ...dependency( + 'builderMiddlewareServer', + 'MARK_BUILDER_MIDDLEWARE_SERVER', + ), + // A validator factory: the validator it returns returns the marker. + 'src/deps/typedSchema.ts': `;(globalThis as Record).MARK_TYPED_VALIDATOR = 'loaded' +export const typedSchema = (_name: string) => (_data: unknown) => + 'MARK_TYPED_VALIDATOR' +`, + // Builders stored in variables, and a validator schema that a type + // alias reads. The middleware module imports no server fn factory: + // with one, the Rsbuild SSR build fails to resolve its + // `createMiddleware` import (pin "a module with a middleware and an + // isomorphic fn builds ..." in known-bugs-start-compiler.test.ts). + 'src/builder-middleware.ts': `import { createMiddleware } from '@tanstack/react-start' +import { builderMiddlewareValidator } from './deps/builderMiddlewareValidator' +import { builderMiddlewareServer } from './deps/builderMiddlewareServer' +const validatedBase = createMiddleware({ type: 'function' }).validator( + (data: unknown) => builderMiddlewareValidator(data), +) +export const validatedMiddleware = validatedBase.server(({ next }) => next()) +const serverBase = createMiddleware({ type: 'function' }).server(({ next }) => + next({ context: { marker: builderMiddlewareServer() } }), +) +export const serverMiddleware = serverBase.client(({ next }) => next()) +`, + 'src/builders.ts': `import { createServerFn } from '@tanstack/react-start' +import { builderValidator } from './deps/builderValidator' +import { typedSchema } from './deps/typedSchema' +const withValidator = createServerFn({ method: 'POST' }).validator( + (data: unknown) => builderValidator(data), +) +export const validatedFn = withValidator.handler(async ({ data }) => data) +const themeValidator = typedSchema('theme') +type Theme = ReturnType +export const setTheme = createServerFn({ method: 'POST' }) + .validator(themeValidator) + .handler(async ({ data }): Promise => data) +`, 'src/middleware.ts': `import { createMiddleware } from '@tanstack/react-start' import { middlewareServer } from './deps/middlewareServer' import { middlewareValidator } from './deps/middlewareValidator' @@ -232,6 +276,8 @@ export const Route = createFileRoute('/api')({ export const getRouter = () => ({ routeTree }) `, 'src/entry.ts': `export { fn, fromBase, isomorphic, serverOnly } from './fns' +export { setTheme, validatedFn } from './builders' +export { serverMiddleware, validatedMiddleware } from './builder-middleware' export { fnMiddleware } from './middleware' export { startInstance } from './start' export { getRouter } from './router' @@ -350,6 +396,55 @@ const next = (options) => options run: `return entry.startInstance.getOptions().requestMiddleware[0].options.server({ next }).context.marker`, expected: 'MARK_START_MIDDLEWARE', }, + { + construct: 'a function middleware .server() on a builder variable', + marker: 'MARK_BUILDER_MIDDLEWARE_SERVER', + run: `return entry.serverMiddleware.options.server({ next }).context.marker`, + expected: 'MARK_BUILDER_MIDDLEWARE_SERVER', + }, + ])( + '$construct: its server code is not in the client bundle, and runs in the SSR build', + async ({ marker, run, expected }) => { + expect(build.client.where(marker)).toBe('nowhere') + expect((await build.ssr.run(`${routes}${run}`)).result).toEqual( + expected, + ) + }, + ) + + // Bug: a `.validator()` link on a builder stored in a variable is not + // stripped from the client (server fn and middleware): the chain walk + // stops at the identifier. Pinned at compiler level in + // createServerFn/caller-dead-code.test.ts and + // start-compiler-factories.test.ts. + // Source: start-compiler/compiler.ts:1296-1330; fuzzing (oracle mode, N4). + // Bug: a validator schema that a type alias reads with `typeof` stays in + // the client output with its initializer. The root cause is upstream + // (babel-dead-code-elimination 1.0.12 counts a `typeof` type query as a + // reference; no workaround here). Pinned at compiler level in + // known-bugs-start-compiler.test.ts. + // Source: router-utils/src/ast.ts:190; fuzzing (oracle mode, V1). + // Impact: the validators and what they import load eagerly in the + // client, which never runs them. + test.fails.each([ + { + construct: 'a server fn validator on a builder variable', + marker: 'MARK_BUILDER_VALIDATOR', + run: `return await entry.validatedFn({ data: 'input' })`, + expected: 'MARK_BUILDER_VALIDATOR', + }, + { + construct: 'a function middleware validator on a builder variable', + marker: 'MARK_BUILDER_MIDDLEWARE_VALIDATOR', + run: `return entry.validatedMiddleware.options.validator({})`, + expected: 'MARK_BUILDER_MIDDLEWARE_VALIDATOR', + }, + { + construct: 'a server fn validator schema that a type alias reads', + marker: 'MARK_TYPED_VALIDATOR', + run: `return await entry.setTheme({ data: 'light' })`, + expected: 'MARK_TYPED_VALIDATOR', + }, ])( '$construct: its server code is not in the client bundle, and runs in the SSR build', async ({ marker, run, expected }) => { From e9cea828a2fee9e4e9d83c46590a8de0e6eb7169 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:43:13 +0200 Subject: [PATCH 0309/1425] test(start-plugin-core): Rsbuild dev .mts/.cts importers, behavior and mockAccess - A client .mts or .cts module importing a .server file is reported and mocked in Rsbuild dev (the host skipped importers by extension). - Rsbuild dev honours behavior: { dev: 'error' } (compile error) and the dev default (warning); onViolation gets the effective behavior. - Rsbuild dev honours mockAccess: the mock reports a use with console.error by default, console.warn for 'warn', nothing for 'off'. runRsbuildClient gains an additive levels option. Kills: spc:7154, spc:7155, spc:7630, spc:7632, spc:7639 Co-Authored-By: Claude Opus 5.5 --- .../tests/rsbuild-dev-server.ts | 38 +++--- .../tests/rsbuild/import-protection.test.ts | 110 ++++++++++++++++++ 2 files changed, 134 insertions(+), 14 deletions(-) diff --git a/packages/start-plugin-core/tests/rsbuild-dev-server.ts b/packages/start-plugin-core/tests/rsbuild-dev-server.ts index 573db9bfbca..e358afeface 100644 --- a/packages/start-plugin-core/tests/rsbuild-dev-server.ts +++ b/packages/start-plugin-core/tests/rsbuild-dev-server.ts @@ -80,26 +80,36 @@ 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). + * to the console, one message per call (`Error`s by their message), each + * prefixed with its console method (`warn: ...`) when `options.levels`. */ export async function runRsbuildClient( server: Pick, + options: { levels?: boolean } = {}, ): 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 recorder = + (level: string) => + (...args: Array) => { + messages.push( + (options.levels ? `${level}: ` : '') + + 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 }, + console: { + log: recorder('log'), + info: recorder('info'), + warn: recorder('warn'), + error: recorder('error'), + }, }) context.self = context context.globalThis = context 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 627477f5781..6afff00f23b 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -5,6 +5,7 @@ import { getRsbuildResolvedImportProtectionCheck } from '../../src/rsbuild/impor import { reportedViolations } from '../importProtection/start-apps' import { runRsbuildClient, startRsbuildDevServer } from '../rsbuild-dev-server' import type { ViolationInfo } from '../../src/import-protection/trace' +import type { ImportProtectionOptions } from '../../src/schema' describe('getRsbuildResolvedImportProtectionCheck', () => { test('skips file and marker checks for excluded resolved files', () => { @@ -68,6 +69,8 @@ exports.page = () => secret { extension: 'jsx', page: esm }, { extension: 'mjs', page: esm }, { extension: 'cjs', page: commonJs }, + { extension: 'mts', page: esm }, + { extension: 'cts', page: commonJs }, ])( 'is reported and mocked when it is a .$extension file', async ({ extension, page }) => { @@ -109,6 +112,51 @@ console.log(page()) ) }) +// Source: round 4 mutation testing (rsbuild host, `behavior` in dev). +describe('Rsbuild dev: behavior', () => { + test.each<{ + behavior: ImportProtectionOptions['behavior'] + reportedAs: 'warnings' | 'errors' + infoBehavior: ViolationInfo['behavior'] + }>([ + { behavior: undefined, reportedAs: 'warnings', infoBehavior: 'mock' }, + { + behavior: { build: 'mock' }, + reportedAs: 'warnings', + infoBehavior: 'mock', + }, + { behavior: { dev: 'error' }, reportedAs: 'errors', infoBehavior: 'error' }, + ])( + '$behavior: a violation is reported in the compile $reportedAs', + async ({ behavior, reportedAs, infoBehavior }) => { + const seen: Array = [] + const server = await startRsbuildDevServer({ + importProtection: { + behavior, + onViolation: (info) => { + seen.push(info as ViolationInfo) + }, + }, + files: { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/entry.ts': `import { secret } from './secret.server' +console.log(secret()) +`, + }, + }) + const other = reportedAs === 'errors' ? 'warnings' : 'errors' + expect( + reportedViolations(server[reportedAs]).map( + ({ environment, importer }) => `${environment} ${importer}`, + ), + ).toEqual(['client src/entry.ts:2:13']) + expect(reportedViolations(server[other])).toEqual([]) + expect(seen.map((info) => info.behavior)).toEqual([infoBehavior]) + }, + 30_000, + ) +}) + describe('Rsbuild dev: reports across rebuilds', () => { /** An entry using the denied file `src/secret.server.ts` and `src/other.ts`. */ const app = { @@ -209,3 +257,65 @@ console.log(a(), b(), query()) ].sort(), ).toEqual(['db-lib', 'src/a.server.ts', 'src/b.server.ts']) }, 30_000) + +// Source: round 4 mutation testing (rsbuild host, `mockAccess`). +describe('Rsbuild dev: mockAccess', () => { + test.each<{ + mockAccess: ImportProtectionOptions['mockAccess'] + reports: Array + }>([ + { mockAccess: undefined, reports: ['error'] }, + { mockAccess: 'error', reports: ['error'] }, + { mockAccess: 'warn', reports: ['warn'] }, + { mockAccess: 'off', reports: [] }, + ])( + '$mockAccess: the use of a mocked import is reported with console $reports', + async ({ mockAccess, reports }) => { + const server = await startRsbuildDevServer({ + importProtection: { + mockAccess, + 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/entry.ts': `import { a } from './a.server' +import { query } from 'db-lib' +console.log(a(), query()) +`, + }, + }) + expect(server.errors).toEqual([]) + const messages = await runRsbuildClient(server, { levels: true }) + expect(messages.filter((message) => message.includes('MARK_'))).toEqual( + [], + ) + // Each denied import (the file and the specifier), by console method. + expect( + [ + ...new Set( + messages + .filter((message) => + message.includes('Mocked import used in dev client'), + ) + .map( + (message) => + `${message.slice(0, message.indexOf(':'))} ${/Denied import: "([^"]+)"/.exec(message)?.[1]}`, + ), + ), + ].sort(), + ).toEqual( + reports.flatMap((level) => [ + `${level} db-lib`, + `${level} src/a.server.ts`, + ]), + ) + }, + 30_000, + ) +}) From deb72b6f37a360559848abcc60fc0cce6b767899 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:44:21 +0200 Subject: [PATCH 0310/1425] test(start-plugin-core): onViolation gets the type and RegExp pattern of a specifier violation - A callback that returns nothing does not allow a denied specifier: the build fails and the callback gets a 'specifier' violation (the existing table had file and marker rows only). - A RegExp client.specifiers pattern is reported as the rule that matched. Both bundlers. Kills: spc:7818, spc:7056, spc:7057, spc:7059 Co-Authored-By: Claude Opus 5.5 --- .../importProtection/build-options.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 4491344e724..02add9eb997 100644 --- a/packages/start-plugin-core/tests/importProtection/build-options.test.ts +++ b/packages/start-plugin-core/tests/importProtection/build-options.test.ts @@ -222,6 +222,41 @@ export const read = () => getRequest() }, timeout, ) + + // Source: round 4 mutation testing (rsbuild host, the pattern of a + // specifier violation). + test( + 'a RegExp pattern is reported as the rule that matched', + async () => { + const seen: Array = [] + await rejection( + buildStart({ + bundler, + files: { + ...dependency('db-lib'), + 'src/entry.ts': userOf('db-lib'), + }, + entry: 'src/entry.ts', + start: { + importProtection: { + client: { specifiers: [/^db-lib$/] }, + onViolation: (info) => { + seen.push(info as Violation) + }, + }, + }, + }), + ) + expect( + distinct( + seen + .filter((info) => info.envType === 'client') + .map((info) => `${info.specifier} ${String(info.pattern)}`), + ), + ).toEqual(['db-lib /^db-lib$/']) + }, + timeout, + ) }) // `excludeFiles`: resolved files the file and marker rules skip; a value @@ -339,6 +374,18 @@ export const secret = () => 'MARK_SECRET' reported: /^src\/marked\.ts:2:\d+$/, type: 'marker', }, + // Source: round 4 mutation testing (rsbuild host, the type of a + // specifier violation). + { + name: 'a denied specifier', + files: { + 'src/entry.ts': `import { getRequest } from '@tanstack/react-start/server' +export const read = () => getRequest() +`, + }, + reported: /^src\/entry\.ts:2:\d+$/, + type: 'specifier', + }, ])( 'a callback that returns nothing does not allow $name: the build fails, and the callback gets a $type violation', async ({ files, reported, type }) => { @@ -516,4 +563,5 @@ interface Violation { specifier: string importer: string resolved?: string + pattern?: string | RegExp } From 443cf26d9c982d45ffad30888884bd247f4f884e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:48:08 +0200 Subject: [PATCH 0311/1425] test(start-plugin-core): Rsbuild dev compile with a missing module reports only that A dev compile in which the entry module (or an imported module) is missing reports the missing module, and import protection does not fail the compile on the entry without a module. Kills: spc:7250 Co-Authored-By: Claude Opus 5.5 --- .../tests/rsbuild/import-protection.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 6afff00f23b..110b82ae179 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -193,6 +193,28 @@ console.log(secret(), other) 30_000, ) + // Source: round 4 mutation testing (rsbuild host, entries without a + // module). + test.each([ + { module: 'the entry module', file: 'src/entry.ts' }, + { module: 'an imported module', file: 'src/other.ts' }, + ])( + 'a compile in which $module is missing reports only that', + async ({ file }) => { + const server = await startRsbuildDevServer({ + importProtection: {}, + files: app, + }) + await server.edit({ [file]: null }) + expect(server.errors).toEqual([ + expect.stringMatching( + new RegExp(`Module not found.*${path.basename(file, '.ts')}`), + ), + ]) + }, + 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 () => { From 50e2a4d80f2178a460926fe64ef61f787bbb0420 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:48:37 +0200 Subject: [PATCH 0312/1425] test(start-plugin-core): cover Start's Vite dev hooks left by mutation round 3 - A virtual module of a user plugin placed after the Start compiler loads in dev (the server-fn id validator must only claim its own virtual id). - A route that becomes a page before the browser loaded the client route tree shows up in the client route tree (the invalidation must cope with a route tree not in the client module graph yet). - The client route module of a split page route takes part in HMR (Start forces route HMR on in its client code splitter). Kills: spc:5579, spc:5972, spc:6046 Co-Authored-By: Claude Opus 5.5 --- .../tests/start-compiler-dev-server.test.ts | 32 ++++++++++++ .../tests/start-router-dev-server.test.ts | 49 +++++++++++++++++++ 2 files changed, 81 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 e11be72fdd5..bc66402b5e6 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,38 @@ export const later = createServerFn().handler(() => 'MARK_LATER') ) }) +describe('Vite dev: virtual modules of other plugins', () => { + // Start's dev plugins load their own `\0` virtual modules; a virtual + // module of a plugin placed after Start's must still load from its plugin. + // Source: mutation round 3 sc-hosts 10 (spc:5579). + test( + 'a virtual module of a plugin placed after the Start compiler loads', + async () => { + const resolvedId = '\0virtual:user-thing' + const server = await startDevServer({ + plugins: [ + { + name: 'user-virtual-module', + resolveId: (source) => + source === 'virtual:user-thing' ? resolvedId : undefined, + load: (id) => + id === resolvedId + ? `export const thing = 'MARK_USER_THING'\n` + : undefined, + }, + ], + files: { + 'src/uses-thing.ts': `export { thing } from 'virtual:user-thing'\n`, + }, + }) + expect((await server.import('/src/uses-thing.ts')).thing).toBe( + 'MARK_USER_THING', + ) + }, + timeout, + ) +}) + 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 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 index 4688409b02b..89ecc6a87ba 100644 --- a/packages/start-plugin-core/tests/start-router-dev-server.test.ts +++ b/packages/start-plugin-core/tests/start-router-dev-server.test.ts @@ -84,4 +84,53 @@ export const getRouter = () => ({ routeTree }) }, timeout, ) + + // The server starts before any browser loaded the client route tree. + // Source: mutation round 3 start-router I/J (spc:5972). + test( + 'a route that becomes a page before the client loaded the route tree is in the client route tree', + async () => { + const server = await startDevServer({ + start: {}, + files: { + 'src/routes/api.tsx': api(handlers('x')), + 'src/router.ts': `import { routeTree } from './routeTree.gen' +export const getRouter = () => ({ routeTree }) +`, + }, + }) + await server.edit({ + 'src/routes/api.tsx': api(`${handlers('x')}, ${component}`), + }) + expect(Object.keys(await server.load('/src/routeTree.gen.ts'))).toContain( + '/src/routes/api.tsx', + ) + }, + timeout, + ) +}) + +describe('Vite dev: route HMR', () => { + // Start's client code splitting adds route HMR, so an edited route module + // updates the live route in place (with the framework's refresh plugin, + // which this harness does not mount, the edit is then a hot update). Like + // route-hmr.test.ts "route HMR in a Vite dev server ...", this checks that + // the served client route module takes part in HMR; the server's does not + // need to. + // Source: mutation round 3 start-router I/J (spc:6046). + test( + 'the client route module of a split page route takes part in HMR', + async () => { + const server = await startDevServer({ + start: {}, + files: { + 'src/routes/page.tsx': `${head}export const Route = createFileRoute('/page')({ component: () => 'MARK_PAGE' })\n`, + }, + }) + expect(await server.transform('/src/routes/page.tsx')).toMatch( + /\bimport\.meta\.hot\b/, + ) + }, + timeout, + ) }) From 27546d9520787092d3270c377b8f0f69423cde94 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:50:23 +0200 Subject: [PATCH 0313/1425] test(start-plugin-core): Rsbuild build --watch rebuild fails on a violation again In watch mode, a rebuild for an unrelated edit still fails on a violation the first build reported (the 'once' dedupe state of a build does not carry over to the next build). buildStart gains an additive Rsbuild-only watchEdits option: build in watch mode, apply edits after the first build, describe the rebuild (bounded waits). Kills: spc:7680, spc:7685 Co-Authored-By: Claude Opus 5.5 --- .../start-plugin-core/tests/build-harness.ts | 89 ++++++++++++++++++- .../tests/rsbuild/import-protection.test.ts | 28 +++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index 156d52459a0..69163fa0da0 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -24,6 +24,7 @@ * Import protection is on, with Start's defaults unless the test configures * it; it checks the modules of the source directory (`src`) only. */ +import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -250,6 +251,12 @@ export interface StartBuildOptions { builds?: number /** Rsbuild configuration merged over the harness's (Rsbuild builds). */ rsbuildConfig?: RsbuildConfig + /** + * Rsbuild builds: builds in watch mode (`rsbuild build --watch`), applies + * these app file edits (`null` deletes a file) once the first build is + * done, and describes the rebuild they cause (its errors reject). + */ + watchEdits?: Record } export interface StartBuild { @@ -337,6 +344,7 @@ export async function buildStart( start: options.start ?? {}, compilerTransforms: options.compilerTransforms, rsbuildConfig: options.rsbuildConfig, + watchEdits: options.watchEdits, } const warnings = { client: [], ssr: [] } as Record> const consoleMessages: Array = [] @@ -382,6 +390,7 @@ interface BuildTarget { start: TanStackStartInputConfig compilerTransforms: Array | undefined rsbuildConfig: RsbuildConfig | undefined + watchEdits?: Record } type Outputs = Record }> @@ -490,10 +499,15 @@ async function buildWithRsbuild( const { createRsbuild, mergeRsbuildConfig } = await import('@rsbuild/core') const outputs: Partial = {} const errors: Array = [] + /** Resolves when the current (watch mode) build is done. */ + let built = deferred() /** Records what each environment emitted. */ const outputPlugin: RsbuildPlugin = { name: 'start-build-harness:outputs', setup(api) { + api.onAfterBuild(() => { + built.resolve() + }) api.onAfterEnvironmentCompile(({ stats, environment }) => { const env = environment.name as Env if (!stats) { @@ -549,7 +563,23 @@ async function buildWithRsbuild( target.rsbuildConfig ?? {}, ), }) - await rsbuild.build() + if (target.watchEdits) { + await watchRebuild(target.root, target.watchEdits, { + build: () => rsbuild.build({ watch: true }), + nextBuild: () => { + built = deferred() + return built.promise + }, + firstBuild: () => built.promise, + reset: () => { + errors.length = 0 + warnings.client.length = 0 + warnings.ssr.length = 0 + }, + }) + } else { + await rsbuild.build() + } } catch (error) { throw new BuildError( 'rspack', @@ -565,6 +595,63 @@ async function buildWithRsbuild( return checkOutputs(outputs) } +/** + * Starts a watch mode build (`build`), waits for its first build, applies + * `edits` to the app at `root`, waits for the rebuild they cause (at most + * 20 s) and stops watching. + */ +async function watchRebuild( + root: string, + edits: Record, + watcher: { + build: () => Promise<{ close: () => Promise } | undefined> + firstBuild: () => Promise + nextBuild: () => Promise + reset: () => void + }, +): Promise { + const firstBuild = watcher.firstBuild() + const result = await watcher.build() + try { + await withTimeout(firstBuild, 'the first watch mode build') + watcher.reset() + const rebuild = watcher.nextBuild() + for (const [file, code] of Object.entries(edits)) { + const filePath = path.join(root, file) + if (code === null) { + await fs.rm(filePath, { force: true }) + } else { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, code) + } + } + await withTimeout(rebuild, 'the watch mode rebuild of the edits') + } finally { + await result?.close() + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function withTimeout(promise: Promise, what: string): Promise { + let timer: ReturnType | undefined + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${what} did not finish within 20 s`)), + 20_000, + ) + }), + ]).finally(() => clearTimeout(timer)) +} + function checkOutputs(outputs: Partial): Outputs { if (!outputs.client || !outputs.ssr) { throw new Error( 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 110b82ae179..f41893791f2 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 { reportedViolations } from '../importProtection/start-apps' +import { buildStart } from '../build-harness' +import { reportedViolations, rejection } from '../importProtection/start-apps' import { runRsbuildClient, startRsbuildDevServer } from '../rsbuild-dev-server' import type { ViolationInfo } from '../../src/import-protection/trace' import type { ImportProtectionOptions } from '../../src/schema' @@ -244,6 +245,31 @@ console.log(secret(), other) }, 30_000) }) +// `rsbuild build --watch`: each build reports the violations it bundles, +// whatever an earlier build reported. +// Source: round 4 mutation testing (rsbuild host, the state of a previous +// build). +test('Rsbuild build --watch: a rebuild for an unrelated edit fails on the violation again', async () => { + const error = await rejection( + buildStart({ + bundler: 'rsbuild', + files: { + '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' +export const read = () => secret() + other +`, + }, + entry: 'src/entry.ts', + watchEdits: { 'src/other.ts': `export const other = 2\n` }, + }), + ) + expect(reportedViolations(error.message)).toEqual([ + { environment: 'client', importer: 'src/entry.ts:3:27' }, + ]) +}, 60_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({ From f05b32f099eb58a8964d042226763f028430a801 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:54:08 +0200 Subject: [PATCH 0314/1425] test(start-plugin-core): Rsbuild build locates imports source.transformImport rewrote; pin two bugs - A denied import that Rsbuild's source.transformImport rewrote (ui-lib -> ui-lib/button) is reported on the line of its use: the location is only found in the rewritten code, through the mock module's path. - Pin (NEW, low): the report points at src/entry.ts:2:16 instead of the use at 2:27. - Pin (NEW): a specifier denied as written ('ui-lib') is not denied once transformImport rewrote it; the build succeeds without any report (the loader matches the post-SWC specifiers). Kills: spc:7425, spc:7426, spc:7432, spc:7817 Co-Authored-By: Claude Opus 5.5 --- .../tests/rsbuild/import-protection.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) 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 f41893791f2..4e2274531aa 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -245,6 +245,100 @@ console.log(secret(), other) }, 30_000) }) +// Rsbuild's `source.transformImport` rewrites a library's named imports +// into imports of its submodules (`import { button } from 'ui-lib'` -> +// `import { button } from 'ui-lib/button'`) before import protection sees +// the module. +// Source: round 4 mutation testing (rsbuild host, the location of a +// rewritten import). +describe('Rsbuild build: an import source.transformImport rewrites', () => { + /** Builds an entry using `button` of `ui-lib`, denying `specifiers`. */ + const build = (specifiers: Array) => + buildStart({ + bundler: 'rsbuild', + files: { + 'node_modules/ui-lib/package.json': JSON.stringify({ + name: 'ui-lib', + type: 'module', + exports: { '.': './index.js', './*': './*.js' }, + }), + 'node_modules/ui-lib/index.js': `export { button } from './button.js'\n`, + 'node_modules/ui-lib/button.js': `export const button = () => 'MARK_SECRET'\n`, + 'src/entry.ts': `import { button } from 'ui-lib' +export const read = () => button() +`, + }, + entry: 'src/entry.ts', + start: { importProtection: { client: { specifiers } } }, + rsbuildConfig: { + source: { + transformImport: [ + { + libraryName: 'ui-lib', + customName: 'ui-lib/{{ member }}', + transformToDefaultImport: false, + }, + ], + }, + }, + }) + let rewrittenDenied: Promise | undefined + /** The build error when the rewritten specifier is denied, built once. */ + const rewrittenDeniedError = () => + (rewrittenDenied ??= rejection(build([/^ui-lib\//]))) + + test('denied as rewritten, it is reported on the line of its use', async () => { + expect(reportedViolations((await rewrittenDeniedError()).message)).toEqual([ + { + environment: 'client', + importer: expect.stringMatching(/^src\/entry\.ts:2:\d+$/), + }, + ]) + }, 60_000) + + // Bug: the report points at `read` (src/entry.ts:2:16), not at the use of + // the import (`button()`, 2:27), which is what other violations point at. + // The location is only found in the rewritten code (through the mock + // module's path), whose positions do not map back to the use (a build + // without source maps). + // Source: rsbuild/import-protection.ts resolveImporterLocation + // (transformedSources lookup). + // Impact: low; the code frame's caret points at the wrong column. + test.fails( + 'denied as rewritten, it is reported at its use', + async () => { + expect( + reportedViolations((await rewrittenDeniedError()).message), + ).toEqual([{ environment: 'client', importer: 'src/entry.ts:2:27' }]) + }, + 60_000, + ) + + // Bug: a specifier denied as the user wrote it is not denied once + // `source.transformImport` rewrote it: the loader matches the specifiers + // of the code SWC already transformed (`ui-lib/button`), so `'ui-lib'` + // never matches, and the build ships the library to the client without + // any error or warning. + // Source: rsbuild/import-protection-loader.ts transformImportProtection + // (`matchesAny(source, matchers.specifiers)` on the post-SWC imports). + // Impact: a client.specifiers rule on a library the app also lists in + // source.transformImport is silently bypassed. + test.fails( + 'denied as written, the build fails on it', + async () => { + expect( + reportedViolations((await rejection(build(['ui-lib']))).message), + ).toEqual([ + { + environment: 'client', + importer: expect.stringMatching(/^src\/entry\.ts/), + }, + ]) + }, + 60_000, + ) +}) + // `rsbuild build --watch`: each build reports the violations it bundles, // whatever an earlier build reported. // Source: round 4 mutation testing (rsbuild host, the state of a previous From ef5558a5df8b0b39bf253196c6a42916134ed482 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:59:33 +0200 Subject: [PATCH 0315/1425] test(router-generator): a Vue component piece keeps its route's children A `posts.component.vue` piece next to `posts.ts` must not take over the children of `/posts`: `/posts/$postId` and `/posts/$postId/edit` stay in the route tree under `/posts`, and the piece becomes the lazy `component` of `/posts`. The existing Vue piece test had a component piece only on routes without children. Kills: rg:2113, rg:2115 Co-Authored-By: Claude Opus 5.5 --- .../tests/route-pieces.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/router-generator/tests/route-pieces.test.ts b/packages/router-generator/tests/route-pieces.test.ts index c4c1b84f40c..0489a55cfa7 100644 --- a/packages/router-generator/tests/route-pieces.test.ts +++ b/packages/router-generator/tests/route-pieces.test.ts @@ -105,6 +105,31 @@ describe('Vue piece files', () => { }) }) + test('a component piece of a route with child routes leaves the children under the route', async () => { + const root = await createApp({ + 'src/routes/__root.ts': rootRoute('vue'), + 'src/routes/posts.ts': fileRoute('/posts', '{}', 'vue'), + 'src/routes/posts.component.vue': sfc('posts'), + 'src/routes/posts.$postId.ts': fileRoute('/posts/$postId', '{}', 'vue'), + 'src/routes/posts.$postId.edit.ts': fileRoute( + '/posts/$postId/edit', + '{}', + 'vue', + ), + }) + const { generatedRouteTree } = await generate(root, { target: 'vue' }) + + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.ts', + '/posts': 'routes/posts.ts', + '/posts/$postId': 'routes/posts.$postId.ts', + '/posts/$postId/edit': 'routes/posts.$postId.edit.ts', + }) + expect((await routeOptionsOf(generatedRouteTree))['/posts']).toEqual({ + component: lazyComponent('posts.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 From 361f4b631b08b61006782f3a3705eb0135576868 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 18:59:33 +0200 Subject: [PATCH 0316/1425] test(router-generator): route paths, conflicts, tokens and params edge cases Behaviours pinned through the evaluated route tree and the generator's errors and warnings: - virtual route paths and physical() prefixes without a leading slash (`docs/intro`, `api/v1`) get the same URLs as with one; - virtual route files written with Windows separators (`docs\\intro.tsx`, as `path.join` gives on Windows) are imported; - the "Conflicting configuration paths" and 'Invalid route path ""' errors name the offending route files; - a route file name with a space gets a route (with a valid route tree); - an indexToken regex with the `i` flag (JSON or RegExp) matches index files whatever their case; - a token regex that does not compile fails the generator with an error naming the pattern (instead of a TypeError later); - no param warning for a route without `$` or with a `$` inside a static segment (`usd-$`); - `FileRoutesByFullPath['/']` is the index route, not a pathless layout defined only by a `.lazy.tsx` file. Kills: rg:2236, rg:2501, rg:3002, rg:3022, rg:2528, rg:2658, rg:2712, rg:3200, rg:3168, rg:2886, rg:2888, rg:2895, rg:2932, rg:2934, rg:2937, rg:2938 Co-Authored-By: Claude Opus 5.5 --- .../tests/route-paths.test.ts | 215 +++++++++++++++--- 1 file changed, 178 insertions(+), 37 deletions(-) diff --git a/packages/router-generator/tests/route-paths.test.ts b/packages/router-generator/tests/route-paths.test.ts index 46fb28958e5..eecc1e7f5f4 100644 --- a/packages/router-generator/tests/route-paths.test.ts +++ b/packages/router-generator/tests/route-paths.test.ts @@ -5,6 +5,7 @@ * evaluated route tree. */ import { readFile } from 'node:fs/promises' +import path from 'node:path' import { describe, expect, test, vi } from 'vitest' import { index, @@ -14,7 +15,7 @@ import { route as virtualRoute, } from '@tanstack/virtual-file-routes' import { parseSync } from 'vite' -import { createApp, fileRoute, generate, rootRoute } from './app' +import { createApp, fileRoute, generate, lazyFileRoute, rootRoute } from './app' import { routesOf, urlsOf } from './route-tree' import type { Config } from '../src' @@ -146,6 +147,47 @@ describe('underscores', () => { }) }) +describe('virtual route paths', () => { + test('virtual route paths and physical mount prefixes without a leading slash get the same URLs', async () => { + const root = await appWithRoutes(['x.tsx', 'dir/index.tsx']) + const { generatedRouteTree } = await generate(root, { + virtualRouteConfig: virtualRootRoute('__root.tsx', [ + virtualRoute('docs/intro', 'x.tsx'), + physical('api/v1', 'dir'), + ]), + }) + + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/docs/intro': 'routes/x.tsx', + '/api/v1/': 'routes/dir/index.tsx', + }) + expect(await urlsOf(generatedRouteTree)).toEqual({ + __root__: '/', + '/docs/intro': '/docs/intro', + '/api/v1/': '/api/v1/', + }) + }) + + // A virtual route config built with `path.join` on Windows has backslash + // separators in its file paths. + test('virtual route files given with Windows path separators are imported', async () => { + const root = await appWithRoutes(['docs/intro.tsx', 'docs/index.tsx']) + const { generatedRouteTree } = await generate(root, { + virtualRouteConfig: virtualRootRoute('__root.tsx', [ + virtualRoute('/intro', 'docs\\intro.tsx'), + index('docs\\index.tsx'), + ]), + }) + + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/intro': 'routes/docs/intro.tsx', + '/': 'routes/docs/index.tsx', + }) + }) +}) + describe('layouts and groups', () => { test('a route group layout adds no path segment', async () => { const root = await appWithRoutes([ @@ -203,6 +245,39 @@ describe('layouts and groups', () => { }) }) +describe('conflicting routes', () => { + test('routes with the same URL fail the generator with an error naming both files', async () => { + const root = await appWithRoutes(['index.tsx', '_auth/index.tsx']) + + const error = await generate(root).then( + () => undefined, + (e: unknown) => e as Error, + ) + expect(error?.message).toContain('Conflicting configuration paths') + expect(error?.message).toContain(path.join(root, 'src/routes/index.tsx')) + expect(error?.message).toContain( + path.join(root, 'src/routes/_auth/index.tsx'), + ) + }) + + test('a route file that resolves to an empty path fails the generator with an error naming it', async () => { + const root = await appWithRoutes(['merged/route.tsx']) + + const error = await generate(root, { + virtualRouteConfig: virtualRootRoute('__root.tsx', [ + physical('', 'merged'), + ]), + }).then( + () => undefined, + (e: unknown) => e as Error, + ) + expect(error?.message).toContain('Invalid route path ""') + expect(error?.message).toContain( + path.join(root, 'src/routes/merged/route.tsx'), + ) + }) +}) + 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']) @@ -215,6 +290,16 @@ describe('route names', () => { }) }) + test('a route file name with a space gets a route with the space in its URL', async () => { + const root = await appWithRoutes(['about us.tsx']) + const { generatedRouteTree } = await generate(root) + + expect(await urlsOf(generatedRouteTree)).toEqual({ + __root__: '/', + '/about us': '/about us', + }) + }) + // Control: the test above (same harness, two names the route tree tells // apart). @@ -289,6 +374,34 @@ describe('token options', () => { '/c/': 'routes/c/home.tsx', }) }) + + test.each([ + { + name: 'a JSON regex with the i flag', + indexToken: { regex: 'home', flags: 'i' }, + }, + { name: 'a RegExp with the i flag', indexToken: /home/i }, + ] satisfies Array & { name: string }>)( + 'an indexToken that is $name matches index files whatever their case', + async ({ name: _name, ...config }) => { + const root = await appWithRoutes(['a/Home.tsx', 'b/HOME.tsx']) + const { generatedRouteTree } = await generate(root, config) + + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/a/': 'routes/a/Home.tsx', + '/b/': 'routes/b/HOME.tsx', + }) + }, + ) + + test('a token regex that does not compile fails the generator with an error naming it', async () => { + const root = await appWithRoutes(['index.tsx']) + + await expect( + generate(root, { indexToken: { regex: 'home(unclosed' } }), + ).rejects.toThrow('home(unclosed') + }) }) describe('param names', () => { @@ -300,6 +413,9 @@ describe('param names', () => { 'files/prefix{$}.tsx', 'opt/{-$}.tsx', 'users/user-{$user-name}.tsx', + // No params: a route without `$`, and a `$` inside a static segment + 'about.tsx', + 'prices/usd-$.tsx', ]) try { await generate(root, { disableLogging: false }) @@ -319,42 +435,8 @@ 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], - ]), - ) + function routeIdsByTo(treeFile: string) { + return routeIdsByKey(treeFile, 'FileRoutesByTo') } test('`to` of an index route is the index route, not a route under pathless layouts with the same path', async () => { @@ -380,3 +462,62 @@ describe('FileRoutesByTo', () => { expect((await routeIdsByTo(generatedRouteTree))['/foo']).toBe('/foo') }) }) + +describe('FileRoutesByFullPath', () => { + test('the full path `/` is the index route, not a pathless layout with only a lazy file', async () => { + const root = await createApp({ + 'src/routes/__root.tsx': rootRoute(), + 'src/routes/index.tsx': fileRoute('/'), + 'src/routes/_auth.lazy.tsx': lazyFileRoute('/_auth'), + 'src/routes/_auth/login.tsx': fileRoute('/_auth/login'), + }) + const { generatedRouteTree } = await generate(root) + + expect( + (await routeIdsByKey(generatedRouteTree, 'FileRoutesByFullPath'))['/'], + ).toBe('/') + }) +}) + +/** + * The route each key of the route tree's `FileRoutesByTo` or + * `FileRoutesByFullPath` interface stands for, by route id (from + * `FileRoutesById`). + */ +async function routeIdsByKey(treeFile: string, interfaceName: 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(interfaceName)).map(([key, variable]) => [ + key, + idsByVariable[variable as string], + ]), + ) +} From 80444e66537a8a08ac369a1ad86a20e5dd940513 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:02:13 +0200 Subject: [PATCH 0317/1425] test(start-plugin-core): watch rebuild test tolerates repeated client compiles Rspack may compile the client more than once for the edit under load (seen once in a full-suite run); assert each distinct violation. Kills: spc:7680, spc:7685 (re-verified) Co-Authored-By: Claude Opus 5.5 --- .../tests/rsbuild/import-protection.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 4e2274531aa..5f72842fe41 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -3,7 +3,11 @@ import { describe, expect, test } from 'vitest' import { compileMatchers } from '../../src/import-protection/matchers' import { getRsbuildResolvedImportProtectionCheck } from '../../src/rsbuild/import-protection' import { buildStart } from '../build-harness' -import { reportedViolations, rejection } from '../importProtection/start-apps' +import { + distinct, + rejection, + reportedViolations, +} from '../importProtection/start-apps' import { runRsbuildClient, startRsbuildDevServer } from '../rsbuild-dev-server' import type { ViolationInfo } from '../../src/import-protection/trace' import type { ImportProtectionOptions } from '../../src/schema' @@ -359,9 +363,14 @@ export const read = () => secret() + other watchEdits: { 'src/other.ts': `export const other = 2\n` }, }), ) - expect(reportedViolations(error.message)).toEqual([ - { environment: 'client', importer: 'src/entry.ts:3:27' }, - ]) + // Rspack may compile the client more than once for the edit. + expect( + distinct( + reportedViolations(error.message).map( + ({ environment, importer }) => `${environment} ${importer}`, + ), + ), + ).toEqual(['client src/entry.ts:3:27']) }, 60_000) // Source: mutation run R3 ip-rsbuild R7 and ip-misc P10. From 5b9853992cb0d692f8a2411e98da1391d86af774 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:04:19 +0200 Subject: [PATCH 0318/1425] test(start-plugin-core): Rsbuild dev keeps compiling after a server fn module is deleted Adds startRsbuildStartDevServer (additive): an Rsbuild dev server with Start's whole Rsbuild plugin (client and SSR, the generated server fn resolver). Deleting a module that defines a server fn must drop it from the resolver; otherwise the next compile never completes. Kills: spc:6802 Survive (no observable difference found): spc:6980-6983 (invalidating deleted files in the compiler cache; a re-created file is reported as modified and invalidated then). Co-Authored-By: Claude Opus 5.5 --- .../tests/rsbuild-dev-server.ts | 50 +++++++++++++++++++ .../tests/rsbuild/dev-server.test.ts | 39 ++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/start-plugin-core/tests/rsbuild-dev-server.ts b/packages/start-plugin-core/tests/rsbuild-dev-server.ts index 573db9bfbca..68c081799f6 100644 --- a/packages/start-plugin-core/tests/rsbuild-dev-server.ts +++ b/packages/start-plugin-core/tests/rsbuild-dev-server.ts @@ -15,6 +15,7 @@ import { createRsbuildDevServer } from '@tanstack/compiler-test-utils/rsbuild-de import { onTestFinished } from 'vitest' import { registerImportProtection } from '../src/rsbuild/import-protection' import { registerStartCompilerTransforms } from '../src/rsbuild/start-compiler-host' +import { tanStackStartRsbuild } from '../src/rsbuild/plugin' import { startRuntimeFiles } from './build-harness' import { testLoaderFiles, useTestLoaders } from './rsbuild-test-loaders' import type { ImportProtectionOptions } from '../src/schema' @@ -77,6 +78,55 @@ export function startRsbuildDevServer( }) } +/** + * Starts an Rsbuild dev server with Start's whole Rsbuild plugin + * (`tanStackStartRsbuild`, with the options `@tanstack/react-start` passes): + * client and SSR environments, SSR as the server fn provider, and the + * generated server fn resolver. Both entries import `src/entry.ts`; the + * server entry also imports the server fn resolver, as Start's server + * handler does. `src/router.ts` and `src/routes/__root.tsx` are stubs unless + * the app has them. + */ +export function startRsbuildStartDevServer( + options: Omit, +): Promise { + const { files, ...devServerOptions } = options + return createRsbuildDevServer({ + dir: harnessDir, + prefix: '.start-rsbuild-start-dev-server-', + registerCleanup: onTestFinished, + ...devServerOptions, + files: { + ...startRuntimeFiles, + ...testLoaderFiles, + 'harness/client-entry.ts': `import '../src/entry'\n`, + 'harness/server-entry.ts': `import '../src/entry' +import { getServerFnById } from '#tanstack-start-server-fn-resolver' +export default { getServerFnById } +`, + '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() +`, + ...files, + }, + plugins: ({ root }) => [ + tanStackStartRsbuild({ + 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, + }), + useTestLoaders(root), + ], + }) +} + /** * 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 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 01332f8f41e..b761e6d106c 100644 --- a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts @@ -4,7 +4,10 @@ */ import vm from 'node:vm' import { describe, expect, test } from 'vitest' -import { startRsbuildDevServer } from '../rsbuild-dev-server' +import { + startRsbuildDevServer, + startRsbuildStartDevServer, +} from '../rsbuild-dev-server' const timeout = 30_000 @@ -72,6 +75,40 @@ console.log(fn1, fn2) ) }) +describe('Rsbuild dev: removing a server fn module', () => { + // The generated server fn resolver lists every server fn by id; a module + // the app no longer has must leave it, or the next compile of the server + // never completes. + // Source: mutation round 3 sc-hosts 10 (spc:6802), with Start's whole + // Rsbuild plugin. + test( + 'deleting a module with a server fn keeps the dev server compiling', + async () => { + const fn = ( + name: string, + ) => `import { createServerFn } from '@tanstack/react-start' +export const ${name} = createServerFn().handler(async () => 'MARK_${name}') +` + const server = await startRsbuildStartDevServer({ + compileTimeout: 10_000, + files: { + 'src/a.ts': fn('a'), + 'src/b.ts': fn('b'), + 'src/entry.ts': `export { a } from './a'\nexport { b } from './b'\n`, + }, + }) + await server.edit({ + 'src/b.ts': null, + 'src/entry.ts': `export { a } from './a'\n`, + }) + expect(server.errors).toEqual([]) + const ssr = Object.values(await server.outputs('ssr')).join('\n') + expect(ssr).toContain('MARK_a') + }, + 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. From 7a03b4c8c99bd2991ee0ddf3daea22ebbea522c0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:08:12 +0200 Subject: [PATCH 0319/1425] test(start-plugin-core): type the Rsbuild dev behavior and mockAccess rows ImportProtectionOptions includes undefined; index its NonNullable. Co-Authored-By: Claude Opus 5.5 --- .../start-plugin-core/tests/rsbuild/import-protection.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5f72842fe41..c907b7c78b0 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -120,7 +120,7 @@ console.log(page()) // Source: round 4 mutation testing (rsbuild host, `behavior` in dev). describe('Rsbuild dev: behavior', () => { test.each<{ - behavior: ImportProtectionOptions['behavior'] + behavior: NonNullable['behavior'] reportedAs: 'warnings' | 'errors' infoBehavior: ViolationInfo['behavior'] }>([ @@ -412,7 +412,7 @@ console.log(a(), b(), query()) // Source: round 4 mutation testing (rsbuild host, `mockAccess`). describe('Rsbuild dev: mockAccess', () => { test.each<{ - mockAccess: ImportProtectionOptions['mockAccess'] + mockAccess: NonNullable['mockAccess'] reports: Array }>([ { mockAccess: undefined, reports: ['error'] }, From b335e17a37ddd20ade2e3c7a4b9d293c42cc0ba5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:24:03 +0200 Subject: [PATCH 0320/1425] test: fix corpus defects and weak pins found by the #8504 probe A1 delete the invalid TS2300 "type re-export and value re-export" row. A2 duplicate Route (TS2440): build fails naming Route, or the page renders. A3 display-name: placement control kept; goal pin added (lazy and renders the property; main: entry throws "Page is not defined"). A4 merged namespace row reads Value.one (old input invalid without splitter). A5 @jsxFrag row really uses a fragment. A6 comment fixes (KB private call result, KB object-form handler, route-hmr lowercase pendingComponent, start-compiler renamed re-export, hydrate namespace hook). B1-B5 counted write row; register/registry counts read after the chunk loads; split-or-reported for export list/alias/specifier/class rows; library server fn refusal uses misplacedServerFnRejection. Co-Authored-By: Claude Opus 5.5 --- .../code-splitter-imports-exports.test.ts | 5 - .../code-splitter-plugin-pipeline.test.ts | 47 +++- .../tests/known-bugs-code-splitter.test.ts | 227 ++++++++++++++---- .../tests/known-bugs-route-hmr.test.ts | 10 +- .../tests/hydrate-boundary-forms.test.ts | 7 +- .../tests/known-bugs-start-compiler.test.ts | 12 +- 6 files changed, 233 insertions(+), 75 deletions(-) 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 11b42fca20d..a4c8a3fb099 100644 --- a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts +++ b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts @@ -831,11 +831,6 @@ function Post(id: string): Post { } export { Post }`, }, - { - name: 'a type re-export and a value re-export', - exports: `export type { Post } from './post-types' -export { Post } from './post-values'`, - }, ])( 'exports a type and a value with the same name: $name', async ({ exports }) => { 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 32408179004..e8743a4f863 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -1,4 +1,6 @@ +import { BuildError } from '@tanstack/compiler-test-utils/build-harness' import { describe, expect, it, vi } from 'vitest' +import { build, routeApp } from './build-harness' import { compileRouteModules, createCodeSplitterTransforms, @@ -438,16 +440,43 @@ export const Route = createFileRoute('/')({}) }, ) -// An invalid route file (a `Route` both imported and declared) fails the -// build. Source: the #8504 confidence campaign (DX-07). -it('refuses a route file that imports and declares Route', () => { - expect(() => - compileRouteModules(`import { createFileRoute } from '@tanstack/react-router' -import { Route } from './other' +// A route file that both imports and declares `Route` is invalid TypeScript +// (TS2440). A real build either fails with an error naming `Route`, or +// builds a page that renders; it never builds a page that breaks. Source: +// the #8504 confidence campaign (DX-07). +it.each(['vite', 'rspack'] as const)( + 'a route file that imports and declares Route fails its build naming Route, or renders (%s)', + async (bundler) => { + let outcome: string + try { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `import { createFileRoute } from '@tanstack/react-router' +import { Route } from '../other' export const Route = createFileRoute('/')({ component: () =>

    page

    }) -`), - ).toThrow(/\bRoute\b/) -}) +`, + { env: 'client', files: { 'other.ts': 'export const Route = 1\n' } }, + ), + }) + outcome = ( + await app.run(`await entry.mount(entry.Route.options.component)`) + ).html + } catch (error) { + if (error instanceof BuildError && /\bRoute\b/.test(error.message)) { + outcome = 'refused naming Route' + } else { + throw error + } + } + expect([ + 'refused naming Route', + '

    page

    ', + ]).toContain(outcome) + }, + 60_000, +) describe('route HMR plugin without code splitting', () => { function transformWith( 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 3d859231a81..1d64269139c 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -33,6 +33,21 @@ import type { TargetFramework } from './regression-helpers' */ const clientBundlers: Array = ['vite', 'rspack', 'webpack'] +/** + * Whether a `console.warn` spy saw a warning that names the route file + * `file` and the split component (`Page` or `component`): the user is told + * the component is not code-split. + */ +function reportsNotSplit( + warn: { mock: { calls: Array> } }, + file: string, +) { + return warn.mock.calls.some(([message]) => { + const text = String(message) + return text.includes(file) && /\b(?:Page|component)\b/.test(text) + }) +} + /** Stubs `./state`, counting the calls of `init` and `register`. */ function stateStub() { const state = { @@ -154,10 +169,12 @@ const read = () => Value.one`, read: 'Value.one', }, { - // Source: #8504 (a namespace merged across two declarations). + // Source: #8504 (a namespace merged across two declarations). The + // second block reads `Value.one`: TypeScript transforms (Oxc, Babel) + // do not resolve a bare `one` across merged blocks. name: 'a namespace declared in two blocks', declaration: `namespace Value { export const one = init(1) } -namespace Value { export const two = one }`, +namespace Value { export const two = Value.one }`, read: 'Value.two', }, ])( @@ -292,6 +309,18 @@ Object.assign(State, { extra: 7 })`, component: '() =>

    {(State as any).extra}

    ', expected: ['data', '

    7

    '], })), + { + // The write has a side effect: it must run once, not once per module + // that holds a copy of the enum and its write. + name: 'an enum only the split component reads (Object.assign of a created value)', + setup: `import { init } from './state' +enum State { Zero = 0 } +Object.assign(State, { extra: init(7) })`, + loader: `'data'`, + component: '() =>

    {(State as any).extra}

    ', + expected: ['data', '

    7

    '], + inits: 1, + }, { name: 'a dotted namespace only the split component reads (property assignment)', setup: `namespace Labels.Page { @@ -411,9 +440,9 @@ const key = 'staticData'`, render: '{store.count}', html: '

    1

    ', }, - // Rows from #8504, where they pass. A type that reads a value is no read - // at runtime (TypeScript erases it), but the reference module keeps the - // declaration for it. + // Rows from #8504, where they pass (except the private call result row + // below). A type that reads a value is no read at runtime (TypeScript + // erases it), but the reference module keeps the declaration for it. { name: 'a type alias that reads it', // Source: corpus-diff, route files that derive a type with @@ -425,8 +454,10 @@ type Value = typeof value`, }, { // A second initialization in the chunk is another instance than the - // one other modules import. Source: #8504 - // (code-splitter-imports-exports.test.ts). + // one other modules import. Importing `cache` from the route module is + // not enough: a chunk that declares the private `initial` again runs + // `init` twice. Source: #8504 (code-splitter-imports-exports.test.ts, + // which checks `cache` only). name: 'an export built from a private call result the chunk reads too', declaration: `const initial = init([['a', 1]]) export const cache = new Map(initial)`, @@ -936,7 +967,9 @@ export async function load() { // module is our limitation, not a pattern the user can change (decision // D11). Only the handler can move; the router reads the other keys // (`staleReloadMode`) from the route options. Today the compiler throws - // first (same root cause as above). + // first (same root cause as above). A compiler that builds these routes + // but keeps a method handler, or a handler next to a spread, in the route + // module on purpose still has this bug. // Impact: the loader's code loads with the entry chunk, or the route // cannot be built. test.fails.each([ @@ -1574,14 +1607,16 @@ import { h } from 'preact'`, import { h, Fragment } from 'preact'`, module: 'component', unused: 'Fragment', + // The split loader renders a fragment, so the file uses `Fragment`. + loader: '<>{1}', }, ])( 'a module without JSX does not import the classic factory ($name)', - async ({ header, module, unused }) => { + async ({ header, module, unused, loader = "'data'" }) => { const route = await loadRoute( `${header} ${head}export const Route = createFileRoute('/')({ - loader: () => 'data', + loader: () => ${loader}, component: () =>

    page

    , }) `, @@ -2544,6 +2579,16 @@ describe('page code in client builds', () => { const component = `export const Route = createFileRoute('$PATH')({ component: Page })` + const displayName = routeCase( + 'a component the route file writes a property of', + 'display-name', + `function Page() { + return
    {'$MARK'}{Page.displayName}
    +} +Page.displayName = 'PageDisplay' +${component}`, + ) + // Page code that loads with its chunk: code the route file does not // export, and exported functions and classes (today the chunk declares its // own copy, whose identity and state are pinned in "module state shared @@ -2680,15 +2725,8 @@ export const Route = createFileRoute('$PATH')({ component: () =>

    {label('page')}

    , })`, ), - routeCase( - 'a component the route file writes a property of', - 'display-name', - `function Page() { - return
    {'$MARK'}{Page.displayName}
    -} -Page.displayName = 'PageDisplay' -${component}`, - ), + // Its placement only; that it renders the property is the goal below. + displayName, ] // Bug: page code the route file exports as a variable, and an exported @@ -2876,8 +2914,20 @@ export const Route = createFileRoute('/imports-exported')({ component: Page }) ...Object.fromEntries( cases.map((row) => [`routes/${row.file}.tsx`, row.route]), ), - 'entry.ts': `import { routeTree } from './routeTree.gen' + 'entry.ts': `import { createElement } from 'react' +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { routeTree } from './routeTree.gen' export { routeTree } +/** Mounts the component of the route at \`path\` into #root. */ +export async function mount(path: string) { + const route = (routeTree.children as Array).find( + (child) => child.options.path === path, + ) + await route.options.component.preload?.() + const root = createRoot(document.getElementById('root')!) + flushSync(() => root.render(createElement(route.options.component))) +} `, } @@ -2908,6 +2958,29 @@ export { routeTree } timeout, ) + // Bug: the code splitter moves a component into its chunk but leaves + // a top-level write to it (`Page.displayName = ...`) in the route + // module, where `Page` is no longer declared (same bug as "top-level + // writes to the split component (property assignment) stay with the + // binding they write"). Keeping a copy of the component and its write + // in the route module as well is no fix: the page code then loads + // with the entry chunk. The control above checks the placement only. + // Source: the #8504 probe (memos/splitter.md B6). + // Impact: the app's entry throws when it loads ("Page is not + // defined"). + test.fails( + `${displayName.name} loads with its chunk and renders the property`, + async () => { + const built = await app() + const { html } = await built.run(`await entry.mount('/display-name')`) + expect({ placement: built.where(displayName.marker), html }).toEqual({ + placement: 'lazy', + html: `
    ${displayName.marker}PageDisplay
    `, + }) + }, + timeout, + ) + test.fails.each([...exported, ...unsplit])( '$name loads with its chunk', async ({ marker: mark }) => { @@ -3265,25 +3338,37 @@ export const Route = createFileRoute('/')({ describe('code that reads a split binding', () => { /** - * Loads a route whose component is `Page`, declared next to `exports`, and - * returns what the route renders and what `read` gets from the route - * module's exports. + * Loads a route whose component is `Page`, declared next to `exports`, in + * a production compile, and returns what the route renders, what `read` + * gets from the route module's exports, and whether `Page` is split or + * the user is told it is not (a warning naming the route file and `Page` + * or `component`). */ async function renderPage( exports: string, read: (moduleExports: Record) => unknown, ) { - const route = await loadRoute( - `${head}import { wrap } from './wrap' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const route = await loadRoute( + `${head}import { wrap } from './wrap' function Page() { return

    page

    } ${exports} export const Route = createFileRoute('/')({ component: Page }) `, - { stubs: { './wrap': { wrap: (component: unknown) => component } } }, - ) - return [(await route.load('component'))(), read(route.exports)] + { stubs: { './wrap': { wrap: (component: unknown) => component } } }, + ) + return { + rendered: [(await route.load('component'))(), read(route.exports)], + splitOrReported: + route.isLazy('component') || + reportsNotSplit(warn, route.ids.reference!), + } + } finally { + warn.mockRestore() + } } // Control for the pin below (same harness, an export that does not read @@ -3294,7 +3379,7 @@ export const Route = createFileRoute('/')({ component: Page }) `export const label = wrap('label')`, (moduleExports) => moduleExports.label, ), - ).toEqual(['

    page

    ', 'label']) + ).toEqual({ rendered: ['

    page

    ', 'label'], splitOrReported: true }) }) // Bug: when a route option names a binding that the code splitter moves @@ -3303,6 +3388,9 @@ export const Route = createFileRoute('/')({ component: Page }) // still reads it. Only `export function`/`export const` declarations are // checked, so export lists, aliases and exports that use the binding lose // it. Source: #8505 (`export { component as View }`), gap O7. + // A fix may also leave `Page` unsplit, but must then tell the user, in + // production builds too (decision D11): unsplit and silent is still this + // bug. // Impact: the route module throws when it loads ("Page is not defined", // "Export 'Page' is not defined"), so the route never renders. test.fails.each([ @@ -3340,33 +3428,37 @@ export const Route = createFileRoute('/')({ component: Page }) ])( 'a split component that $name reads renders from both', async ({ exports, read }) => { - expect(await renderPage(exports, read)).toEqual([ - '

    page

    ', - '

    page

    ', - ]) + expect(await renderPage(exports, read)).toEqual({ + rendered: ['

    page

    ', '

    page

    '], + splitOrReported: true, + }) }, ) // Bug: same root cause as above, for a function that module-level code // keeps reachable: it renders the split component, whose declaration the - // route module lost. Source: #8504 (code-splitter-dead-code.test.ts), where - // it passes. + // route module lost. The registry is created, filled and subscribed to + // once, also after the chunk loads (a chunk that runs that code again + // makes a second registry). Source: #8504 (code-splitter-dead-code.test.ts). // Impact: the route module throws when it loads ("Page is not defined"). test.fails.each([ { name: 'module-level code registers', setup: `registry.set('preview', Preview)`, + subscribes: 0, }, { name: 'an unused subscription calls back', setup: `const unsubscribe = subscribe((name) => registry.set(name, Preview))`, + subscribes: 1, }, ])( 'a split component renders through a function $name', - async ({ setup }) => { + async ({ setup, subscribes }) => { + const runs = { registries: 0, sets: 0, subscribes: 0 } const route = await loadRoute( - `${head}import { subscribe } from './subscribe' -const registry = new Map() + `${head}import { createRegistry, subscribe } from './subscribe' +const registry = createRegistry() function Page() { return

    page

    } @@ -3382,16 +3474,32 @@ export const Route = createFileRoute('/')({ { stubs: { './subscribe': { - subscribe: (callback: (name: string) => void) => - callback('preview'), + createRegistry: () => { + runs.registries++ + const map = new Map() + return { + get: (name: string) => map.get(name), + set: (name: string, value: unknown) => { + runs.sets++ + map.set(name, value) + }, + } + }, + subscribe: (callback: (name: string) => void) => { + runs.subscribes++ + callback('preview') + }, }, }, }, ) - expect([ - route.options.loader(), - (await route.load('component'))(), - ]).toEqual(['

    page

    ', '

    page

    ']) + const loaded = route.options.loader() + const rendered = (await route.load('component'))() + expect([loaded, rendered, runs]).toEqual([ + '

    page

    ', + '

    page

    ', + { registries: 1, sets: 1, subscribes }, + ]) }, ) @@ -3421,7 +3529,9 @@ function Page() { ) // Bug: same root cause as above, for a top-level call that reads the split - // component. Source: #8504 (differential fuzzing), where it passes. + // component. The call runs once, also after the chunk loads (a chunk that + // calls `register` again registers a second component). Source: #8504 + // (differential fuzzing). // Impact: the route module throws when it loads ("Page is not defined"). test.fails( 'a split component that a top-level call reads renders from both', @@ -3445,10 +3555,11 @@ register(Page) }, }, ) - expect([ - registered.map((component) => component()), - (await route.load('component'))(), - ]).toEqual([['
    page
    '], '
    page
    ']) + const rendered = (await route.load('component'))() + expect([registered.map((component) => component()), rendered]).toEqual([ + ['
    page
    '], + '
    page
    ', + ]) }, ) @@ -3677,6 +3788,8 @@ export const Route = createFileRoute('/')({ component: Page }) // Bug: a route option that names a class cannot be split: the virtual // compiler throws "Unexpected splitNode type ☝️: ClassDeclaration". + // A fix may leave an exported class unsplit, but must then tell the user, + // in production builds too (decision D11). // Source: #8505 (`export class component {}`). // Impact: a route whose component is a class component cannot be built // once code splitting is on. @@ -3686,11 +3799,21 @@ export const Route = createFileRoute('/')({ component: Page }) ])( 'a class component declared in the route file renders (%s)', async (declaration) => { - const route = await loadRoute(`${head}${declaration} + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const route = await loadRoute(`${head}${declaration} export const Route = createFileRoute('/')({ component: Page }) `) - const Component = await route.load('component') - expect(new Component().render()).toBe('page') + const Component = await route.load('component') + expect({ + rendered: new Component().render(), + splitOrReported: + route.isLazy('component') || + reportsNotSplit(warn, route.ids.reference!), + }).toEqual({ rendered: 'page', splitOrReported: true }) + } finally { + warn.mockRestore() + } }, ) 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 b0f77c103fa..9d55c7c2d14 100644 --- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts @@ -376,11 +376,15 @@ export const Route = createFileRoute('/')({ ) // Bug: same root cause as the pin above, for a lowercase component that - // the split `component` and the unsplit `pendingComponent` share. + // the split `component` and the unsplit `pendingComponent` share. Both + // options must be registered: a lowercase `pendingComponent` that React + // Refresh never registers, or a renamed copy in the chunk (another + // function than the pendingComponent), is still this bug. // Impact: in development, the route's component chunk fails to load - // (SyntaxError: Export 'SplitComponent' is not defined). + // (SyntaxError: Export 'SplitComponent' is not defined), or the pending + // component loses its state on hot updates. // Source: #8504 react-refresh-route-components ("that is also the unsplit - // pendingComponent"), where it passes. + // pendingComponent"), which checks the chunk's registration only. test.fails( 'a lowercase function component that is also the unsplit pendingComponent is registered in both', async () => { 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 f35f518d3ad..c2df7f0fe91 100644 --- a/packages/start-plugin-core/tests/hydrate-boundary-forms.test.ts +++ b/packages/start-plugin-core/tests/hydrate-boundary-forms.test.ts @@ -629,9 +629,10 @@ export function Page() { // component. // Impact: the hook runs in another component on the client than on the // server (`useId` mismatches, hydration errors). - // Source: Qwik optimizer example_use_optimization (known-bugs-hydrate.test.ts; - // #8504 hydrate-split-modules.test.ts "an optional hook call in the - // children is rejected", red on main) + // Source: Qwik optimizer example_use_optimization (known-bugs-hydrate.test.ts); + // the optional-call row: #8504 hydrate-split-modules.test.ts "an optional + // hook call in the children is rejected" (red on main). #8504 has no test + // for the namespace row and still moves `React.useId()` into the chunk. test.fails.each([ { name: 'a hook called through a namespace', 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 a2baaa04c58..89d80472c0d 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 @@ -451,8 +451,11 @@ export const fn = iso.server(() => db.x()).client(() => 'client')`, }, { // Source: #8504 compiler.test.ts "resolves distinct re-export aliases - // without conflating their source bindings" (red on main with the - // detection the bundler plugins run) + // without conflating their source bindings". The module's text names + // no factory, so the text detection the bundler plugins run before + // compiling skips it and the module is left untransformed. #8504's + // test calls the compiler without that detection; with it, #8504 has + // the same bug. name: 'a renamed createServerOnlyFn re-export', files: { '/test/src/env.ts': `export { createServerOnlyFn as serverOnly } from '@tanstack/react-start'`, @@ -1869,7 +1872,10 @@ export const fn = createLoggedServerFn(async () => db.secret()) (error: unknown) => ({ error: String(error) }), ) if ('error' in outcome) { - expect(outcome.error).toMatch(/createServerFn/) + expect(outcome.error).toMatch(misplacedServerFnRejection) + expect(outcome.error).not.toMatch( + /Expected createServerFn declaration in a statement list/, + ) } else { expect(outcome).toEqual({ client: 'nowhere', From d7820f9ec12274ddb525a003d08e60efca36bb53 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:31:29 +0200 Subject: [PATCH 0321/1425] test: apply the 2026-10-17 rulings (dead-code references, Route readers, R-09, L3) - validate-module: free names inside module-level functions that are not exported and that nothing reads (transitively) are no module error; everything else stays strict. Controls for both sides. - dead-code:196 and the KB `as any` controls: the dead code (marker) is absent from production client builds on Vite, Rspack and webpack, and the page renders. - KB:2197 comment states the one-instance requirement. - R-09 route factories: split (and hot-updated keeping identity), or unsplit with a warning naming the file in every mode; the five factory pins accept the same two outcomes. hotUpdate returns the file id. - L3: a module imported only for a type specifier with verbatimModuleSyntax does not load in a Vite client build (with a control proving the tsconfig is in effect). Co-Authored-By: Claude Opus 5.5 --- .../src/validate-module.ts | 149 ++++++++++++++++-- .../tests/validate-module.test.ts | 95 +++++++++++ .../tests/code-splitter-dead-code.test.ts | 49 ++++-- .../code-splitter-imports-exports.test.ts | 62 ++++++++ .../code-splitter-plugin-pipeline.test.ts | 126 +++++++++++---- .../tests/known-bugs-code-splitter.test.ts | 44 +++++- .../router-plugin/tests/regression-helpers.ts | 2 + 7 files changed, 478 insertions(+), 49 deletions(-) diff --git a/packages/compiler-test-utils/src/validate-module.ts b/packages/compiler-test-utils/src/validate-module.ts index bf3e53beb26..51b1a05ba86 100644 --- a/packages/compiler-test-utils/src/validate-module.ts +++ b/packages/compiler-test-utils/src/validate-module.ts @@ -42,7 +42,7 @@ export async function getModuleErrors( return errors.map((error) => error.message) } const allowed = new Set(options.globals) - return [...(await freeIdentifiers(code, filename))] + return [...(await freeIdentifiers(code, filename, { deadCode: false }))] .filter((name) => !allowed.has(name)) .map((name) => `\`${name}\` is referenced but never declared or imported`) } @@ -52,10 +52,21 @@ export async function getModuleErrors( * known globals and names it probes with `typeof`, once TypeScript is erased * and JSX compiled with the project default (React's automatic runtime) or * the module's own JSX pragmas. + * + * `deadCode: false` leaves out names referenced only inside code that never + * runs (`getModuleErrors` does: such a reference throws nothing). Dead code + * is approximated statically as the module-level functions that are not + * exported and that no other code of the module reads, directly or through + * other such functions: a `function` declaration, or a `const`/`let`/`var` + * initialised with a function or arrow expression, whose name the module + * declares once. Reads by anything else (module-level code, exports, classes, + * `typeof`, assignments) keep a function live; anything undecidable stays + * live. */ export async function freeIdentifiers( code: string, filename = 'module.tsx', + options: { deadCode?: boolean } = {}, ): Promise> { const { code: javascript } = await transformWithOxc(code, filename, { jsx: { runtime: 'automatic' }, @@ -64,9 +75,14 @@ export async function freeIdentifiers( const { program } = parseSync('module.js', javascript, { sourceType: 'module', }) - const { references, probed } = analyzeScopes(program) + const { references, probed, isOnlyInDeadCode } = analyzeScopes(program) return new Set( - [...references].filter((name) => !probed.has(name) && !isKnownGlobal(name)), + [...references].filter( + (name) => + !probed.has(name) && + !isKnownGlobal(name) && + (options.deadCode !== false || !isOnlyInDeadCode(name)), + ), ) } @@ -290,18 +306,44 @@ function declareLexical(statements: Array, scope: Scope) { function analyzeScopes(program: ESTree.Program) { const references = new Set() const probed = new Set() + const moduleScope = createScope(undefined, true) + const body = program.body as unknown as Array + /** Module-level functions that may be dead (see `freeIdentifiers`). */ + const candidates = deadCodeCandidates(body) + /** The candidate whose body is being visited (`undefined`: other code). */ + let owner: string | undefined + /** Candidates each candidate (or other code, `undefined`) reads. */ + const reads = new Map>() + /** Where each free name is referenced. */ + const freeOwners = new Map>() - const reference = (name: string, scope: Scope) => { + /** Resolves `name` from `scope`; true when it is declared. */ + const resolve = (name: string, scope: Scope) => { for ( let current: Scope | undefined = scope; current; current = current.parent ) { if (current.names.has(name)) { - return + if (current === moduleScope && candidates.has(name)) { + const read = reads.get(owner) ?? new Set() + read.add(name) + reads.set(owner, read) + } + return true } } + return false + } + + const reference = (name: string, scope: Scope) => { + if (resolve(name, scope)) { + return + } references.add(name) + const owners = freeOwners.get(name) ?? new Set() + owners.add(owner) + freeOwners.set(name, owners) } /** Visits a binding pattern: only defaults and computed keys are read. */ @@ -541,6 +583,8 @@ function analyzeScopes(program: ESTree.Program) { node.argument.type === 'Identifier' ) { probed.add(node.argument.name as string) + // A probed function counts as read: it stays live. + resolve(node.argument.name as string, scope) return } visit(node.argument, scope) @@ -573,9 +617,96 @@ function analyzeScopes(program: ESTree.Program) { } } - const moduleScope = createScope(undefined, true) - const body = program.body as unknown as Array declareVars(body, moduleScope) - visitBody(body, moduleScope) - return { references, probed } + declareLexical(body, moduleScope) + for (const statement of body) { + if ( + statement.type === 'FunctionDeclaration' && + isNode(statement.id) && + candidates.has(statement.id.name as string) + ) { + owner = statement.id.name as string + visit(statement, moduleScope) + owner = undefined + } else if (statement.type === 'VariableDeclaration') { + for (const declarator of statement.declarations as Array) { + visitBinding(declarator.id, moduleScope) + const id = declarator.id as Node + owner = + id.type === 'Identifier' && candidates.has(id.name as string) + ? (id.name as string) + : undefined + visit(declarator.init, moduleScope) + owner = undefined + } + } else { + visit(statement, moduleScope) + } + } + + // Live candidates: read by other code, or by a live candidate. + const live = new Set() + const pending = [...(reads.get(undefined) ?? [])] + while (pending.length > 0) { + const name = pending.pop()! + if (!live.has(name)) { + live.add(name) + pending.push(...(reads.get(name) ?? [])) + } + } + const isOnlyInDeadCode = (name: string) => + [...(freeOwners.get(name) ?? [undefined])].every( + (where) => where !== undefined && !live.has(where), + ) + return { references, probed, isOnlyInDeadCode } +} + +/** Unwraps parentheses around an expression. */ +function unparenthesized(node: unknown): unknown { + while (isNode(node) && node.type === 'ParenthesizedExpression') { + node = node.expression + } + return node +} + +/** + * Names of the module-level functions that are dead unless something reads + * them: unexported `function` declarations, and unexported + * `const`/`let`/`var` declarators initialised with a function or arrow + * expression, whose name the module declares exactly once. + */ +function deadCodeCandidates(body: Array) { + const counts = new Map() + const count = (name: string) => counts.set(name, (counts.get(name) ?? 0) + 1) + for (const statement of body) { + const scope = createScope(undefined, true) + declareVars([statement], scope) + declareLexical([statement], scope) + scope.names.forEach(count) + } + const candidates = new Set() + for (const statement of body) { + if (statement.type === 'FunctionDeclaration' && isNode(statement.id)) { + candidates.add(statement.id.name as string) + } else if (statement.type === 'VariableDeclaration') { + for (const declarator of statement.declarations as Array) { + const id = declarator.id as Node + const init = unparenthesized(declarator.init) + if ( + id.type === 'Identifier' && + isNode(init) && + (init.type === 'FunctionExpression' || + init.type === 'ArrowFunctionExpression') + ) { + candidates.add(id.name as string) + } + } + } + } + for (const name of candidates) { + if (counts.get(name) !== 1) { + candidates.delete(name) + } + } + return candidates } diff --git a/packages/compiler-test-utils/tests/validate-module.test.ts b/packages/compiler-test-utils/tests/validate-module.test.ts index 75ee1ed8f20..e6076579913 100644 --- a/packages/compiler-test-utils/tests/validate-module.test.ts +++ b/packages/compiler-test-utils/tests/validate-module.test.ts @@ -60,6 +60,101 @@ export const Page = () =>
    {imported}
    `), ), ).toEqual([]) }) + + // User ruling 2026-10-17: a reference to an undefined name inside code + // that never runs throws nothing, so it is no error of the module (whether + // it reaches a production bundle is a build-level question). Dead code is + // approximated as module-level functions that are not exported and that + // nothing else reads. + test.each([ + { + name: 'an uncalled function declaration', + code: `function unused() { return missing() }`, + }, + { + name: 'an unused arrow function', + code: `const makeOptions = () => ({ component: Page })`, + }, + { + name: 'mutually recursive unused functions', + code: `function ping() { return pong() + missing } +const pong = function () { return ping() }`, + }, + { + name: 'an unused function only another unused function calls', + code: `function outer() { return inner() } +function inner() { return }`, + }, + ])('accepts a free name in $name', async ({ code }) => { + expect( + await getModuleErrors(`${code} +export const Route = { component: () =>
    }`), + ).toEqual([]) + }) + + // The other side of the line: code that runs, or may run. + test.each([ + { name: 'module-level code', code: `const value = missing` }, + { + name: 'an exported function', + code: `export function read() { return missing }`, + }, + { + name: 'a function exported by an export list', + code: `function read() { return missing } +export { read as default }`, + }, + { + name: 'a function module-level code calls', + code: `function read() { return missing } +read()`, + }, + { + name: 'a function a route option reads', + code: `function Page() { return

    {missing}

    } +export const Route = { component: Page }`, + }, + { + name: 'a function a live function calls', + code: `function inner() { return missing } +export function outer() { return inner() }`, + }, + { + name: 'a function another function passes on', + code: `function inner() { return missing } +const outer = () => inner +export const Route = { loader: outer }`, + }, + { + name: 'a function a class reads', + code: `function inner() { return missing } +class Store { static value = inner }`, + }, + { + name: 'a function probed with typeof', + code: `function inner() { return missing } +console.log(typeof inner)`, + }, + { + name: 'a function reassigned at module level', + code: `let inner = () => missing +inner = inner`, + }, + { + // Declared twice: which declaration runs is not decided statically. + name: 'a function whose name is declared twice', + code: `var inner = () => missing +if (globalThis) { var inner = () => 'other' }`, + }, + { + name: 'a function body nested in an object', + code: `const helpers = { read: () => missing }`, + }, + ])('rejects a free name in $name', async ({ code }) => { + expect(await getModuleErrors(code)).toEqual([ + '`missing` is referenced but never declared or imported', + ]) + }) }) describe('declaredNames', () => { diff --git a/packages/router-plugin/tests/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/code-splitter-dead-code.test.ts index 3b2ff774a20..9a3321c7d03 100644 --- a/packages/router-plugin/tests/code-splitter-dead-code.test.ts +++ b/packages/router-plugin/tests/code-splitter-dead-code.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { build, routeApp } from './build-harness' import { compileRouteModules, declaredNames, @@ -191,20 +192,50 @@ export const Route = createFileRoute('/')({ component: () =>
    }) // Source: babel-dead-code-elimination dead-code-elimination.test.ts "only // eliminates newly unreferenced identifiers", "unexported circular references" // and "SCC with external caller used -> all preserved". Whether unused user - // code is kept or dropped is free; every module stays valid and the split - // component still reaches its helper. - it('keeps the modules of unused functions, one calling a helper of the split component, valid', async () => { - const code = `${head}function helper() { return 'helper' } -function unused() { return helper() } -function ping(): number { return pong() } + // code is kept or dropped is free, and unused code may name a binding its + // module no longer declares (user ruling 2026-10-17: it never runs); every + // module stays valid and the split component still reaches its helper. + const unusedFunctions = `${head}function helper() { return 'helper' } +function unused() { return 'MARK_UNUSED_END' + helper() } +function ping(): number { return pong() + 'MARK_PING_END'.length } function pong(): number { return ping() } export const Route = createFileRoute('/')({ component: () =>

    {helper()}

    }) ` - await expectValidModules(compileRouteModules(code).modules) - await expectValidModules(compileRouteModules(code, { hmr: true }).modules) - expect(await renderSplitComponent(code)).toBe('

    helper

    ') + it('keeps the modules of unused functions, one calling a helper of the split component, valid', async () => { + await expectValidModules(compileRouteModules(unusedFunctions).modules) + await expectValidModules( + compileRouteModules(unusedFunctions, { hmr: true }).modules, + ) + expect(await renderSplitComponent(unusedFunctions)).toBe('

    helper

    ') }) + // The other half of the ruling: the unused functions (and any binding they + // name that their module no longer declares) do not reach a production + // client bundle. + it.each(['vite', 'rspack', 'webpack'] as const)( + 'drops unused functions, one calling a helper of the split component, from a production client build (%s)', + async (bundler) => { + const app = await build({ + bundler, + env: 'client', + files: routeApp(unusedFunctions, { env: 'client' }), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component)`, + ) + expect({ + unused: app.where('MARK_UNUSED_END'), + ping: app.where('MARK_PING_END'), + html: run.html, + }).toEqual({ + unused: 'nowhere', + ping: 'nowhere', + html: '

    helper

    ', + }) + }, + 60_000, + ) + // Source: babel-dead-code-elimination dead-code-elimination.test.ts "SCC dead // code elimination" > "arrow functions in mutual recursion -> removed" it.each(hmrModes)( 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 a4c8a3fb099..930e26758f7 100644 --- a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts +++ b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts @@ -1,6 +1,7 @@ import { parseSync, transformWithOxc } from 'vite' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' +import { build, clientMountEntry, routeApp } from './build-harness' import { compileRouteModules, componentChunk, @@ -879,6 +880,67 @@ export const Route = createFileRoute('/')({ expect((await route.load('component'))()).toBe('

    server

    ') }) + // With `verbatimModuleSyntax`, TypeScript keeps `import { type Db } from + // './db'` as `import './db'`. A module a route file imports only for types + // need not load in the client (ruling L3, 2026-10-17; same as the Start + // client in start-plugin-core known-bugs-start-compiler.test.ts "a + // server-only module imported only for a type specifier"). Only Vite reads + // the app's tsconfig in this harness. + describe('a module imported only for a type specifier, with verbatimModuleSyntax (vite)', () => { + const files = { + 'tsconfig.json': JSON.stringify({ + compilerOptions: { verbatimModuleSyntax: true, jsx: 'react-jsx' }, + }), + 'db.ts': `;(globalThis as any).dbLoaded = 'MARK_DB_LOADED_END' +export type Db = { name: string } +`, + } + const route = `${head}import { type Db } from '../db' +function Page(props: { db?: Db }) { + return

    {props.db ? props.db.name : 'page'}

    +} +export const Route = createFileRoute('/')({ component: Page }) +` + + // Control: the tsconfig is in effect (the same import, in a module the + // code splitter does not compile, loads `db.ts`). + it('loads with the module that imports it outside route files', async () => { + const app = await build({ + bundler: 'vite', + env: 'client', + files: routeApp(route, { + env: 'client', + files: { + ...files, + 'entry.ts': `import { type Db } from './db'\n${clientMountEntry}`, + }, + }), + }) + expect(app.where('MARK_DB_LOADED_END')).toBe('eager') + }, 60_000) + + it('does not load in the client of a split route file, which renders', async () => { + const app = await build({ + bundler: 'vite', + env: 'client', + files: routeApp(route, { env: 'client', files }), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component) +return globalThis.dbLoaded ?? null`, + ) + expect({ + where: app.where('MARK_DB_LOADED_END'), + loaded: run.result, + html: run.html, + }).toEqual({ + where: 'nowhere', + loaded: null, + html: '

    page

    ', + }) + }, 60_000) + }) + it('keeps decorators before `export` for legacy decorator transforms', () => { // TypeScript `experimentalDecorators` and Babel `decorators-legacy` only // accept decorators before the `export` keyword. 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 e8743a4f863..d3ef6b1c5fd 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -567,10 +567,35 @@ export const Route = createFileRoute('/hmr')({ }) // A route the route file creates inside a function of the same file (a -// helper, an IIFE) is split like a module-scope route, and hot-updates. +// helper, an IIFE) is unsupported but user-fixable (user ruling 2026-10-17, +// decision D11): it is either split like a module-scope route (and +// hot-updates in place), or left unsplit with a warning naming the file, in +// production as in development. Either way it renders correctly; silent +// wrong output, or unsplit without a warning, fails. // Sources: #8504 (code-splitter-plugin-pipeline.test.ts); the #8504 // confidence campaign (route-factory cases 1-3 and 5, dev-hmr D1/D2). describe('a route created inside a function', () => { + /** + * Runs `run` with `console.warn` captured; returns its result and whether + * a warning named `fileOf(result)`. + */ + async function reporting( + run: () => Promise, + fileOf: (result: T) => string, + ) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const result = await run() + const file = fileOf(result) + const reported = warn.mock.calls.some((args) => + args.map(String).join(' ').includes(file), + ) + return { result, reported } + } finally { + warn.mockRestore() + } + } + /** Route files whose `/made` route a function of the file creates. */ const factoryRoutes = { 'a helper function, with an inline component': (data: string) => @@ -622,16 +647,19 @@ export const Route = (() => `, } + // A production compile. it.each(Object.entries(factoryRoutes))( - 'built in %s is split and keeps working', + 'built in %s is split, or reported, and keeps working', async (_, route) => { - const loaded = await loadRoute(route('data'), { routeId: '/made' }) + const { result: loaded, reported } = await reporting( + () => loadRoute(route('data'), { routeId: '/made' }), + (loaded) => loaded.ids.reference!, + ) await expectValidModules(loaded.modules) - expect(loaded.isLazy('component')).toBe(true) - expect([ - loaded.options.loader(), - (await loaded.load('component'))(), - ]).toEqual(['data', '

    made

    ']) + expect({ + rendered: [loaded.options.loader(), (await loaded.load('component'))()], + splitOrReported: loaded.isLazy('component') || reported, + }).toEqual({ rendered: ['data', '

    made

    '], splitOrReported: true }) }, ) @@ -645,19 +673,47 @@ export const Route = (() => 'with $style HMR (codeSplitting: $codeSplitting)', ({ style, codeSplitting }) => { it.each(Object.entries(factoryRoutes))( - 'a route built in %s is hot-updated in place, keeping its component', + 'a route built in %s is hot-updated in place keeping its component, or reported', async (_, route) => { - const update = await hotUpdate(route('old'), { - style, - codeSplitting, - refresh: true, - routeId: '/made', - updated: route('new'), - }) - expect(update.invalidated).toBe(false) - expect(update.live.component).toBe(update.previous.component) - expect(await (await update.load('loader'))()).toBe('new') - expect((await update.load('component'))()).toBe('

    made

    ') + const { result: update, reported } = await reporting( + () => + hotUpdate(route('old'), { + style, + codeSplitting, + refresh: true, + routeId: '/made', + updated: route('new'), + }), + (update) => update.file, + ) + const updated = { + loader: await (await update.load('loader'))(), + component: (await update.load('component'))(), + } + const updatedInPlace = + !update.invalidated && + updated.loader === 'new' && + updated.component === '

    made

    ' + // (a) supported: split (with code splitting on) and hot-updated + // in place, keeping the component's identity. + const supported = + updatedInPlace && + update.live.component === update.previous.component && + (!codeSplitting || update.isLazy('component')) + // (b) reported, and still correct: updated in place, or the + // update invalidated (the page reloads with the new code). + const unsupportedAndReported = + reported && (update.invalidated || updatedInPlace) + expect( + supported || unsupportedAndReported, + JSON.stringify({ + invalidated: update.invalidated, + split: update.isLazy('component'), + identityKept: update.live.component === update.previous.component, + reported, + updated, + }), + ).toBe(true) }, ) }, @@ -669,7 +725,8 @@ export const Route = (() => // component declared in the factory leaves a chunk that exports an // undeclared binding, and a parameter that shadows a module-level // component makes the chunk render the module-level one. Leaving such - // options unsplit (with or without a warning) is a valid fix. + // options unsplit with a warning naming the file is a valid fix (the + // pattern is unsupported but user-fixable); unsplit and silent is not. // Source: the #8504 confidence campaign (route-factory cases 4, 5 with a // split loader, 6, 6b and 6c; F-21). // Impact: the route throws when it renders or loads, or silently renders @@ -725,20 +782,29 @@ export const Route = makeRoute('given')`, } export const Route = makeRoute('given')`, expected: ['given', '

    made

    '], + split: ['loader', 'component'], }, ])( - 'a route built from $name renders what the factory was given, split or not', - async ({ route, expected }) => { - const loaded = await loadRoute( - `import { createFileRoute } from '@tanstack/react-router' + 'a route built from $name renders what the factory was given, split or reported', + async ({ route, expected, split = ['component'] }) => { + const { result: loaded, reported } = await reporting( + () => + loadRoute( + `import { createFileRoute } from '@tanstack/react-router' ${route} `, - { routeId: '/made' }, + { routeId: '/made' }, + ), + (loaded) => loaded.ids.reference!, ) - expect([ - await (await loaded.load('loader'))(), - (await loaded.load('component'))(), - ]).toEqual(expected) + expect({ + rendered: [ + await (await loaded.load('loader'))(), + (await loaded.load('component'))(), + ], + splitOrReported: + split.every((option) => loaded.isLazy(option)) || reported, + }).toEqual({ rendered: expected, splitOrReported: true }) }, ) 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 1d64269139c..990a28b3c01 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -1196,6 +1196,46 @@ export const Route = ${route} expect((await loaded.load('component'))()).toBe('

    page

    ') }) + // The unused `makeOptions` of the two rows above may name a `Page` its + // module no longer declares (user ruling 2026-10-17: it never runs), as + // long as that dead code does not reach a production client bundle. + test.each( + clientBundlers.flatMap((bundler) => + [ + `createFileRoute('/')({ ssr: false, component: Page } as any)`, + `(createFileRoute('/') as any)({ ssr: false, component: Page })`, + ].map((route) => ({ bundler, route })), + ), + )( + 'a route written as $route drops its unused options factory from a production client build ($bundler)', + async ({ bundler, route }) => { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `${head}function Page() { + return

    page

    +} +const makeOptions = () => ({ component: Page, title: 'MARK_MAKE_OPTIONS_END' }) +export const Route = ${route} +`, + { env: 'client' }, + ), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component)`, + ) + expect({ + makeOptions: app.where('MARK_MAKE_OPTIONS_END'), + html: run.html, + }).toEqual({ + makeOptions: 'nowhere', + html: '

    page

    ', + }) + }, + 60_000, + ) + // 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 ☝️: @@ -2226,7 +2266,9 @@ export const Route = createFileRoute('/')({ ${options}component: ${binding} }) // Bug: the entry's `useTitle` and the one the split component calls are // two functions: a binding that reads `Route` is never shared // (`removeBindingsDependingOnRoute`), so the chunk declares a copy of the - // exported function. Source: gap S19. + // exported function. Importers of the route module and the route must + // share one instance (user ruling 2026-10-17, P1): a copy in the chunk is + // this bug even when the function holds no state. Source: gap S19. // Impact: code that relies on the identity of an exported hook or helper // (memoisation, registries, `===` checks) sees two of them. test.fails( diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index ad368797305..f2dea074eda 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -1445,6 +1445,8 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { const live = liveOptions() return { + /** Id of the route file, as the plugin saw it. */ + file: first.id, previous, live, created, From 6167ecd433c0a0a382a2c35779162d5386d3e5bf Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:38:54 +0200 Subject: [PATCH 0322/1425] test(start-plugin-core): Vite dev import protection hooks (mockAccess, marker retro checks, traces, full-bundle dev) Behaviours of the Vite import-protection plugin hooks in dev: - mockAccess 'warn' / 'off' change how the dev client mocks report their use (console.warn / nothing instead of console.error). - The importers of each of several server-only-marked modules are logged when the client loads the importers first (retroactive marker check per module). - A warning's trace leads from the router entry through a module of src/routes (route modules are not entries in unbundled dev). - With behavior "error", the trace of an import added by an edit still leads from the router entry (the dev server's module graph). - Full-bundle dev (experimental.bundledDev): a denied file is mocked in the client bundle and logged at the use (coverage of the bundledDev path; the harness gains an additive `bundledDev` option). Pin (test.fails, NEW): in a JSX module, a denied file imported through an alias (package.json `imports`) is reported at a location read from the compiled code (4:77 of a two-line module): resolveId reports the violation before the transform-cache loop captures the module's source map ('devViolation'); a scratch run that always captures the map passes it. Kills: spc:4859, spc:5089, spc:4986, spc:4987, spc:4899 Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/dev.test.ts | 179 ++++++++++++++++++ .../tests/importProtection/start-apps.ts | 7 + 2 files changed, 186 insertions(+) diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index d29dad8c172..4cf8375c734 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -20,6 +20,8 @@ import { serverFnFactoryApp, startProtectedDevServer, } from './start-apps' +import { violationReports } from './violation-report' +import type { ViolationReport } from './violation-report' import type { ImportProtectionOptions } from '../../src/schema' import type { ViolationInfo } from '../../src/import-protection/trace' import type { Plugin } from 'vite' @@ -167,6 +169,57 @@ describe('a client import of a denied file', () => { ) }) +describe('the trace of a client import of a denied file', () => { + /** The modules of the trace of `report`, without locations. */ + const traceModules = (report: ViolationReport) => + report.trace.map(({ at }) => at.replace(/:\d+:\d+$/, '')) + + // A module under `src/routes` is not an entry: the trace goes on to the + // router entry. + // Source: mutation run R4 (spc:4986, 4987: route modules taken as entries). + test( + 'leads from the router entry through a module of src/routes to the importer', + async () => { + const server = await startProtectedDevServer({ + ...deniedFileApp, + 'src/routes/page.ts': `export { read } from '../a'\n`, + 'src/entry.ts': `export * from './routes/page'\n`, + }) + await server.load('/src/entry.ts') + const reports = violationReports(server.logs) + expect(reports).not.toEqual([]) + for (const report of reports) { + expect(traceModules(report)).toEqual([ + 'src/entry.ts', + 'src/routes/page.ts', + 'src/a.ts', + ]) + } + }, + timeout, + ) + + // Source: mutation run R4 (spc:4899: the dev server's module graph, which + // still has the edges an edit drops from import protection's own graph). + test( + 'with behavior "error", of an import added by an edit, leads from the router entry', + async () => { + const server = await startProtectedDevServer( + { ...deniedFileApp, 'src/a.ts': `export const read = () => 'plain'\n` }, + { behavior: 'error' }, + ) + await server.load('/src/entry.ts') + await server.edit({ 'src/a.ts': deniedFileApp['src/a.ts'] }) + const error = await rejection(server.load('/src/entry.ts')) + const [report, ...others] = violationReports(error.message) + expect(others).toEqual([]) + expect(report!.importer).toBe(deniedFileUse) + expect(traceModules(report!)).toEqual(['src/entry.ts', 'src/a.ts']) + }, + timeout, + ) +}) + describe('log', () => { /** * The violations logged when the client loads the app, and those logged @@ -403,6 +456,37 @@ export const request = () => getRequest() }, timeout, ) + + // `mockAccess` (guide/import-protection.md): how the dev client mocks + // report their use. + // Source: mutation run R4 (spc:4859, the option ignored). + test.each([ + { mockAccess: 'warn', expected: { errors: false, warnings: true } }, + { mockAccess: 'off', expected: { errors: false, warnings: false } }, + ] as const)( + 'with mockAccess $mockAccess, the mocks report their use with console.warn: $expected.warnings, console.error: $expected.errors', + async ({ mockAccess, expected }) => { + const server = await startProtectedDevServer(deniedSpecifierApp, { + mockAccess, + }) + const reported = { errors: false, warnings: false } + const isReport = (message: unknown) => + String(message instanceof Error ? message.message : message).includes( + 'Mocked import used in dev client', + ) + vi.spyOn(console, 'error').mockImplementation((message: unknown) => { + reported.errors ||= isReport(message) + }) + vi.spyOn(console, 'warn').mockImplementation((message: unknown) => { + reported.warnings ||= isReport(message) + }) + const module = await evaluateServed(server, '/src/entry.ts') + expect(String(module.request())).toBe('[import-protection mock]') + expect(module.read()).toBe('[import-protection mock]') + expect(reported).toEqual(expected) + }, + timeout, + ) }) describe('onViolation', () => { @@ -494,6 +578,30 @@ export const read = () => secret() timeout, ) + // Source: mutation run R4 (spc:5089: only the first marked module's + // importers were checked). + test( + 'the importers of each of several server-only-marked modules are logged when the client loads the importers first', + async () => { + const server = await startProtectedDevServer({ + ...markedApp(serverOnly), + 'src/d.ts': `${serverOnly}export const other = () => 'MARK_D_CODE'\n`, + 'src/c.ts': `import { other } from './d' +export const peek = () => other() +`, + 'src/entry.ts': `export * from './a'\nexport * from './c'\n`, + }) + await server.load('/src/entry.ts') + expect(loggedViolations(server.logs)).toEqual( + expect.arrayContaining([ + 'client src/a.ts:2:27', + 'client src/c.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 @@ -860,6 +968,38 @@ export const leak = () => secret() ) }) +// Vite's full-bundle dev (`experimental.bundledDev`), which Start supports: +// Rolldown bundles the client, so a module can be transformed before its +// importers. +// Source: mutation run R4 (the bundledDev branches of the plugin had no +// unit test; e2e import-protection runs them with E2E_VITE_BUNDLED_DEV). +describe('full-bundle dev', () => { + test.each([ + { name: 'its router entry', files: deniedFileApp }, + { + name: 'a module of src/routes', + files: { + ...deniedFileApp, + 'src/routes/page.ts': `export { read } from '../a'\n`, + 'src/entry.ts': `export * from './routes/page'\n`, + }, + }, + ])( + 'a denied file imported from $name is mocked in the client bundle and logged at the use', + async ({ files }) => { + const server = await startProtectedDevServer( + files, + {}, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + const bundle = await server.clientBundle() + expect(modulesContaining(bundle, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([`client ${deniedFileUse}`]) + }, + 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). @@ -913,4 +1053,43 @@ export const Page = () =>
    {String(1)}
    }, timeout, ) + + // Bug: import protection keeps a module's source map for its diagnostics + // up front only when the module's compiled code names a denied module + // (`shouldCaptureDiagnosticSourceMap`, vite/import-protection-plugin/ + // plugin.ts). Through an alias (here the app's package.json `imports`), + // the denied file is only known once the import resolves, and `resolveId` + // reports it (transform-cache import loop → `reportOrDeferViolation`) + // before the loop captures the map (`'devViolation'`), so the location is + // read from the compiled code. + // Impact: in dev, the warning for a denied file imported through an alias + // in a JSX module points at a line that is not in the source (here 4:77 + // of a two-line module). + // Source: mutation run R4 (spc:5436, that late source map capture). + test.fails( + 'in a JSX module, a use of a denied file imported through a subpath import alias is located on its source line', + async () => { + const server = await startProtectedDevServer({ + ...reactRuntimeFiles, + 'package.json': JSON.stringify({ + name: 'app', + type: 'module', + imports: { '#secret': './src/secret.server.ts' }, + }), + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/a.tsx': `import { secret } from '#secret' +export const Page = () =>
    {String(secret())}
    +`, + 'src/entry.ts': `export * from './a'\n`, + }) + const client = await server.load('/src/entry.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + // (the reports with a location: the one naming the resolved path + // has none, see the 'once' pin of `log`) + expect( + loggedViolations(server.logs).filter((v) => /:\d+:\d+$/.test(v)), + ).toEqual(['client src/a.tsx:2:40']) + }, + timeout, + ) }) diff --git a/packages/start-plugin-core/tests/importProtection/start-apps.ts b/packages/start-plugin-core/tests/importProtection/start-apps.ts index eb213bfd49a..522537ac93e 100644 --- a/packages/start-plugin-core/tests/importProtection/start-apps.ts +++ b/packages/start-plugin-core/tests/importProtection/start-apps.ts @@ -33,6 +33,11 @@ export interface ProtectedDevServerOptions { plugins?: Array /** Vite configuration of the app (e.g. `server.warmup`). */ config?: StartDevServerOptions['config'] + /** + * Bundles the client from this entry (Vite's full-bundle dev, + * `experimental.bundledDev`): `server.clientBundle()`. + */ + bundledDev?: StartDevServerOptions['bundledDev'] } /** @@ -51,9 +56,11 @@ export function startProtectedDevServer( providerEnvironment = 'ssr', plugins = [], config = {}, + bundledDev, } = options return startDevServer({ files, + bundledDev, config: providerEnvironment === 'ssr' ? config From 4561f9fee56ee238791eeae7d9a908d7ba89b019 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:39:02 +0200 Subject: [PATCH 0323/1425] test(start-plugin-core): build import protection reports only surviving imports, once, with their type - A server-only-marked module re-exported by a mixed barrel whose other exports the client uses is tree-shaken: the build succeeds without a report (guide "False Positives: Dev vs Build"). Rsbuild is pinned test.fails: same root cause as the IP6b pins in known-bugs.test.ts (its report step walks every module of the graph). - Mock mode: a denied import of a route used by its loader and its split component (two bundle modules, one source import) is warned once. - onViolation gets type 'specifier' for an import of a denied specifier. Kills: spc:5266, spc:5268, spc:5275, spc:5296, spc:5299, spc:5137 Co-Authored-By: Claude Opus 5.5 --- .../importProtection/build-options.test.ts | 31 ++++++++ .../tests/importProtection/build.test.ts | 77 +++++++++++++++++++ 2 files changed, 108 insertions(+) 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 4491344e724..8446a6ade44 100644 --- a/packages/start-plugin-core/tests/importProtection/build-options.test.ts +++ b/packages/start-plugin-core/tests/importProtection/build-options.test.ts @@ -365,6 +365,37 @@ export const secret = () => 'MARK_SECRET' timeout, ) + // Source: mutation run R4 (spc:5137, the `specifier` type of the + // table above). + test( + 'the callback gets a specifier violation for an import of a denied specifier', + async () => { + const seen: Array = [] + await rejection( + buildStart({ + bundler, + files: { + 'src/entry.ts': `import { getRequest } from '@tanstack/react-start/server' +export const read = () => getRequest() +`, + }, + entry: 'src/entry.ts', + start: { + importProtection: { + onViolation: (info) => { + seen.push(info as Violation) + }, + }, + }, + }), + ) + expect( + distinct(seen.map((info) => `${info.type} ${info.specifier}`)), + ).toEqual(['specifier @tanstack/react-start/server']) + }, + 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 () => { diff --git a/packages/start-plugin-core/tests/importProtection/build.test.ts b/packages/start-plugin-core/tests/importProtection/build.test.ts index 99bb0c0b70f..ab4796cb311 100644 --- a/packages/start-plugin-core/tests/importProtection/build.test.ts +++ b/packages/start-plugin-core/tests/importProtection/build.test.ts @@ -275,6 +275,46 @@ export const read = () => secret() + '|' + d() ) }) +// A build reports the denied imports that survive tree-shaking: "If an import +// is eliminated from the final bundle, no violation is reported" +// (guide/import-protection.md, "False Positives: Dev vs Build"; its example is a marked file +// re-exported through a mixed barrel that the client never uses). +// Source: mutation run R4 (generateBundle's survival check, spc:5266, 5268, +// 5275). +describe.each(startBundlers)('%s build, tree-shaken imports', (bundler) => { + // Bug (Rsbuild): same as the pins "a denied module the client bundle drops + // does not fail the build" (known-bugs.test.ts): the Rsbuild report step + // walks every module of the module graph, used or not, so the marked + // module the client bundle drops still fails the build. + // Impact: on Rsbuild, a mixed barrel re-exporting a server-only-marked + // module fails the build even when the client only uses its other exports. + const barrelTest = bundler === 'rsbuild' ? test.fails : test + barrelTest( + 'a server-only-marked module re-exported by a barrel the client uses other exports of builds without any report', + async () => { + const { client } = await buildStart({ + bundler, + files: { + ...deniedModules['a server-only-marked module'].files, + 'src/barrel.ts': `export { secret } from './marked' +export const label = () => 'client' +`, + 'src/entry.ts': `import { label } from './barrel' +export const read = () => label() +`, + }, + entry: 'src/entry.ts', + }) + expect({ + reported: reportedViolations(client.warnings), + hasRealCode: client.where('MARK_SECRET') !== 'nowhere', + read: (await client.run(`return entry.read()`)).result, + }).toEqual({ reported: [], hasRealCode: false, read: 'client' }) + }, + timeout, + ) +}) + // `behavior: 'mock'` builds: a denied import is replaced with a mock (and // a warning); the real module's code stays out of the bundle. describe.each(startBundlers)('%s build, mock mode', (bundler) => { @@ -443,6 +483,43 @@ export const read = () => [String(secret), ok(), fine(), String(s())].join(',') }, timeout, ) + + // The route module and its split component are two modules of the bundle + // that import the denied file for the same import of the source. + // Source: mutation run R4 (spc:5296, 5299). + test( + 'a denied import of a route used by its loader and its split component is warned once', + async () => { + const { client } = await buildStart({ + bundler, + files: { + ...routerRuntimeFiles, + ...deniedModules['a denied file'].files, + 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' +import { secret } from '../secret.server' +export const Route = createFileRoute('/')({ + loader: () => String(secret()), + component: () => String(secret()), +}) +`, + 'src/router.ts': `import { routeTree } from './routeTree.gen' +export const getRouter = () => ({ routeTree }) +`, + 'src/entry.ts': `export { getRouter } from './router'\n`, + }, + entry: 'src/entry.ts', + start: { importProtection: { behavior: 'mock' } }, + }) + expect(client.where('MARK_SECRET')).toBe('nowhere') + expect(reportedViolations(client.warnings)).toEqual([ + { + environment: 'client', + importer: expect.stringMatching(/^src\/routes\/index\.tsx/), + }, + ]) + }, + timeout, + ) }) // Bug: `onViolation` runs after the build has replaced the denied module From f80fc35de59d3e6deb2c8dfc98cb58e269957fdb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:39:02 +0200 Subject: [PATCH 0324/1425] test(start-plugin-core): pin dev error mode silencing modules a server fn factory imports Pin (test.fails, NEW): when the Start compiler's lookup of a server fn factory module resolves its imports, Vite import protection records each imported module as a lookup module by its path ("Propagate pre-transform status transitively" in resolveId), so the client's own transform of that module is silenced in dev with behavior "error": a module the factory imports that calls getRequest() at the top level loads the real @tanstack/react-start/server without any error. Same root cause as the B3 pin above, wider trigger. Red reason: "expected 'loaded' to deeply equal [ 'client src/other.ts' ]"; the test passes with propagation removed. Kills: spc:5168, spc:5169 Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/known-bugs.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 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 97338c9ee9f..120d9fe02f5 100644 --- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts @@ -347,6 +347,27 @@ describe('Vite dev, behavior "error"', () => { }, 30_000, ) + + // Bug: same cause as the pin above, one module further: when the Start + // compiler's lookup of a server fn factory module resolves the module's + // imports, import protection records each imported module as a lookup + // module too (vite/import-protection-plugin/plugin.ts `resolveId`, + // "Propagate pre-transform status transitively"), by its path, so the + // client's own transform of that module is silenced as well. + // Impact: in dev with behavior "error", any module a server fn factory + // module imports gets the real denied module when it calls it at the top + // level, without any error (the control above fails to load). + // Source: mutation run R4 (spc:5168, 5169). + test.fails( + 'a module a server fn factory module imports, calling a denied import at the top level, fails to load', + async () => { + const app = topLevelCallApp('src/other.ts') + app['src/factory.ts'] += `export { request } from './other'\n` + app['src/entry.ts'] = `export { fn } from './usage'\n` + expect(await loadOutcome(app)).toEqual(['client src/other.ts']) + }, + 30_000, + ) }) describe('Rsbuild dev', () => { From 0e8684b896de1fb20880c0a40d11a01dbc4b53e7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:39:10 +0200 Subject: [PATCH 0325/1425] test: lone-CR positions, R-13 pragma row, import-protection exemptions - R-11: findPostCompileUsagePos reports the same line and column for LF, CRLF and lone CR; the reported importer location for a lone-CR file on Vite/Rsbuild dev and build (main passes both). - R-13: `@jsx` only row on the KB "factory the route module creates ... created once" pin (main: "jsx is not defined"). - Import-protection exemptions (ruling 2026-10-17), new exemptions.test.ts: exempt importers (ignoreImporters, exclude, outside include) get the real module and are not reported; non-exempt importers get the mock and are reported. Vite and Rsbuild builds, Vite dev. Pins: exempt importers get the mock (ignoreImporters, exclude). NEW pin: with `include`, a denied file outside the patterns is never mocked (build ships the real module; Vite dev page fails to load). Co-Authored-By: Claude Opus 5.5 --- .../tests/known-bugs-code-splitter.test.ts | 17 +- .../importProtection/analysis.usage.test.ts | 19 ++ .../importProtection/build-options.test.ts | 5 +- .../tests/importProtection/exemptions.test.ts | 225 ++++++++++++++++++ .../reported-location.test.ts | 8 +- 5 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 packages/start-plugin-core/tests/importProtection/exemptions.test.ts 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 990a28b3c01..2685e25907b 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -2150,13 +2150,20 @@ export const Route = createFileRoute('/')({ // and keeping it in the chunk without sharing it creates it again there. // Impact: the split component throws "jsx is not defined", or the route // module and the chunk each create their own factory. - test.fails( - 'a factory the route module creates and the loader reads is created once for the split component', - async () => { + // Source of the `@jsx` only row: the #8504 probe (R-13; a project whose + // tsconfig selects the classic runtime). + test.fails.each([ + { + name: '@jsxRuntime classic and @jsx', + header: '/** @jsxRuntime classic */\n/** @jsx jsx */', + }, + { name: '@jsx only', header: '/** @jsx jsx */' }, + ])( + 'a factory the route module creates and the loader reads is created once for the split component ($name)', + async ({ header }) => { let creations = 0 const route = await loadRoute( - `/** @jsxRuntime classic */ -/** @jsx jsx */ + `${header} ${head}import { createJsx } from './jsx' const jsx = createJsx() export const Route = createFileRoute('/')({ 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 472f4194f66..a408f2cf37a 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts @@ -22,6 +22,25 @@ describe('findPostCompileUsagePos', () => { }) }) + // A lone CR is a line terminator in JavaScript, like LF and CRLF, so the + // same source reports the same position with each. Source: the #8504 + // probe (R-11, lone CR). + test.each(['\n', '\r\n', '\r'])( + 'reports the same line and column with %j line endings', + (newline) => { + const code = [ + "import { secret } from 'denied'", + '// a comment', + 'export const value = secret()', + '', + ].join(newline) + expect(pos(code, 'denied')).toEqual({ + line: 3, + column0: 'export const value = '.length, + }) + }, + ) + test('returns undefined when import is type-only', () => { expect( pos(`import type { Foo } from 'denied';\nconst x = 1`, 'denied'), 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 4491344e724..c45988b44f2 100644 --- a/packages/start-plugin-core/tests/importProtection/build-options.test.ts +++ b/packages/start-plugin-core/tests/importProtection/build-options.test.ts @@ -136,8 +136,9 @@ describe.each(startBundlers)('%s build', (bundler) => { timeout, ) - // What the excluded or ignored importer then gets for the denied file is - // not documented, so these rows only check that nothing is reported. + // These rows check that nothing is reported; what the excluded or + // ignored importer then gets for the denied file (the real module) is + // pinned in exemptions.test.ts. test.each([ { option: 'exclude', diff --git a/packages/start-plugin-core/tests/importProtection/exemptions.test.ts b/packages/start-plugin-core/tests/importProtection/exemptions.test.ts new file mode 100644 index 00000000000..f8a18e2c96c --- /dev/null +++ b/packages/start-plugin-core/tests/importProtection/exemptions.test.ts @@ -0,0 +1,225 @@ +/** + * What an importer exempted from import protection gets for a denied file: + * the real module (user ruling 2026-10-17). Exempt means matched by + * `ignoreImporters` or `exclude`, or outside `include`. Exempting a file + * does more than silence the report; importers that are not exempt still get + * the mock and a report. Client environment, mock mode. That nothing is + * reported for an exempt importer is also checked in build-options.test.ts. + */ +import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' +import { afterAll, afterEach, describe, expect, test, vi } from 'vitest' +import { buildStart, startBundlers } from '../build-harness' +import { reportedViolations, startProtectedDevServer } from './start-apps' +import type { StartBuild } from '../build-harness' +import type { ImportProtectionOptions } from '../../src/schema' + +const timeout = 60_000 + +afterEach(() => { + vi.restoreAllMocks() + delete (globalThis as { secretLoaded?: unknown }).secretLoaded +}) + +/** + * `src/exempt/a.ts` and `src/app/b.ts` both use the denied file + * `src/secret.server.ts`, which records that it ran; the entry re-exports + * both uses. + */ +const files = { + 'src/secret.server.ts': `;(globalThis as any).secretLoaded = 'MARK_SECRET_LOADED' +export const secret = () => 'MARK_SECRET' +`, + 'src/exempt/a.ts': `import { secret } from '../secret.server' +export const exemptRead = () => secret() +`, + 'src/app/b.ts': `import { secret } from '../secret.server' +export const otherRead = () => secret() +`, + 'src/entry.ts': `export { exemptRead } from './exempt/a' +export { otherRead } from './app/b' +`, +} + +/** + * Each way to exempt `src/exempt/a.ts` but not `src/app/b.ts`, and which of + * the tests below main fails (`.fails`): `exempt` in builds and dev, + * `control` in builds and dev. + */ +const exemptions: Array<{ + option: string + importProtection: ImportProtectionOptions + fails: { + build: { exempt: boolean; control: boolean } + dev: { exempt: boolean; control: boolean } + } +}> = [ + { + option: 'ignoreImporters', + importProtection: { ignoreImporters: ['**/exempt/*.ts'] }, + fails: { + build: { exempt: true, control: false }, + dev: { exempt: true, control: false }, + }, + }, + { + option: 'exclude', + importProtection: { exclude: ['src/exempt/**'] }, + fails: { + build: { exempt: true, control: false }, + dev: { exempt: true, control: false }, + }, + }, + { + // The denied file is outside `include` too: see the NEW bug below. + option: 'an importer outside include', + importProtection: { include: ['src/app/**'] }, + fails: { + build: { exempt: false, control: true }, + dev: { exempt: true, control: true }, + }, + }, +] + +/** The files of the reported violations, ` `. */ +const reportedFiles = (output: string | Array) => + [ + ...new Set( + reportedViolations(output).map( + ({ environment, importer }) => + `${environment} ${importer.replace(/:\d+:\d+$/, '')}`, + ), + ), + ].sort() + +/** What each re-exported use returns, and whether the denied file ran. */ +const readScript = `const read = (use) => { try { return String(use()) } catch (error) { return 'throws: ' + error } } +return { + exempt: read(entry.exemptRead), + other: read(entry.otherRead), + loaded: globalThis.secretLoaded ?? null, +}` + +type Outcome = { + exempt: string + other: string + loaded: unknown + reported: Array +} + +// Bug: an exempt importer is only left out of the reports: it still gets +// the mock, because the denied file is mocked by its own transform, for +// every importer (vite/import-protection-plugin/plugin.ts:2158 self-denial; +// the Rsbuild adapter behaves the same). User ruling 2026-10-17. +// Impact: code a project exempts on purpose (tests, generated code) +// silently gets a mock instead of the module it imports. +const expectExemptGetsRealModule = (outcome: Outcome) => + expect({ + exempt: outcome.exempt, + loaded: outcome.loaded, + reported: outcome.reported, + }).toEqual({ + exempt: 'MARK_SECRET', + loaded: 'MARK_SECRET_LOADED', + reported: ['client src/app/b.ts'], + }) + +// Control: the importer that is not exempt gets the mock and is reported. +// NEW bug (`include` rows): with `importProtection.include`, a denied file +// outside the patterns is never mocked, because its own transform skips +// files out of scope before the self-denial +// (vite/import-protection-plugin/plugin.ts:2139 `shouldCheckImporter(file)`; +// Rsbuild: same outcome). An included importer is reported, but a mock-mode +// build ships the real server-only module to the client, and in Vite dev the +// importer's rewrite (`import __tss_deny_0 from '/src/secret.server.ts'`) +// reads a default export the real file lacks, so the page fails to load. +// Impact: server-only code in the client bundle in mock mode (the dev +// default, and `behavior: { build: 'mock' }`), or a broken dev page. +const expectOtherGetsMock = (outcome: Outcome) => + expect({ other: outcome.other, reported: outcome.reported }).toEqual({ + other: expect.not.stringMatching(/^MARK_SECRET$/), + reported: expect.arrayContaining(['client src/app/b.ts']), + }) + +describe.each(startBundlers)('%s build', (bundler) => { + describe.each(exemptions)('$option', ({ importProtection, fails }) => { + // One build for both tests. + let built: Promise | undefined + let dispose: (() => Promise) | undefined + const app = () => + (built ??= buildStart({ + bundler, + files, + entry: 'src/entry.ts', + start: { importProtection: { ...importProtection, behavior: 'mock' } }, + registerCleanup: (cleanup) => { + dispose = cleanup + }, + })) + afterAll(() => dispose?.()) + + async function outcome(): Promise { + const { client } = await app() + const { result } = await client.run(readScript) + return { + ...(result as Omit), + reported: reportedFiles(client.warnings), + } + } + + ;(fails.build.control ? test.fails : test)( + 'an importer that is not exempt gets the mock and is reported', + async () => { + expectOtherGetsMock(await outcome()) + }, + timeout, + ) + ;(fails.build.exempt ? test.fails : test)( + 'the exempt importer gets the real module, and is not reported', + async () => { + expectExemptGetsRealModule(await outcome()) + }, + timeout, + ) + }) +}) + +describe.each(exemptions)( + 'vite dev: $option', + ({ importProtection, fails }) => { + /** Loads the entry as a browser would; what it reads, and the logs. */ + async function outcome(): Promise { + const server = await startProtectedDevServer(files, importProtection) + // The mock reports each use in the browser console. + vi.spyOn(console, 'error').mockImplementation(() => {}) + const module = await evaluateServed(server, '/src/entry.ts') + const read = (use: () => unknown) => { + try { + return String(use()) + } catch (error) { + return `throws: ${String(error)}` + } + } + return { + exempt: read(module.exemptRead), + other: read(module.otherRead), + loaded: (globalThis as { secretLoaded?: unknown }).secretLoaded ?? null, + reported: reportedFiles(server.logs), + } + } + + ;(fails.dev.control ? test.fails : test)( + 'an importer that is not exempt gets the mock and is reported', + async () => { + expectOtherGetsMock(await outcome()) + }, + timeout, + ) + ;(fails.dev.exempt ? test.fails : test)( + 'the exempt importer gets the real module, and is not reported', + async () => { + expectExemptGetsRealModule(await outcome()) + }, + timeout, + ) + }, +) 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 7dee58e4562..019877abdd9 100644 --- a/packages/start-plugin-core/tests/importProtection/reported-location.test.ts +++ b/packages/start-plugin-core/tests/importProtection/reported-location.test.ts @@ -122,6 +122,12 @@ const cases: Record = { code: jsxElement, expected: [at(jsxElement, 2, ' = [ @@ -137,7 +143,7 @@ const rows = (names: Array, on: Array) => describe('the reported location of a violation is the leaking use', () => { test.each([ - ...rows(['afterHandler'], channels), + ...rows(['afterHandler', 'loneCr'], channels), ...rows(['typePosition', 'jsxElement'], ['vite dev', 'vite build']), ])( '$channel: $name', From 0814bc3da263fb900440251d2fa88b93fbca8643 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:44:10 +0200 Subject: [PATCH 0326/1425] test(start-plugin-core): Vite dev import protection gaps (onViolation, start entry, split routes, non-JS modules, edits, traces) Dev server tests (tests/importProtection/dev.test.ts): - onViolation that returns nothing keeps the warning; with behavior "error", an onViolation that does not return false does not allow the import (denied file and specifier). - A denied import reached only from the start entry (src/start.ts) is mocked and logged. - A split route component using a denied file, directly or through a module only the split module imports, is mocked and logged at each use (Start's whole plugin). - An .mdx module (stand-in compiler plugin) importing a denied file, directly or through a module, is mocked and logged at each importer. - A denied specifier added to an importer by an edit is a mock the edited module can import. - An edit of a CSS module and a JSON module updates without an HMR error. - A use of a specifier denied by a custom pattern rule in a JSX module is located on its source line. - Full-bundle dev (bundledDev): denied imports are mocked in the client bundle and logged (no import-protection test covered bundledDev). - With maxTraceDepth, the trace ends at the importer, each step naming the import of the next. - Control: a module the client loads after the server rendered is logged. start-apps.ts: startProtectedDevServer gains an additive `bundledDev` option. Pins (test.fails, NEW main bugs): - Dev ignores maxTraceDepth: the module graph trace replaces the cut trace (vite/import-protection-plugin/plugin.ts rebuildAndAnnotateTrace). - Dev: an .mdx (non-JS) module importing a denied specifier fails to load with "[vite] The "code" property of ModuleInfo is not supported." (resolveExportsForDeniedSpecifier falls back to getModuleInfo().code). - With server.perEnvironmentStartEndDuringDev, the first request of another environment clears every environment's state (buildStart clearEnvState), so violations of modules the client loads afterwards are discarded. Kills: spc:4741, 4763, 4524, 4639, 4579, 4609, 4611, 4174, 4182, 4529, 4530, 4536, 4542, 4510, 4551, 4552, 4553, 4556, 4497, 4494, 4474, 4484, 4037, 4043, 4045, 4047, 4049, 4052, 4054, 4056, 4059, 4070, 4071, 4076, 4077, 4078, 4081, 4085, 4086, 4088, 4089, 4090, 4094, 4098, 4099, 4103, 4107, 4110, 4114, 4115, 4213, 4035, 4036, 4039, 4040, 4042, 4048, 4061, 4063, 4065, 4074, 4075, 4092, 4096, 4100, 4111, 4212, 4214, 4215, 4217, 4221, 4222 (module graph trace and clearEnvState keys by pin flip). Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/dev.test.ts | 452 ++++++++++++++++++ .../tests/importProtection/start-apps.ts | 7 + 2 files changed, 459 insertions(+) diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index d29dad8c172..0ef03544e31 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -20,6 +20,7 @@ import { serverFnFactoryApp, startProtectedDevServer, } from './start-apps' +import { violationReports } from './violation-report' import type { ImportProtectionOptions } from '../../src/schema' import type { ViolationInfo } from '../../src/import-protection/trace' import type { Plugin } from 'vite' @@ -167,6 +168,33 @@ describe('a client import of a denied file', () => { ) }) +// Import protection checks only JavaScript and TypeScript modules; edits of +// the app's other modules update as usual. +// Source: mutation run R4 (spc:4497: the edit of a module import protection +// never compiled). +test( + 'an edit of a CSS module and a JSON module the app imports updates them without an error', + async () => { + const server = await startProtectedDevServer({ + 'src/style.css': `.page { color: red }\n`, + 'src/data.json': `{ "count": 1 }\n`, + 'src/entry.ts': `import './style.css' +import data from './data.json' +export const count = () => data.count +`, + }) + await server.load('/src/entry.ts') + const logged = server.logs.length + const updates = await server.edit({ + 'src/style.css': `.page { color: blue }\n`, + 'src/data.json': `{ "count": 2 }\n`, + }) + expect(updates.filter(({ type }) => type === 'error')).toEqual([]) + expect(server.logs.slice(logged)).toEqual([]) + }, + timeout, +) + describe('log', () => { /** * The violations logged when the client loads the app, and those logged @@ -403,6 +431,122 @@ export const request = () => getRequest() }, timeout, ) + + // Source: mutation run R4 (spc:4494: the names the mock exports, read from + // the importer, after an edit of the importer). + test( + 'added to an importer by an edit is a mock the edited module can import', + async () => { + const server = await startProtectedDevServer({ + 'src/a.ts': `import { getRequest } from '@tanstack/react-start/server' +export const read = () => String(getRequest()) +`, + 'src/entry.ts': `export * from './a'\n`, + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect((await evaluateServed(server, '/src/entry.ts')).read()).toBe( + '[import-protection mock]', + ) + await server.edit({ + 'src/a.ts': `import { getRequest, getResponse } from '@tanstack/react-start/server' +export const read = () => String(getRequest()) + String(getResponse()) +`, + }) + expect((await evaluateServed(server, '/src/entry.ts')).read()).toBe( + '[import-protection mock][import-protection mock]', + ) + }, + timeout, + ) +}) + +// A module another Vite plugin compiles to JavaScript from a file that is +// not JavaScript or TypeScript (MDX, Vue and Svelte components...). +describe('a module of another compiler (`.mdx`)', () => { + /** A stand-in of an MDX plugin: drops the `# ` heading lines. */ + const mdx: Plugin = { + name: 'mdx-stand-in', + enforce: 'pre', + transform(code, id) { + if (!id.endsWith('.mdx')) { + return undefined + } + return { code: code.replace(/^# .*$/gm, ''), map: null } + }, + } + + // Source: mutation run R4 (spc:4639: the module has no compiled imports + // import protection could check its violations against; spc:4579: nor + // can it tell whether its imports survived compiling, so a module it + // imports is reachable through it). + test( + 'importing a denied file, directly or through a module, gets the mock, and the dev server logs a warning at each importer', + async () => { + const leak = `import { secret } from './secret.server' +export const leak = () => secret() +` + const server = await startProtectedDevServer( + { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/leak.ts': leak, + 'src/page.mdx': `# Title +import { secret } from './secret.server' +import { leak } from './leak' +export const read = () => secret() + leak() +`, + 'src/entry.ts': `export * from './page.mdx'\n`, + }, + {}, + { plugins: [mdx] }, + ) + const client = await server.load('/src/entry.ts') + expect(Object.keys(client)).toContain('/src/page.mdx') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + const importers = reportedViolations(server.logs).map( + ({ importer }) => importer, + ) + expect(importers).toContainEqual(expect.stringMatching(/^src\/page\.mdx/)) + expect(importers).toContain( + locationIn('src/leak.ts', leak, 2, 'secret()'), + ) + }, + timeout, + ) + + // Bug: to give the mock of a denied specifier the named exports its + // importer imports, import protection reads the importer's code from its + // own cache of compiled modules, which only has JavaScript and TypeScript + // files, and otherwise from Vite's `getModuleInfo(importer).code`, which + // Vite's dev server does not support: it throws "[vite] The "code" + // property of ModuleInfo is not supported." + // (vite/import-protection-plugin/plugin.ts + // `resolveExportsForDeniedSpecifier`). + // Impact: in dev, an `.mdx` (or other non-JS) module that imports a denied + // specifier fails to load with that internal error, instead of getting + // the mock and a warning. + // Source: found by mutation run R4 (spc:4365-4369). + test.fails( + 'importing a denied specifier gets the mock, and the dev server logs a warning naming it', + async () => { + const server = await startProtectedDevServer( + { + 'src/page.mdx': `# Title +import { getRequest } from '@tanstack/react-start/server' +export const request = () => getRequest() +`, + 'src/entry.ts': `export * from './page.mdx'\n`, + }, + {}, + { plugins: [mdx] }, + ) + const module = await evaluateServed(server, '/src/entry.ts') + expect(String(module.request())).toBe('[import-protection mock]') + expect( + reportedViolations(server.logs).map(({ importer }) => importer), + ).toContainEqual(expect.stringMatching(/^src\/page\.mdx/)) + }, + timeout, + ) }) describe('onViolation', () => { @@ -453,6 +597,58 @@ export const read = () => getRequest() }, timeout, ) + + // Only `false` overrides the denial (guide/import-protection.md): an + // `onViolation` that only records (returns nothing) changes nothing. + // Source: mutation run R4 (spc:4741). + test( + 'that returns nothing keeps the warning; it receives the importer', + async () => { + const importers: Array = [] + const server = await startProtectedDevServer(deniedFileApp, { + onViolation: (info) => { + importers.push((info as ViolationInfo).importer) + }, + }) + const client = await server.load('/src/entry.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([`client ${deniedFileUse}`]) + expect( + distinct( + importers.map((importer) => path.relative(server.root, importer)), + ), + ).toEqual(['src/a.ts']) + }, + timeout, + ) + + // Source: mutation run R4 (spc:4763). + test.each([ + { name: 'a denied file', files: deniedFileApp, use: deniedFileUse }, + { + 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`, + }, + use: 'src/a.ts:2:27', + }, + ])( + 'with behavior "error", an import of $name it does not return false for fails to load', + async ({ files, use }) => { + const server = await startProtectedDevServer(files, { + behavior: 'error', + onViolation: () => true, + }) + const error = await rejection(server.load('/src/entry.ts')) + expect(reportedViolations(error.message)).toEqual([ + { environment: 'client', importer: use }, + ]) + }, + timeout, + ) }) describe('a client import of a server-only-marked module', () => { @@ -623,6 +819,30 @@ describe('reachability from the router entry', () => { timeout, ) + // The start entry (`src/start.ts`) is an entry like the router entry: the + // client loads it, and so what it imports. + // Source: mutation run R4 (spc:4524). + test( + 'a denied import reached only from the start entry is mocked and logged', + async () => { + const server = await startProtectedDevServer({ + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/leak.ts': `import { secret } from './secret.server' +export const leak = () => secret() +`, + 'src/start.ts': `import { leak } from './leak' +export const startInstance = { leak } +`, + 'src/entry.ts': `export const getRouter = () => undefined\n`, + }) + const client = await server.load('/src/start.ts') + expect(Object.keys(client)).toContain('/src/leak.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 @@ -781,6 +1001,61 @@ export const q = () => [read(), request(), slow] }, timeout, ) + + /** + * `src/late.ts` uses the denied file `src/secret.server.ts`; `src/a.ts`, + * which the router entry re-exports, imports it dynamically. The client + * loads the app, the server renders it (`ssr`), then the client loads + * `src/late.ts`. The violations logged. + */ + async function logsOfALateModule(perEnvironmentStartEndDuringDev: boolean) { + const server = await startProtectedDevServer( + { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/late.ts': `import { secret } from './secret.server' +export const read = () => secret() +`, + 'src/a.ts': `export const late = () => import('./late')\n`, + 'src/entry.ts': `export * from './a'\n`, + }, + {}, + { config: { server: { perEnvironmentStartEndDuringDev } } }, + ) + await server.load('/src/entry.ts') + await server.load('/src/entry.ts', { environment: 'ssr' }) + await server.load('/src/late.ts') + return loggedViolations(server.logs) + } + + // Control for the pin below (same harness). + test( + 'a denied import of a module the client loads after the server rendered is logged', + async () => { + expect(await logsOfALateModule(false)).toEqual([ + 'client src/late.ts:2:27', + ]) + }, + timeout, + ) + + // Bug: with Vite's `server.perEnvironmentStartEndDuringDev`, each + // environment runs `buildStart` when it first handles a module, and import + // protection's `buildStart` clears the state of every environment + // (vite/import-protection-plugin/plugin.ts `clearEnvState` for each), so + // the server's first request drops the client's import graph: a module the + // client loads afterwards is unreachable from Start's entries and its + // violations are discarded. + // Impact: with that Vite option, dev import protection mocks the denied + // imports of modules loaded after the first server request without any + // warning (low: opt-in option). + // Source: found by mutation run R4 (clearEnvState mutants). + test.fails( + 'with perEnvironmentStartEndDuringDev, a denied import of a module the client loads after the server rendered is logged', + async () => { + expect(await logsOfALateModule(true)).toEqual(['client src/late.ts:2:27']) + }, + timeout, + ) }) describe("an app's client entry, with Start's whole plugin", () => { @@ -860,6 +1135,88 @@ export const leak = () => secret() ) }) +// The router plugin splits a route's component into a module of its own, +// which the route module imports dynamically (the browser loads it when the +// route renders). +describe("a split route component, with Start's whole plugin", () => { + // Source: mutation run R4 (spc:4529 and the cache of compiled split + // modules: the denied import, and the import of the module using the + // denied file, are only in the split module). + test( + 'using a denied file, directly or through a module, gets the mock, and the dev server logs a warning at each use', + async () => { + const route = `import { createFileRoute } from '@tanstack/react-router' +import { secret } from '../secret.server' +import { widget } from '../widget' +export const Route = createFileRoute('/')({ + component: () => String(secret()) + widget(), +}) +` + const widget = `import { secret } from './secret.server' +export const widget = () => String(secret()) +` + const server = await startDevServer({ + start: {}, + files: { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/widget.ts': widget, + 'src/routes/index.tsx': route, + 'src/router.ts': `import { routeTree } from './routeTree.gen' +export const getRouter = () => ({ routeTree }) +`, + }, + config: { server: { hmr: false } }, + }) + const app = await server.load('/src/router.ts') + // (the route module's dynamic imports: its split modules) + const split = [ + ...app['/src/routes/index.tsx']!.matchAll(/\bimport\("([^"]+)"\)/g), + ].map((match) => match[1]!) + expect(split).not.toEqual([]) + for (const url of split) { + const served = await server.load(url) + expect(modulesContaining(served, 'MARK_SECRET')).toEqual([]) + } + expect(loggedViolations(server.logs).sort()).toEqual([ + `client ${locationIn('src/routes/index.tsx', route, 5, 'secret()')}`, + `client ${locationIn('src/widget.ts', widget, 2, 'secret()')}`, + ]) + }, + timeout, + ) +}) + +// Vite's full-bundle dev (`experimental.bundledDev`): Rolldown's dev engine +// bundles the client, transforming modules before it knows all their +// importers. +describe('full-bundle dev (bundledDev)', () => { + // Source: mutation run R4 (full-bundle dev had no import-protection test). + test( + 'denied imports are mocked in the client bundle, and the dev server logs a warning at each use', + async () => { + const x = `import { getRequest } from '@tanstack/react-start/server' +export const request = () => getRequest() +` + const server = await startProtectedDevServer( + { + ...deniedFileApp, + 'src/x.ts': x, + 'src/entry.ts': `export * from './a'\nexport * from './x'\n`, + }, + {}, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + const bundle = await server.clientBundle() + expect(modulesContaining(bundle, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs).sort()).toEqual([ + `client ${deniedFileUse}`, + `client ${locationIn('src/x.ts', x, 2, 'getRequest()')}`, + ]) + }, + 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). @@ -913,4 +1270,99 @@ export const Page = () =>
    {String(1)}
    }, timeout, ) + + // Source: mutation run R4 (spc:4174: a pattern rule cannot be found in + // the module's code as text, unlike the specifiers of the rows above). + test( + 'in a JSX module, a use of a specifier denied by a custom pattern rule (client) is located on its source line', + async () => { + const server = await startProtectedDevServer( + { + ...reactRuntimeFiles, + 'node_modules/@acme/private/package.json': JSON.stringify({ + name: '@acme/private', + type: 'module', + exports: { '.': './index.js' }, + }), + 'node_modules/@acme/private/index.js': `export const secret = () => 'MARK_SECRET'\n`, + 'src/a.tsx': `import { secret } from '@acme/private' +export const Page = () =>
    {String(secret())}
    +`, + 'src/entry.ts': `export * from './a'\n`, + }, + { client: { specifiers: ['@acme/*'] } }, + ) + await server.load('/src/entry.ts') + expect(loggedViolations(server.logs)).toEqual(['client src/a.tsx:2:40']) + }, + timeout, + ) + + /** + * The client imports `src/secret.server.ts` through a chain of modules + * (`src/b.ts` uses it). + */ + const chainApp = { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/b.ts': `import { secret } from './secret.server' +export const read = () => secret() +`, + 'src/a.ts': `import { read } from './b'\nexport const page = () => read()\n`, + 'src/entry.ts': `export * from './a'\n`, + } + + /** The trace steps of each warning logged when the client loads the app. */ + async function tracesOf(importProtection: ImportProtectionOptions) { + const server = await startProtectedDevServer(chainApp, importProtection) + await server.load('/src/entry.ts') + const reports = violationReports(server.logs) + expect(reports).not.toEqual([]) + return reports.map(({ trace }) => + trace.map(({ at, imports }) => ({ + file: at.replace(/:\d+:\d+$/, ''), + imports, + })), + ) + } + + // Source: mutation run R4 (the trace from Vite's module graph, which dev + // import protection prefers when it is longer than its own: here, its own + // stops at `maxTraceDepth`). + test( + 'with maxTraceDepth, the trace ends at the importer, each step naming the import of the next', + async () => { + for (const trace of await tracesOf({ maxTraceDepth: 1 })) { + expect(trace.at(-1)?.file).toBe('src/b.ts') + for (const [index, step] of trace.slice(0, -1).entries()) { + expect(step.imports).toBe( + `./${path.basename(trace[index + 1]!.file, '.ts')}`, + ) + } + } + }, + timeout, + ) + + // Bug: dev import protection replaces its trace (cut at `maxTraceDepth`) + // with the trace it finds in Vite's module graph whenever that one is + // longer (vite/import-protection-plugin/plugin.ts + // `rebuildAndAnnotateTrace`); the module graph trace runs from the entry + // and is only cut at `maxTraceDepth + 2` steps, from the entry side (so in + // a chain deeper than that, it does not reach the importer). A build cuts + // the trace, keeping the importer (build-options.test.ts "cuts a deeper + // trace, keeping the importer"). + // Impact: in dev, `maxTraceDepth` does not cut traces (low: diagnostics + // only). + // Source: found by mutation run R4 (module graph trace mutants). + test.fails( + 'with maxTraceDepth, a deeper trace is cut, keeping the importer', + async () => { + for (const trace of await tracesOf({ maxTraceDepth: 1 })) { + expect(trace.length).toBeLessThanOrEqual(2) + expect(trace.at(-1)?.file).toBe('src/b.ts') + expect(trace.map(({ file }) => file)).not.toContain('src/entry.ts') + } + }, + timeout, + ) }) diff --git a/packages/start-plugin-core/tests/importProtection/start-apps.ts b/packages/start-plugin-core/tests/importProtection/start-apps.ts index eb213bfd49a..aaa689c63f9 100644 --- a/packages/start-plugin-core/tests/importProtection/start-apps.ts +++ b/packages/start-plugin-core/tests/importProtection/start-apps.ts @@ -33,6 +33,11 @@ export interface ProtectedDevServerOptions { plugins?: Array /** Vite configuration of the app (e.g. `server.warmup`). */ config?: StartDevServerOptions['config'] + /** + * Vite's full-bundle dev (`experimental.bundledDev`): the client is + * bundled from this entry (see `startDevServer`). + */ + bundledDev?: StartDevServerOptions['bundledDev'] } /** @@ -51,9 +56,11 @@ export function startProtectedDevServer( providerEnvironment = 'ssr', plugins = [], config = {}, + bundledDev, } = options return startDevServer({ files, + bundledDev, config: providerEnvironment === 'ssr' ? config From 02c896188f2073631427223c74de7052f14f6baa Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:46:04 +0200 Subject: [PATCH 0327/1425] test: recall tests for source map names, the dev race and cached rebuilds - Y-names: a minified Vite client chunk's map names a renamed split component parameter at its original position (namedSegments helper in compiler-test-utils, with a control). - Dev race (known-bugs-dev-server): rows split; the hybrid shape (removing a helper of an exported component) is a plain test, main passes it; the removing/adding rows stay `.fails`. - Cached rebuild: webpack/Rspack watch builds (new `rebuildAfter` harness option, memory cache, with a control) keep an exported component that a split component renders lazy after an edit. Co-Authored-By: Claude Opus 5.5 --- .../compiler-test-utils/src/build-harness.ts | 139 ++++++++++++++++++ .../tests/build-harness.test.ts | 30 ++++ .../tests/build-source-maps.test.ts | 42 +++++- .../tests/code-splitter-robustness.test.ts | 39 +++++ .../tests/known-bugs-dev-server.test.ts | 63 ++++++-- 5 files changed, 300 insertions(+), 13 deletions(-) diff --git a/packages/compiler-test-utils/src/build-harness.ts b/packages/compiler-test-utils/src/build-harness.ts index 3c088aa5aa9..149e4305f6f 100644 --- a/packages/compiler-test-utils/src/build-harness.ts +++ b/packages/compiler-test-utils/src/build-harness.ts @@ -152,6 +152,13 @@ export interface BundleTarget { * defaults (e.g. `solid`, for packages that ship JSX source). */ conditions?: Array + /** + * webpack and Rspack: build in watch mode with a memory cache, then write + * these files (by path relative to the app root) and describe the output + * of the cached rebuild they cause, as a dev or `--watch` build would emit + * after an edit. + */ + rebuildAfter?: Record } export interface RunResult { @@ -323,6 +330,7 @@ interface BundlerConfig { jsxImportSource: string sourceRules: Array> | undefined conditions: Array + rebuildAfter: Record | undefined } /** An emitted chunk, as a bundler reports it. */ @@ -360,6 +368,10 @@ async function bundle( jsxImportSource: target.jsxImportSource ?? 'react', sourceRules: target.sourceRules, conditions: target.conditions ?? [], + rebuildAfter: target.rebuildAfter, + } + if (config.rebuildAfter && bundler !== 'webpack' && bundler !== 'rspack') { + throw new Error(`rebuildAfter: not supported for ${bundler}`) } const warnings: Array = [] const consoleMessages: Array = [] @@ -598,6 +610,22 @@ async function buildWithWebpackApi( plugins: config.plugin ? [config.plugin()] : [], } + if (config.rebuildAfter) { + const stats = await watchAndRebuild( + bundler, + { ...options, cache: bundler === 'rspack' ? true : { type: 'memory' } }, + config.root, + config.rebuildAfter, + warnings, + ) + const json = stats.toJson(statsOptions) + warnings.push(...statsMessages(json.warnings)) + if (json.errors?.length) { + throw new BuildError(bundler, statsMessages(json.errors), warnings) + } + return chunksOfStats(json, { entry: 'entry', extension: '.mjs' }) + } + const stats = await new Promise((resolve, reject) => { const run = (compiler: WebpackCompiler) => compiler.run((error, result) => { @@ -628,6 +656,63 @@ async function buildWithWebpackApi( return chunksOfStats(json, { entry: 'entry', extension: '.mjs' }) } +/** + * Builds in the compiler's watch mode (`compiler.watch`, with `options`' + * cache), waits for the builds to settle, writes `files` (paths relative to + * `root`), and returns the stats of the last build once the rebuilds they + * cause settle. Fails after 30 s without a settled rebuild. + */ +async function watchAndRebuild( + bundler: 'webpack' | 'rspack', + options: Record, + root: string, + files: Record, + warnings: Array, +): Promise { + const compiler = (bundler === 'rspack' + ? (await import('@rsbuild/core')).rspack(options as never) + : (await import('webpack')).default( + options as never, + )) as unknown as WebpackCompiler & { + watch: ( + options: { aggregateTimeout: number }, + callback: (error: Error | null, stats?: WebpackStats) => void, + ) => { close: (callback: () => void) => void } + } + let builds = 0 + let last: { error: Error | null; stats?: WebpackStats } | undefined + const watching = compiler.watch({ aggregateTimeout: 50 }, (error, stats) => { + builds++ + last = { error, stats } + }) + const deadline = Date.now() + 30_000 + /** Waits for a build after the `after`th, then for 500 ms without one. */ + const settle = async (after: number) => { + let seen = -1 + while (builds <= after || seen !== builds) { + if (Date.now() > deadline) { + throw new BuildError(bundler, ['timed out waiting for a build'], []) + } + seen = builds > after ? builds : -1 + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + try { + await settle(0) + const before = builds + for (const [file, code] of Object.entries(files)) { + await writeFile(path.join(root, file), code) + } + await settle(before) + } finally { + await new Promise((resolve) => watching.close(() => resolve())) + } + if (last!.error) { + throw new BuildError(bundler, [errorMessage(last!.error)], warnings) + } + return last!.stats! +} + /** The `toJson` options of the stats `chunksOfStats` reads. */ export const statsOptions = { all: false, @@ -869,6 +954,60 @@ export async function describeBuild( } } +/** + * The segments of a source map that carry a name (`names`), with the + * original position each maps to: `source` as the map names it, `line` + * 1-based, `column` 0-based (source map columns). + */ +export function namedSegments( + map: SourceMap, +): Array<{ name: string; source: string; line: number; column: number }> { + const base64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + const segments: Array<{ + name: string + source: string + line: number + column: number + }> = [] + // Running values of the source index, line, column and name index. + const state = [0, 0, 0, 0] + for (const line of map.mappings.split(';')) { + for (const segment of line.split(',')) { + if (!segment) { + continue + } + const fields: Array = [] + let value = 0 + let shift = 0 + for (const char of segment) { + const digit = base64.indexOf(char) + value += (digit & 31) << shift + if (digit & 32) { + shift += 5 + } else { + fields.push(value & 1 ? -(value >>> 1) : value >>> 1) + value = 0 + shift = 0 + } + } + // fields[0] is the generated column. + for (let index = 1; index < fields.length; index++) { + state[index - 1]! += fields[index]! + } + if (fields.length === 5) { + segments.push({ + name: map.names[state[3]!]!, + source: map.sources[state[0]!]!, + line: state[1]! + 1, + column: state[2]!, + }) + } + } + } + return segments +} + /** A position in an original source file, according to a source map. */ export interface OriginalPosition { /** The source as the source map names it. */ diff --git a/packages/compiler-test-utils/tests/build-harness.test.ts b/packages/compiler-test-utils/tests/build-harness.test.ts index f9c2da8dc8e..7d7802e0a6b 100644 --- a/packages/compiler-test-utils/tests/build-harness.test.ts +++ b/packages/compiler-test-utils/tests/build-harness.test.ts @@ -11,6 +11,7 @@ import { bundleApp as bundleAppIn, bundlers, createApp, + namedSegments, originalFrameOf, } from '../src/build-harness' import type { BundleOptions, Placement } from '../src/build-harness' @@ -170,6 +171,35 @@ test('NODE_ENV is the mode during a build, or unset with nodeEnv: null', async ( expect(seen).toEqual({ undefined: 'production', null: undefined }) }) +test.each(['webpack', 'rspack'] as const)( + '%s: rebuildAfter describes the output of the rebuild after the edit', + async (bundler) => { + const app = await bundleApp({ + bundler, + files: { 'entry.ts': `export const value = 'v1'\n` }, + rebuildAfter: { 'entry.ts': `export const value = 'v2'\n` }, + }) + expect((await app.run('return entry.value')).result).toBe('v2') + }, + timeout, +) + +// Two named segments: `a` at line 1 column 0 of `x.ts`, `b` at line 2 +// column 4 of `y.ts`; one segment without a name. +test('namedSegments decodes the named segments of a source map', () => { + expect( + namedSegments({ + version: 3, + sources: ['x.ts', 'y.ts'], + names: ['a', 'b'], + mappings: 'AAAAA,EAAA;ACCIC', + }), + ).toEqual([ + { name: 'a', source: 'x.ts', line: 1, column: 0 }, + { name: 'b', source: 'y.ts', line: 2, column: 4 }, + ]) +}) + describe('side-effect hints', () => { /** `./effects` is imported for an export the entry never uses. */ const unusedModuleApp = { diff --git a/packages/router-plugin/tests/build-source-maps.test.ts b/packages/router-plugin/tests/build-source-maps.test.ts index 1eef6136763..4eeb1ede692 100644 --- a/packages/router-plugin/tests/build-source-maps.test.ts +++ b/packages/router-plugin/tests/build-source-maps.test.ts @@ -7,7 +7,10 @@ * "source maps".) */ import { describe, expect, test } from 'vitest' -import { originalFrameOf } from '@tanstack/compiler-test-utils/build-harness' +import { + namedSegments, + originalFrameOf, +} from '@tanstack/compiler-test-utils/build-harness' import { build, routeApp, routeHead } from './build-harness' import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' @@ -157,6 +160,43 @@ export const Route = createFileRoute('/')({ }, timeout, ) + + // Devtools and error trackers show a minified variable by its original + // name through the map's `names`. Source: the #8504 probe (Y-names). + test( + 'a minified split component parameter keeps its name in the map', + async () => { + const source = `${routeHead()}function Page(props: { title?: string }) { + return

    {props.title ?? 'MARK_NAMED_PAGE'}

    +} +export const Route = createFileRoute('/')({ component: Page }) +` + const app = await build({ + bundler: 'vite', + env: 'client', + minify: true, + sourcemap: true, + files: routeApp(source, { env: 'client' }), + }) + const chunk = app.chunks.find((candidate) => + candidate.code.includes('MARK_NAMED_PAGE'), + )! + // The minifier renamed the parameter. + expect(chunk.code).not.toMatch(/\bprops\b/) + const line = lineOf(source, 'function Page(props') + const column = source.split('\n')[line - 1]!.indexOf('props') + expect( + namedSegments(chunk.map!) + .filter( + (segment) => + segment.name === 'props' && + segment.source.split('?')[0]!.endsWith('routes/index.tsx'), + ) + .map((segment) => [segment.line, segment.column]), + ).toContainEqual([line, column]) + }, + timeout, + ) }) // Bug: unplugin's webpack and Rspack transform loaders drop the source map diff --git a/packages/router-plugin/tests/code-splitter-robustness.test.ts b/packages/router-plugin/tests/code-splitter-robustness.test.ts index 8b86106a0cc..45bec8778f6 100644 --- a/packages/router-plugin/tests/code-splitter-robustness.test.ts +++ b/packages/router-plugin/tests/code-splitter-robustness.test.ts @@ -60,3 +60,42 @@ export const Route = createFileRoute('/café/日本語')({ }, 120_000, ) + +// A watch build (webpack-dev-server, `--watch`) rebuilds from its cache after +// an edit: the split stays as in a fresh build. Source: the #8504 probe (a +// module marked side-effect free lost the mark on a cached rebuild). +test.each(['rspack', 'webpack'] as const)( + 'an exported component a split component renders still loads with its chunk after a cached rebuild (%s)', + async (bundler) => { + const route = (version: string) => `${head}export function Card() { + return {'MARK_CARD_END'} +} +function Page() { + return

    {'${version}'}

    +} +export const Route = createFileRoute('/')({ component: Page }) +` + const app = await build({ + bundler, + env: 'client', + files: { + 'routes/__root.tsx': rootRoute, + 'routes/index.tsx': route('v1'), + // Like the route tree, the entry imports only `Route`. + 'entry.ts': clientMountEntry.replace( + "export * from './routes/index'", + "export { Route } from './routes/index'", + ), + }, + rebuildAfter: { 'routes/index.tsx': route('v2') }, + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component)`, + ) + expect({ card: app.where('MARK_CARD_END'), html: run.html }).toEqual({ + card: 'lazy', + html: '

    MARK_CARD_ENDv2

    ', + }) + }, + 120_000, +) diff --git a/packages/router-plugin/tests/known-bugs-dev-server.test.ts b/packages/router-plugin/tests/known-bugs-dev-server.test.ts index 29dcbb7fc93..652d74c4a7f 100644 --- a/packages/router-plugin/tests/known-bugs-dev-server.test.ts +++ b/packages/router-plugin/tests/known-bugs-dev-server.test.ts @@ -103,25 +103,27 @@ export const Route = createFileRoute('/page')({ /** * Loads the route modules in order (the URLs the route module imports, as - * the first load finds them), edits the route from `twoCards` to - * `oneCard`, requests them again in the order `reorder` gives, and compares - * them with what a dev server started after the edit compiles. + * the first load finds them), edits the route from `before` to `after` + * (default `twoCards` to `oneCard`), requests them again in the order + * `reorder` gives, and compares them with what a dev server started after + * the edit compiles. */ async function editAndCompare( reorder: (urls: Array) => Array, + { before = twoCards, after = oneCard } = {}, ) { const server = await routerDevServer({ - files: { 'routes/page.tsx': twoCards }, + files: { 'routes/page.tsx': before }, }) // The page loads: the route module first, then the modules it imports const urls = await routeModuleUrls(server) expect(urls.length).toBeGreaterThan(1) await inspect(server, urls, urls) - await server.edit({ 'routes/page.tsx': oneCard }) + await server.edit({ 'routes/page.tsx': after }) const edited = await inspect(server, reorder(urls), urls) const fresh = await routerDevServer({ - files: { 'routes/page.tsx': oneCard }, + files: { 'routes/page.tsx': after }, }) const expected = await inspect(fresh, urls, urls) for (const module of Object.values(expected)) { @@ -151,13 +153,50 @@ export const Route = createFileRoute('/page')({ // exported ("Export 'CardB' is not defined") and the page stays broken until // the next edit or a restart; an added one is copied into each chunk instead // of shared (two module states). - // Source: gap DV8 (gaps/dev-hmr.md); #8504 fixes it and tests it in - // code-splitter-plugin-pipeline.test.ts ("a dev server compiling an edited - // route out of order"). - test.fails( - 'compiles the shared module and chunks requested before the route module from the edited source', + // Source: gap DV8 (gaps/dev-hmr.md); #8504 code-splitter-plugin-pipeline + // .test.ts ("a dev server compiling an edited route out of order"). + test.fails.each([ + { + name: 'removing a component two split options render', + before: twoCards, + after: oneCard, + }, + { + name: 'adding a component two split options render', + before: oneCard, + after: twoCards, + }, + ])( + 'compiles the shared module and chunks requested before the route module from the edited source after $name', + async ({ before, after }) => { + await editAndCompare((urls) => [...urls.slice(1), route], { + before, + after, + }) + }, + timeout, + ) + + // The same race for an exported component a split component renders, + // whose private helper the edit removes: main keeps the exported component + // and its helpers in the route module, so nothing is stored for them. + // An implementation that moves them to a module the chunk shares must not + // reach the race. Source: the #8504 probe (hybrid shared modules; #8504 + // code-splitter-plugin-pipeline.test.ts, first row). + test( + 'compiles the modules requested before the route module from the edited source after removing a helper of an exported component a split component renders', async () => { - await editAndCompare((urls) => [...urls.slice(1), route]) + await editAndCompare((urls) => [...urls.slice(1), route], { + before: `${head}const prefix = 'a' +const suffix = 'b' +export function Card() { return {prefix}{suffix} } +export const Route = createFileRoute('/page')({ component: () => }) +`, + after: `${head}const prefix = 'a' +export function Card() { return {prefix} } +export const Route = createFileRoute('/page')({ component: () => }) +`, + }) }, timeout, ) From f53ea7ba37c868732112eb5f4cdcae9bb4653a20 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:47:19 +0200 Subject: [PATCH 0328/1425] test(start-plugin-core): one seam module for the compilerTransforms test transforms renderThingTransform (regression-helpers.ts) and the warning transform (start-build-warnings.test.ts) move to tests/compiler-transforms-seam.ts, the only test file that binds to the AST shape of the `compilerTransforms` hook. Behaviour unchanged; regression-helpers re-exports renderThingTransform. Co-Authored-By: Claude Opus 5.5 --- .../tests/compiler-transforms-seam.ts | 60 +++++++++++++++++++ .../tests/regression-helpers.ts | 31 +--------- .../tests/start-build-warnings.test.ts | 20 +------ 3 files changed, 63 insertions(+), 48 deletions(-) create mode 100644 packages/start-plugin-core/tests/compiler-transforms-seam.ts diff --git a/packages/start-plugin-core/tests/compiler-transforms-seam.ts b/packages/start-plugin-core/tests/compiler-transforms-seam.ts new file mode 100644 index 00000000000..6f9701596f2 --- /dev/null +++ b/packages/start-plugin-core/tests/compiler-transforms-seam.ts @@ -0,0 +1,60 @@ +/** + * The test transforms given to the Start plugin's `compilerTransforms` hook + * (`StartCompilerImportTransform`, src/types.ts). + * + * This is the ONLY test file that binds to the AST shape the hook hands to a + * transform (today Babel's: `candidate.path.node`, `context.types`, + * `context.parseExpression`). A compiler rewrite that changes that shape + * adapts this file alone; every test uses these transforms through their + * observable effect (the injected argument, the warning), never through the + * AST. + */ +import type { StartCompilerImportTransform } from '../src/types' + +/** + * An external import transform (`compilerTransforms`), shaped like the RSC + * CSS transform: on the server, it injects `{ injected: loadThing() }` as + * the second argument of each direct call of `renderThing` from + * `@example/runtime` given one argument. + */ +export const renderThingTransform: StartCompilerImportTransform = { + name: 'test-render-option-injection', + environment: 'server', + imports: [{ libName: '@example/runtime', rootExport: 'renderThing' }], + detect: /\brenderThing\b/, + transform: (candidates, context) => { + const t = context.types + for (const candidate of candidates) { + const args = candidate.path.node.arguments + if (args.length !== 1) { + continue + } + args.push( + t.objectExpression([ + t.objectProperty( + t.identifier('injected'), + context.parseExpression('loadThing()'), + ), + ]), + ) + } + }, +} + +/** + * A server transform of `warnMe()` calls from `warn-lib` that warns about + * each call it cannot handle (one with an argument). + */ +export const warnMeTransform: StartCompilerImportTransform = { + name: 'test-warning-transform', + environment: 'server', + imports: [{ libName: 'warn-lib', rootExport: 'warnMe' }], + detect: /\bwarnMe\b/, + transform: (candidates, context) => { + for (const candidate of candidates) { + if (candidate.path.node.arguments.length > 0) { + context.warn?.(`MARK_WARNING: cannot handle warnMe() in ${context.id}`) + } + } + }, +} diff --git a/packages/start-plugin-core/tests/regression-helpers.ts b/packages/start-plugin-core/tests/regression-helpers.ts index 2bc84acaf6d..b68a6134e6a 100644 --- a/packages/start-plugin-core/tests/regression-helpers.ts +++ b/packages/start-plugin-core/tests/regression-helpers.ts @@ -373,35 +373,8 @@ export async function compileServer( } } -/** - * An external import transform (`compilerTransforms`), shaped like the RSC - * CSS transform: on the server, it injects `{ injected: loadThing() }` as - * the second argument of each direct call of `renderThing` from - * `@example/runtime` given one argument. - */ -export const renderThingTransform: StartCompilerImportTransform = { - name: 'test-render-option-injection', - environment: 'server', - imports: [{ libName: '@example/runtime', rootExport: 'renderThing' }], - detect: /\brenderThing\b/, - transform: (candidates, context) => { - const t = context.types - for (const candidate of candidates) { - const args = candidate.path.node.arguments - if (args.length !== 1) { - continue - } - args.push( - t.objectExpression([ - t.objectProperty( - t.identifier('injected'), - context.parseExpression('loadThing()'), - ), - ]), - ) - } - }, -} +// The `compilerTransforms` test transform lives in the seam module. +export { renderThingTransform } from './compiler-transforms-seam' /** * Project-local sources of a module's import and re-export declarations 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 4cefbbeea4a..7cac434cf4d 100644 --- a/packages/start-plugin-core/tests/start-build-warnings.test.ts +++ b/packages/start-plugin-core/tests/start-build-warnings.test.ts @@ -8,28 +8,10 @@ */ import { describe, expect, test } from 'vitest' import { buildStart, startBundlers } from './build-harness' -import type { StartCompilerImportTransform } from '../src/types' +import { warnMeTransform as warningTransform } from './compiler-transforms-seam' const timeout = 60_000 -/** - * A server transform of `warnMe()` calls from `warn-lib` that warns about - * each call it cannot handle (one with an argument). - */ -const warningTransform: StartCompilerImportTransform = { - name: 'test-warning-transform', - environment: 'server', - imports: [{ libName: 'warn-lib', rootExport: 'warnMe' }], - detect: /\bwarnMe\b/, - transform: (candidates, context) => { - for (const candidate of candidates) { - if (candidate.path.node.arguments.length > 0) { - context.warn?.(`MARK_WARNING: cannot handle warnMe() in ${context.id}`) - } - } - }, -} - const files = { 'node_modules/warn-lib/package.json': JSON.stringify({ name: 'warn-lib', From d73bcdc4d4f0105e4140be80b0ebceaec699ebd4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 19:58:23 +0200 Subject: [PATCH 0329/1425] test(router-plugin): type the rendered HTML of the duplicate Route build test Co-Authored-By: Claude Opus 5.5 --- .../tests/code-splitter-plugin-pipeline.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 d3ef6b1c5fd..da1138dc24e 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -460,9 +460,10 @@ export const Route = createFileRoute('/')({ component: () =>

    page

    }) { env: 'client', files: { 'other.ts': 'export const Route = 1\n' } }, ), }) - outcome = ( - await app.run(`await entry.mount(entry.Route.options.component)`) - ).html + outcome = String( + (await app.run(`await entry.mount(entry.Route.options.component)`)) + .html, + ) } catch (error) { if (error instanceof BuildError && /\bRoute\b/.test(error.message)) { outcome = 'refused naming Route' From 9c4f41eda707e3e23dede130d71da8c327991e8f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 20:22:49 +0200 Subject: [PATCH 0330/1425] test(compiler-test-utils): check classic JSX pragma modules with the classic runtime The module validator compiled JSX with React's automatic runtime unless the module set `@jsxRuntime`. Oxc then ignores a lone `@jsx`/`@jsxFrag` pragma, so a module that names a factory it never declares passed validation. Under the automatic runtime such a pragma is ignored (Oxc, TypeScript, esbuild with a warning) or rejected (SWC, Babel), so the module only works as written in a classic-runtime project. The validator now uses the classic runtime for a module with `@jsx`/`@jsxFrag` and no `@jsxRuntime`/ `@jsxImportSource` pragma; Oxc still reads the module's own leading pragmas. Controls: classic pragma with the factory declared or imported (accepted), factory or fragment missing (rejected), a pragma below an import (default factory, rejected), automatic runtime unchanged (no pragma, explicit `@jsxRuntime automatic`, `@jsxImportSource`). No test changed status in router-plugin, start-plugin-core, react-start-rsc or router-generator. One pin now fails earlier for the same bug: known-bugs-hydrate "client: a module with a classic JSX pragma renders through its factory" (the validator reports `React`; comment updated). Co-Authored-By: Claude Opus 5.5 --- .../src/validate-module.ts | 48 +++++++++++- .../tests/validate-module.test.ts | 77 +++++++++++++++++++ .../tests/known-bugs-hydrate.test.ts | 5 +- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/compiler-test-utils/src/validate-module.ts b/packages/compiler-test-utils/src/validate-module.ts index 51b1a05ba86..0af026a986b 100644 --- a/packages/compiler-test-utils/src/validate-module.ts +++ b/packages/compiler-test-utils/src/validate-module.ts @@ -12,7 +12,8 @@ import type { ESTree } from 'vite' * * It also rejects references to names that are neither declared nor imported * in the module, once JSX is compiled the way a bundler compiles it (so a - * classic `@jsx` pragma reads its factory): such a reference throws a + * classic `@jsx` or `@jsxFrag` pragma reads its factory or fragment, and + * `@jsxImportSource` its runtime module): such a reference throws a * ReferenceError when it runs. Known globals of browsers and Node.js, names * the module probes with `typeof`, and `globals` (e.g. the globals the route * file itself reads) are allowed. @@ -51,7 +52,8 @@ export async function getModuleErrors( * Names a module references without declaring or importing them, other than * known globals and names it probes with `typeof`, once TypeScript is erased * and JSX compiled with the project default (React's automatic runtime) or - * the module's own JSX pragmas. + * the module's own JSX pragmas (a `@jsx` or `@jsxFrag` pragma alone selects + * the classic runtime, see `jsxRuntime`). * * `deadCode: false` leaves out names referenced only inside code that never * runs (`getModuleErrors` does: such a reference throws nothing). Dead code @@ -69,7 +71,7 @@ export async function freeIdentifiers( options: { deadCode?: boolean } = {}, ): Promise> { const { code: javascript } = await transformWithOxc(code, filename, { - jsx: { runtime: 'automatic' }, + jsx: { runtime: jsxRuntime(code, filename) }, typescript: { onlyRemoveTypeImports: true }, }) const { program } = parseSync('module.js', javascript, { @@ -86,6 +88,46 @@ export async function freeIdentifiers( ) } +/** + * The JSX runtime a module is compiled with. Oxc (the transform of Vite) and + * the other bundlers' transforms read the module's `@jsxRuntime`, + * `@jsxImportSource`, `@jsx` and `@jsxFrag` pragmas themselves; this only + * picks the default they start from. + * + * The default is React's automatic runtime, except for a module that sets + * `@jsx` or `@jsxFrag` without `@jsxRuntime` or `@jsxImportSource`: that + * module is compiled with the classic runtime, so its factory and fragment + * names are read. Under the automatic runtime such a pragma means nothing + * (Oxc and TypeScript ignore it, esbuild ignores it with a warning) or + * fails the build (SWC, so Rspack, and Babel reject it), so the module only + * works as written in a project whose default runtime is classic. + * + * Pragmas anywhere in the module count here, while Oxc only reads the ones + * that lead the module: a compiler that inserts code above a pragma still + * outputs a module of the same (classic) project, whose JSX Oxc then + * compiles with the default factory (`React.createElement`). + */ +function jsxRuntime(code: string, filename: string): 'classic' | 'automatic' { + const { comments } = parseSync(filename, code, { sourceType: 'module' }) + const pragmas = new Set() + for (const comment of comments) { + for (const match of comment.value.matchAll( + /@jsx(Runtime|ImportSource|Frag)?\s+(\S+)/g, + )) { + const pragma = match[1] ?? '' + if (pragma === 'Runtime') { + // Oxc honours the module's own `@jsxRuntime` over the default. + return match[2] === 'classic' ? 'classic' : 'automatic' + } + pragmas.add(pragma) + } + } + return !pragmas.has('ImportSource') && + (pragmas.has('') || pragmas.has('Frag')) + ? 'classic' + : 'automatic' +} + /** * Names a module declares at runtime, at any depth: variables (every * declarator, destructured bindings included), functions, classes, enums diff --git a/packages/compiler-test-utils/tests/validate-module.test.ts b/packages/compiler-test-utils/tests/validate-module.test.ts index e6076579913..7943fcbb950 100644 --- a/packages/compiler-test-utils/tests/validate-module.test.ts +++ b/packages/compiler-test-utils/tests/validate-module.test.ts @@ -34,6 +34,83 @@ export const Page = () =>
    `), ).toEqual(['`jsx` is referenced but never declared or imported']) }) + // A `@jsx`/`@jsxFrag` pragma without `@jsxRuntime`/`@jsxImportSource` only + // works as written under a classic default runtime (SWC and Babel reject it + // under the automatic runtime, Oxc and esbuild ignore it), so the module is + // checked as classic: its factory and fragment names must exist. + test.each([ + { + name: '`@jsx` alone, factory declared', + code: `/** @jsx h */ +const h = (type, props, ...children) => ({ type, props, children }) +export const Page = () =>
    `, + errors: [], + }, + { + name: '`@jsx` and `@jsxFrag`, both imported', + code: `/** @jsx h */ +/** @jsxFrag Fragment */ +import { h, Fragment } from 'preact' +export const Page = () => <>
    `, + errors: [], + }, + { + name: '`@jsx` alone, factory missing', + code: `/** @jsx h */ +export const Page = () =>
    `, + errors: ['`h` is referenced but never declared or imported'], + }, + { + name: '`@jsx` and `@jsxFrag`, fragment missing', + code: `/** @jsx h @jsxFrag Fragment */ +import { h } from 'preact' +export const Page = () => <>
    `, + errors: ['`Fragment` is referenced but never declared or imported'], + }, + { + name: '`@jsxFrag` alone (classic default factory), fragment missing', + code: `// @jsxFrag Fragment +import React from 'react' +export const Page = () => <>
    `, + errors: ['`Fragment` is referenced but never declared or imported'], + }, + { + // Oxc only reads the pragmas that lead the module: below an import, the + // pragma still marks a classic project, whose default factory Oxc uses. + name: '`@jsx` below an import, default factory missing', + code: `import { h } from 'preact' +/** @jsx h */ +export const Page = () =>
    {h('i', null)}
    `, + errors: ['`React` is referenced but never declared or imported'], + }, + ])('checks a classic pragma module: $name', async ({ code, errors }) => { + expect(await getModuleErrors(code)).toEqual(errors) + }) + + // The automatic runtime imports its factories: nothing to declare. A + // factory pragma does not apply to it (`@jsxRuntime automatic`), and + // `@jsxImportSource` selects it. + test.each([ + { + name: 'no pragma', + code: `export const Page = () => <>
    `, + }, + { + name: '`@jsxRuntime automatic` next to `@jsx`', + code: `/** @jsxRuntime automatic */ +/** @jsx h @jsxFrag Fragment */ +export const Page = () => <>
    `, + }, + { + name: '`@jsxImportSource` next to `@jsx`', + code: `/** @jsxImportSource preact */ +/** @jsx h */ +export const Page = () => <>
    `, + }, + ])('accepts an automatic runtime module: $name', async ({ code }) => { + expect(await getModuleErrors(code)).toEqual([]) + }) + test('accepts declared, hoisted, imported, probed and global names', async () => { expect( await getModuleErrors(`import { imported } from './lib' 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 c658ca9ae3f..c014ec4a9c0 100644 --- a/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts +++ b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts @@ -1192,7 +1192,10 @@ ${head}export function Page() { // known-bugs-start-compiler.test.ts and // router-plugin/tests/known-bugs-code-splitter.test.ts. // Impact: the client renders the parent's JSX through another factory than - // the server (a hydration mismatch), or throws "h is not defined". + // the server (a hydration mismatch), or throws "h is not defined". In a + // project whose default JSX runtime is classic, Oxc then compiles the + // parent's JSX to `React.createElement` without `React` in scope (the module + // validator reports it first). test.fails( 'client: a module with a classic JSX pragma renders through its factory', async () => { From 85dcde9b52cd4b8a0dd99227f372b823a283e21a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 20:23:04 +0200 Subject: [PATCH 0331/1425] test(start-plugin-core): importers of a denied file excludeFiles matches get the real module Covers the excludeFiles part of the 2026-10-17 import-protection exemption ruling, in mock mode: - Vite and Rsbuild builds: `client.excludeFiles` (client) and `server.excludeFiles` (SSR) matching `src/vendor/**`: the importer gets the real module, the denied file runs, nothing is reported. - Controls, same builds with the default patterns: the same files are mocked and their importers reported. - Default patterns: a `*.server.*` entry of a local `node_modules` dependency is real and not reported. - Vite dev: the client rows (custom, control, dependency). All pass on main (no pin). A scratch mutation that ignores excludeFiles (import-protection/utils.ts `isFileExcluded`) fails the 9 "real module" rows. Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/exemptions.test.ts | 263 +++++++++++++++++- 1 file changed, 261 insertions(+), 2 deletions(-) diff --git a/packages/start-plugin-core/tests/importProtection/exemptions.test.ts b/packages/start-plugin-core/tests/importProtection/exemptions.test.ts index f8a18e2c96c..2bafa72e754 100644 --- a/packages/start-plugin-core/tests/importProtection/exemptions.test.ts +++ b/packages/start-plugin-core/tests/importProtection/exemptions.test.ts @@ -5,19 +5,28 @@ * does more than silence the report; importers that are not exempt still get * the mock and a report. Client environment, mock mode. That nothing is * reported for an exempt importer is also checked in build-options.test.ts. + * Same ruling for a denied file `client.excludeFiles` / `server.excludeFiles` + * match (end of the file): its importers get the real module. */ import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterAll, afterEach, describe, expect, test, vi } from 'vitest' import { buildStart, startBundlers } from '../build-harness' import { reportedViolations, startProtectedDevServer } from './start-apps' -import type { StartBuild } from '../build-harness' +import type { StartBuild, StartBundler } from '../build-harness' import type { ImportProtectionOptions } from '../../src/schema' const timeout = 60_000 afterEach(() => { vi.restoreAllMocks() - delete (globalThis as { secretLoaded?: unknown }).secretLoaded + for (const loaded of [ + 'secretLoaded', + 'vendorLoaded', + 'widgetLoaded', + 'packageLoaded', + ]) { + delete (globalThis as Record)[loaded] + } }) /** @@ -223,3 +232,253 @@ describe.each(exemptions)( ) }, ) + +/** + * `client.excludeFiles` / `server.excludeFiles` (user ruling 2026-10-17): a + * denied file the patterns match is not checked, so its importers get the + * real module and nothing is reported. Each use records whether its denied + * file ran: + * - `vendor`: `src/vendor/secret.server.ts`, denied to the client; + * - `widget`: `src/vendor/widget.client.ts`, denied to the server (SSR); + * - `package`: the `*.server.*` entry of the dependency `server-pkg`, which + * the default patterns (`**\/node_modules/**`) match. + */ +const excludedFiles = { + 'src/vendor/secret.server.ts': `;(globalThis as any).vendorLoaded = 'MARK_VENDOR_LOADED' +export const secret = () => 'MARK_VENDOR' +`, + 'src/vendor/widget.client.ts': `;(globalThis as any).widgetLoaded = 'MARK_WIDGET_LOADED' +export const widget = () => 'MARK_WIDGET' +`, + 'node_modules/server-pkg/package.json': JSON.stringify({ + name: 'server-pkg', + type: 'module', + exports: { '.': './secret.server.js' }, + }), + 'node_modules/server-pkg/secret.server.js': `globalThis.packageLoaded = 'MARK_PACKAGE_LOADED' +export const secret = () => 'MARK_PACKAGE' +`, + 'src/app/vendor-user.ts': `import { secret } from '../vendor/secret.server' +export const vendorRead = () => secret() +`, + 'src/app/widget-user.ts': `import { widget } from '../vendor/widget.client' +export const widgetRead = () => widget() +`, + 'src/app/package-user.ts': `import { secret } from 'server-pkg' +export const packageRead = () => secret() +`, + 'src/entry.ts': `export { vendorRead } from './app/vendor-user' +export { widgetRead } from './app/widget-user' +export { packageRead } from './app/package-user' +`, +} + +type Use = { read: string; loaded: unknown } + +/** Each use of `excludedFiles`: what it returns, and whether its file ran. */ +type ExcludedOutcome = Record<'vendor' | 'widget' | 'package', Use> & { + reported: Array +} + +const excludedReadScript = `const read = (use, loaded) => { + let value + try { value = String(use()) } catch (error) { value = 'throws: ' + error } + return { read: value, loaded: globalThis[loaded] ?? null } +} +return { + vendor: read(entry.vendorRead, 'vendorLoaded'), + widget: read(entry.widgetRead, 'widgetLoaded'), + package: read(entry.packageRead, 'packageLoaded'), +}` + +/** The real module of a use: its value, and its file ran. */ +const realModule = (name: string): Use => ({ + read: `MARK_${name.toUpperCase()}`, + loaded: `MARK_${name.toUpperCase()}_LOADED`, +}) + +/** A mock: not the real value, and the denied file never ran. */ +const mockedModule = { + read: expect.not.stringMatching(/^MARK_/), + loaded: null, +} + +/** Custom patterns that match `src/vendor`; `node_modules` stays excluded. */ +const vendorExcluded = ['**/vendor/**', '**/node_modules/**'] + +const vendorExcludedOptions: ImportProtectionOptions = { + client: { excludeFiles: vendorExcluded }, + server: { excludeFiles: vendorExcluded }, +} + +/** + * One mock-mode build of `excludedFiles` with `importProtection`, shared by + * the tests of the enclosing `describe`; what an environment's uses read, + * and the files it reports. + */ +function excludedBuild( + bundler: StartBundler, + importProtection: ImportProtectionOptions, +) { + let built: Promise | undefined + let dispose: (() => Promise) | undefined + afterAll(() => dispose?.()) + return async (environment: 'client' | 'ssr'): Promise => { + built ??= buildStart({ + bundler, + files: excludedFiles, + entry: 'src/entry.ts', + start: { importProtection: { ...importProtection, behavior: 'mock' } }, + registerCleanup: (cleanup) => { + dispose = cleanup + }, + }) + const result = (await built)[environment] + const { result: read } = await result.run(excludedReadScript) + return { + ...(read as Omit), + reported: reportedFiles(result.warnings), + } + } +} + +describe.each(startBundlers)('%s build: excludeFiles', (bundler) => { + describe('custom patterns', () => { + const outcome = excludedBuild(bundler, vendorExcludedOptions) + + test( + 'client: the importer of a denied file client.excludeFiles matches gets the real module, and nothing is reported', + async () => { + const { vendor, reported } = await outcome('client') + expect({ vendor, reported }).toEqual({ + vendor: realModule('vendor'), + reported: [], + }) + }, + timeout, + ) + test( + 'ssr: the importer of a denied file server.excludeFiles matches gets the real module, and nothing is reported', + async () => { + const { widget, reported } = await outcome('ssr') + expect({ widget, reported }).toEqual({ + widget: realModule('widget'), + reported: [], + }) + }, + timeout, + ) + }) + + describe('default patterns', () => { + const outcome = excludedBuild(bundler, {}) + + // Controls: the same files, which the default patterns do not match. + test( + 'client: the importer of the same denied file outside the patterns gets the mock and is reported', + async () => { + const { vendor, reported } = await outcome('client') + expect({ vendor, reported }).toEqual({ + vendor: mockedModule, + reported: expect.arrayContaining(['client src/app/vendor-user.ts']), + }) + }, + timeout, + ) + test( + 'ssr: the importer of the same denied file outside the patterns gets the mock and is reported', + async () => { + const { widget, reported } = await outcome('ssr') + expect({ widget, reported }).toEqual({ + widget: mockedModule, + reported: expect.arrayContaining(['server src/app/widget-user.ts']), + }) + }, + timeout, + ) + test( + 'client: the importer of a denied file of a dependency gets the real module, and is not reported', + async () => { + const { package: used, reported } = await outcome('client') + expect({ package: used, reported }).toEqual({ + package: realModule('package'), + reported: expect.not.arrayContaining([ + 'client src/app/package-user.ts', + ]), + }) + }, + timeout, + ) + }) +}) + +describe('vite dev: excludeFiles', () => { + /** Loads the entry as a browser would; what its uses read, and the logs. */ + async function outcome( + importProtection: ImportProtectionOptions, + ): Promise> { + const server = await startProtectedDevServer( + excludedFiles, + importProtection, + ) + // The mock reports each use in the browser console. + vi.spyOn(console, 'error').mockImplementation(() => {}) + // Loads the dependency's files too (served from `/node_modules/`). + const module = await evaluateServed(server, '/src/entry.ts', { + skip: /^\/@(vite|react-refresh)\b/, + }) + const read = (use: () => unknown, loaded: string): Use => { + let value: string + try { + value = String(use()) + } catch (error) { + value = `throws: ${String(error)}` + } + return { + read: value, + loaded: (globalThis as Record)[loaded] ?? null, + } + } + return { + vendor: read(module.vendorRead, 'vendorLoaded'), + package: read(module.packageRead, 'packageLoaded'), + reported: reportedFiles(server.logs), + } + } + + test( + 'custom patterns: the importer of a denied file client.excludeFiles matches gets the real module, and nothing is reported', + async () => { + const { vendor, reported } = await outcome(vendorExcludedOptions) + expect({ vendor, reported }).toEqual({ + vendor: realModule('vendor'), + reported: [], + }) + }, + timeout, + ) + test( + 'default patterns: the importer of the same denied file outside the patterns gets the mock and is reported', + async () => { + const { vendor, reported } = await outcome({}) + expect({ vendor, reported }).toEqual({ + vendor: mockedModule, + reported: expect.arrayContaining(['client src/app/vendor-user.ts']), + }) + }, + timeout, + ) + test( + 'default patterns: the importer of a denied file of a dependency gets the real module, and is not reported', + async () => { + const { package: used, reported } = await outcome({}) + expect({ package: used, reported }).toEqual({ + package: realModule('package'), + reported: expect.not.arrayContaining([ + 'client src/app/package-user.ts', + ]), + }) + }, + timeout, + ) +}) From ef4ed206e26aa2835ea28b48d0555be6a4bef90c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 22:02:40 +0200 Subject: [PATCH 0332/1425] test(compiler-test-utils): clear free names only in code Rolldown tree-shakes away User ruling 2026-10-17 (dead-code references, widened): code the bundlers delete from a production build may read undeclared names. getModuleErrors now replaces the hand-rolled "unreferenced non-exported function" rule with real tree-shaking: when the plain check finds a free name, the compiled module is bundled alone with Vite's own Rolldown (treeshake on, exports as roots, imports external and side-effectful, platform neutral) and only the free names of what survives are errors. No test changed status. Controls: an unused function, an unused variable with a pure initializer, dead `if (false)` branches, an unused class pass; exported code, code reachable from an export, module-level calls and writes fail. Co-Authored-By: Claude Opus 5.5 --- .../src/validate-module.ts | 252 ++++++++---------- .../tests/validate-module.test.ts | 54 ++-- .../tests/code-splitter-dead-code.test.ts | 6 +- 3 files changed, 145 insertions(+), 167 deletions(-) diff --git a/packages/compiler-test-utils/src/validate-module.ts b/packages/compiler-test-utils/src/validate-module.ts index 0af026a986b..631c0b3a7c2 100644 --- a/packages/compiler-test-utils/src/validate-module.ts +++ b/packages/compiler-test-utils/src/validate-module.ts @@ -1,6 +1,18 @@ +/** + * Dangling names in dead code (user ruling "Dead-code references, widened", + * 2026-10-17): code that the supported bundlers delete from a production + * build may read names that are never declared or imported; a dangling name + * that survives into production output is a bug. So `getModuleErrors` + * reports only the free names of what real tree-shaking keeps: the module + * bundled alone by Rolldown (Vite's production bundler) with its exports as + * roots and every import external (and side-effectful). The build harness + * checks the production client chunks themselves (`build-harness.ts`). + */ +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' import { stripVTControlCharacters } from 'node:util' import { parseSync, transformWithOxc } from 'vite' -import type { ESTree } from 'vite' +import type { ESTree, Rolldown } from 'vite' /** * Validates generated code the way a browser or bundler sees it, without using @@ -16,7 +28,8 @@ import type { ESTree } from 'vite' * `@jsxImportSource` its runtime module): such a reference throws a * ReferenceError when it runs. Known globals of browsers and Node.js, names * the module probes with `typeof`, and `globals` (e.g. the globals the route - * file itself reads) are allowed. + * file itself reads) are allowed, and so are references in code that + * tree-shaking deletes (see the top of this file). * * @returns the error messages; an empty array means the module is valid. */ @@ -43,9 +56,22 @@ export async function getModuleErrors( return errors.map((error) => error.message) } const allowed = new Set(options.globals) - return [...(await freeIdentifiers(code, filename, { deadCode: false }))] - .filter((name) => !allowed.has(name)) - .map((name) => `\`${name}\` is referenced but never declared or imported`) + const compiled = await compileModule(code, filename) + let names = [...freeNamesOf(compiled)].filter((name) => !allowed.has(name)) + // Tree-shaking only deletes code, so it runs only when there is a name to + // clear. + if (names.length > 0) { + let kept: Set + try { + kept = freeNamesOf(await treeShake(compiled)) + } catch (error) { + return [stripVTControlCharacters((error as Error).message)] + } + names = names.filter((name) => kept.has(name)) + } + return names.map( + (name) => `\`${name}\` is referenced but never declared or imported`, + ) } /** @@ -53,41 +79,90 @@ export async function getModuleErrors( * known globals and names it probes with `typeof`, once TypeScript is erased * and JSX compiled with the project default (React's automatic runtime) or * the module's own JSX pragmas (a `@jsx` or `@jsxFrag` pragma alone selects - * the classic runtime, see `jsxRuntime`). - * - * `deadCode: false` leaves out names referenced only inside code that never - * runs (`getModuleErrors` does: such a reference throws nothing). Dead code - * is approximated statically as the module-level functions that are not - * exported and that no other code of the module reads, directly or through - * other such functions: a `function` declaration, or a `const`/`let`/`var` - * initialised with a function or arrow expression, whose name the module - * declares once. Reads by anything else (module-level code, exports, classes, - * `typeof`, assignments) keep a function live; anything undecidable stays - * live. + * the classic runtime, see `jsxRuntime`). Dead code counts too. */ export async function freeIdentifiers( code: string, filename = 'module.tsx', - options: { deadCode?: boolean } = {}, ): Promise> { + return freeNamesOf(await compileModule(code, filename)) +} + +/** + * Names a JavaScript module (e.g. a bundler's output chunk) reads without + * declaring or importing them, other than the names it probes with `typeof`. + * Globals count: the caller decides which names exist at run time. + */ +export function unboundNames(javascript: string): Set { + const { program } = parseSync('module.js', javascript, { + sourceType: 'module', + }) + const { references, probed } = analyzeScopes(program) + return new Set([...references].filter((name) => !probed.has(name))) +} + +/** The module with TypeScript erased and JSX compiled (see `jsxRuntime`). */ +async function compileModule(code: string, filename: string) { const { code: javascript } = await transformWithOxc(code, filename, { jsx: { runtime: jsxRuntime(code, filename) }, typescript: { onlyRemoveTypeImports: true }, }) - const { program } = parseSync('module.js', javascript, { - sourceType: 'module', - }) - const { references, probed, isOnlyInDeadCode } = analyzeScopes(program) + return javascript +} + +function freeNamesOf(javascript: string): Set { return new Set( - [...references].filter( - (name) => - !probed.has(name) && - !isKnownGlobal(name) && - (options.deadCode !== false || !isOnlyInDeadCode(name)), - ), + [...unboundNames(javascript)].filter((name) => !isKnownGlobal(name)), ) } +const require = createRequire(import.meta.url) +let rolldownModule: Promise | undefined + +/** The Rolldown that Vite builds with (a dependency of Vite, not ours). */ +function loadRolldown(): Promise { + rolldownModule ??= import( + pathToFileURL(createRequire(require.resolve('vite')).resolve('rolldown')) + .href + ) as Promise + return rolldownModule +} + +const entryId = '\0validate-module-entry' + +/** + * What Rolldown keeps of a JavaScript module bundled alone, tree-shaking on, + * its exports kept: every import stays external, and external modules count + * as side-effectful. `platform: 'neutral'` defines nothing (no + * `process.env.NODE_ENV`), since the module may run on the client or the + * server. + */ +async function treeShake(javascript: string): Promise { + const { rolldown } = await loadRolldown() + const bundle = await rolldown({ + input: entryId, + platform: 'neutral', + treeshake: true, + logLevel: 'silent', + external: (id) => id !== entryId, + plugins: [ + { + name: 'validate-module-entry', + resolveId: (id) => (id === entryId ? id : null), + load: (id) => (id === entryId ? javascript : null), + }, + ], + }) + try { + const { output } = await bundle.generate({ format: 'esm' }) + return output + .map((item) => (item.type === 'chunk' ? item.code : '')) + .join('\n') + } finally { + await bundle.close() + } +} + /** * The JSX runtime a module is compiled with. Oxc (the transform of Vite) and * the other bundlers' transforms read the module's `@jsxRuntime`, @@ -350,42 +425,18 @@ function analyzeScopes(program: ESTree.Program) { const probed = new Set() const moduleScope = createScope(undefined, true) const body = program.body as unknown as Array - /** Module-level functions that may be dead (see `freeIdentifiers`). */ - const candidates = deadCodeCandidates(body) - /** The candidate whose body is being visited (`undefined`: other code). */ - let owner: string | undefined - /** Candidates each candidate (or other code, `undefined`) reads. */ - const reads = new Map>() - /** Where each free name is referenced. */ - const freeOwners = new Map>() - /** Resolves `name` from `scope`; true when it is declared. */ - const resolve = (name: string, scope: Scope) => { + const reference = (name: string, scope: Scope) => { for ( let current: Scope | undefined = scope; current; current = current.parent ) { if (current.names.has(name)) { - if (current === moduleScope && candidates.has(name)) { - const read = reads.get(owner) ?? new Set() - read.add(name) - reads.set(owner, read) - } - return true + return } } - return false - } - - const reference = (name: string, scope: Scope) => { - if (resolve(name, scope)) { - return - } references.add(name) - const owners = freeOwners.get(name) ?? new Set() - owners.add(owner) - freeOwners.set(name, owners) } /** Visits a binding pattern: only defaults and computed keys are read. */ @@ -625,8 +676,6 @@ function analyzeScopes(program: ESTree.Program) { node.argument.type === 'Identifier' ) { probed.add(node.argument.name as string) - // A probed function counts as read: it stays live. - resolve(node.argument.name as string, scope) return } visit(node.argument, scope) @@ -660,95 +709,6 @@ function analyzeScopes(program: ESTree.Program) { } declareVars(body, moduleScope) - declareLexical(body, moduleScope) - for (const statement of body) { - if ( - statement.type === 'FunctionDeclaration' && - isNode(statement.id) && - candidates.has(statement.id.name as string) - ) { - owner = statement.id.name as string - visit(statement, moduleScope) - owner = undefined - } else if (statement.type === 'VariableDeclaration') { - for (const declarator of statement.declarations as Array) { - visitBinding(declarator.id, moduleScope) - const id = declarator.id as Node - owner = - id.type === 'Identifier' && candidates.has(id.name as string) - ? (id.name as string) - : undefined - visit(declarator.init, moduleScope) - owner = undefined - } - } else { - visit(statement, moduleScope) - } - } - - // Live candidates: read by other code, or by a live candidate. - const live = new Set() - const pending = [...(reads.get(undefined) ?? [])] - while (pending.length > 0) { - const name = pending.pop()! - if (!live.has(name)) { - live.add(name) - pending.push(...(reads.get(name) ?? [])) - } - } - const isOnlyInDeadCode = (name: string) => - [...(freeOwners.get(name) ?? [undefined])].every( - (where) => where !== undefined && !live.has(where), - ) - return { references, probed, isOnlyInDeadCode } -} - -/** Unwraps parentheses around an expression. */ -function unparenthesized(node: unknown): unknown { - while (isNode(node) && node.type === 'ParenthesizedExpression') { - node = node.expression - } - return node -} - -/** - * Names of the module-level functions that are dead unless something reads - * them: unexported `function` declarations, and unexported - * `const`/`let`/`var` declarators initialised with a function or arrow - * expression, whose name the module declares exactly once. - */ -function deadCodeCandidates(body: Array) { - const counts = new Map() - const count = (name: string) => counts.set(name, (counts.get(name) ?? 0) + 1) - for (const statement of body) { - const scope = createScope(undefined, true) - declareVars([statement], scope) - declareLexical([statement], scope) - scope.names.forEach(count) - } - const candidates = new Set() - for (const statement of body) { - if (statement.type === 'FunctionDeclaration' && isNode(statement.id)) { - candidates.add(statement.id.name as string) - } else if (statement.type === 'VariableDeclaration') { - for (const declarator of statement.declarations as Array) { - const id = declarator.id as Node - const init = unparenthesized(declarator.init) - if ( - id.type === 'Identifier' && - isNode(init) && - (init.type === 'FunctionExpression' || - init.type === 'ArrowFunctionExpression') - ) { - candidates.add(id.name as string) - } - } - } - } - for (const name of candidates) { - if (counts.get(name) !== 1) { - candidates.delete(name) - } - } - return candidates + visitBody(body, moduleScope) + return { references, probed } } diff --git a/packages/compiler-test-utils/tests/validate-module.test.ts b/packages/compiler-test-utils/tests/validate-module.test.ts index 7943fcbb950..a4bdc19e1a1 100644 --- a/packages/compiler-test-utils/tests/validate-module.test.ts +++ b/packages/compiler-test-utils/tests/validate-module.test.ts @@ -138,11 +138,10 @@ export const Page = () =>
    {imported}
    `), ).toEqual([]) }) - // User ruling 2026-10-17: a reference to an undefined name inside code - // that never runs throws nothing, so it is no error of the module (whether - // it reaches a production bundle is a build-level question). Dead code is - // approximated as module-level functions that are not exported and that - // nothing else reads. + // User ruling 2026-10-17 (dead-code references, widened): a reference to + // an undefined name in code the bundlers delete from a production build is + // no error of the module. The validator deletes what Rolldown's + // tree-shaking deletes. test.each([ { name: 'an uncalled function declaration', @@ -152,6 +151,26 @@ export const Page = () =>
    {imported}
    `), name: 'an unused arrow function', code: `const makeOptions = () => ({ component: Page })`, }, + { + name: 'an unused variable with a pure initializer', + code: `const helpers = { read: () => missing, label: \`\${'a'}\` }`, + }, + { + name: 'a dead `if (false)` branch at module level', + code: `if (false) { missing() }`, + }, + { + name: 'a dead `if (false)` branch of an exported function', + code: `export function read() { + if (false) { return missing } + return 'read' +}`, + }, + { + name: 'an unused class', + code: `class Store { static value = inner; read() { return missing } } +function inner() { return }`, + }, { name: 'mutually recursive unused functions', code: `function ping() { return pong() + missing } @@ -169,9 +188,15 @@ export const Route = { component: () =>
    }`), ).toEqual([]) }) - // The other side of the line: code that runs, or may run. + // The other side of the line: code that survives tree-shaking, whether it + // runs or not. test.each([ { name: 'module-level code', code: `const value = missing` }, + { + name: 'a module-level call', + code: `import { track } from './track' +track(() => missing)`, + }, { name: 'an exported function', code: `export function read() { return missing }`, @@ -201,11 +226,6 @@ export function outer() { return inner() }`, code: `function inner() { return missing } const outer = () => inner export const Route = { loader: outer }`, - }, - { - name: 'a function a class reads', - code: `function inner() { return missing } -class Store { static value = inner }`, }, { name: 'a function probed with typeof', @@ -218,14 +238,10 @@ console.log(typeof inner)`, inner = inner`, }, { - // Declared twice: which declaration runs is not decided statically. - name: 'a function whose name is declared twice', - code: `var inner = () => missing -if (globalThis) { var inner = () => 'other' }`, - }, - { - name: 'a function body nested in an object', - code: `const helpers = { read: () => missing }`, + // Writing a property of the function is a side effect: both stay. + name: 'a function module-level code writes a property of', + code: `function Page() { return missing } +Page.displayName = 'Page'`, }, ])('rejects a free name in $name', async ({ code }) => { expect(await getModuleErrors(code)).toEqual([ diff --git a/packages/router-plugin/tests/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/code-splitter-dead-code.test.ts index 9a3321c7d03..ba416219f2e 100644 --- a/packages/router-plugin/tests/code-splitter-dead-code.test.ts +++ b/packages/router-plugin/tests/code-splitter-dead-code.test.ts @@ -193,8 +193,10 @@ export const Route = createFileRoute('/')({ component: () =>
    }) // eliminates newly unreferenced identifiers", "unexported circular references" // and "SCC with external caller used -> all preserved". Whether unused user // code is kept or dropped is free, and unused code may name a binding its - // module no longer declares (user ruling 2026-10-17: it never runs); every - // module stays valid and the split component still reaches its helper. + // module no longer declares (user ruling 2026-10-17, widened: the bundlers + // delete it; the validator checks what Rolldown's tree-shaking keeps); + // every module stays valid and the split component still reaches its + // helper. const unusedFunctions = `${head}function helper() { return 'helper' } function unused() { return 'MARK_UNUSED_END' + helper() } function ping(): number { return pong() + 'MARK_PING_END'.length } From 3179078138343c54f34d087d0c3fed7f9c342d0d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 22:02:40 +0200 Subject: [PATCH 0333/1425] test(compiler-test-utils): reject dangling names in production client builds describeBuild now checks every production client build of Vite, Rspack and webpack (webpack/Rspack when minified; esbuild is not checked): a chunk name that is neither declared, imported nor a global throws DanglingNameError. Globals: the `globals` package (builtin + browser + nodeBuiltin), new dependency of compiler-test-utils (already in the lockfile, 16.4.0), plus Solid's hydration global `_$HY`. Applies to the router build harness and Start's buildStart. KB "page code in client builds": the display-name route (known bug, the route module keeps `Page.displayName = ...` without `Page`) now builds alone for its .fails pin, red on all bundlers with `entry.mjs: Page`; its placement-only control rows (6) are removed. The Vite "unused object names the split component" pin now fails at the harness check, same bug. Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/package.json | 1 + .../compiler-test-utils/src/build-harness.ts | 86 ++++++++++++++++++- .../tests/build-harness.test.ts | 73 ++++++++++++++++ .../tests/known-bugs-code-splitter.test.ts | 57 +++++++----- .../start-plugin-core/tests/build-harness.ts | 1 + pnpm-lock.yaml | 3 + 6 files changed, 198 insertions(+), 23 deletions(-) diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index 9aeae672113..02e4d3f2b40 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -24,6 +24,7 @@ "dependencies": { "@rsbuild/core": "catalog:", "esbuild": "^0.28.2", + "globals": "^16.2.0", "jsdom": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/compiler-test-utils/src/build-harness.ts b/packages/compiler-test-utils/src/build-harness.ts index 149e4305f6f..94d12030876 100644 --- a/packages/compiler-test-utils/src/build-harness.ts +++ b/packages/compiler-test-utils/src/build-harness.ts @@ -4,7 +4,9 @@ * change. Writes a small app to disk, builds it with one of the bundlers the * router plugin supports, describes the emitted chunks (entry, eager, lazy) * and runs the output in a separate Node process (with a jsdom document for - * client builds). + * client builds). Describing a production client build of Vite, Rspack or + * webpack rejects chunks that read a name that is neither declared, + * imported nor a global (`DanglingNameError`). * * Plugin-agnostic: the caller supplies the plugin of each build, and the * directory its apps live in (where their imports resolve). @@ -205,6 +207,27 @@ export interface BuildResult { dispose: () => Promise } +/** + * A production client build emitted code that reads names no chunk declares + * or imports and that are no global of a browser or of Node.js: that code + * throws a ReferenceError when it runs. `names` lists them by chunk. + */ +export class DanglingNameError extends Error { + constructor( + readonly bundler: Bundler, + readonly names: Record>, + ) { + super( + `${bundler}: the production client build reads names that are never declared or imported:\n${Object.entries( + names, + ) + .map(([fileName, list]) => ` ${fileName}: ${list.join(', ')}`) + .join('\n')}`, + ) + this.name = 'DanglingNameError' + } +} + /** A build failed; `errors` are the bundler's error messages. */ export class BuildError extends Error { constructor( @@ -386,6 +409,7 @@ async function bundle( bundler, env, mode, + minify: config.minify, root, outDir: config.outDir, chunks: output, @@ -842,6 +866,8 @@ export interface DescribeBuildInput { bundler: Bundler env: BuildEnv mode: BuildMode + /** Whether the build minified its output (default: `mode === 'production'`). */ + minify?: boolean /** App root on disk. */ root: string /** Absolute output directory the chunk file names are relative to. */ @@ -885,6 +911,16 @@ export async function describeBuild( }), }) } + if ( + input.env === 'client' && + input.mode === 'production' && + bundler !== 'esbuild' && + // webpack and Rspack only drop unused code inside a module when + // minifying; Rolldown's tree-shaking does not depend on it. + ((input.minify ?? true) || bundler === 'vite') + ) { + await checkDanglingNames(bundler, chunks) + } const entries = chunks.filter((chunk) => chunk.isEntry) if (entries.length !== 1) { throw new Error( @@ -954,6 +990,54 @@ export async function describeBuild( } } +/** + * Names production client chunks may read without declaring or importing + * them: the globals of browsers, of Node.js (ES modules: no CommonJS + * wrapper variables) and of the language, as the `globals` package lists + * them, and the globals framework runtimes define for themselves. + */ +let clientGlobals: Set | undefined + +/** + * Globals a framework's own code defines: `_$HY` is the hydration state the + * script of Solid's `generateHydrationScript()` creates, which `solid-js/web` + * reads while hydrating. + */ +const frameworkGlobals = ['_$HY'] + +/** + * Throws a `DanglingNameError` when production client chunks read a name + * that is neither declared, imported nor a global (user ruling "Dead-code + * references, widened", 2026-10-17: a dangling name that survives into a + * production client bundle is a bug; one in code the bundler deletes is + * fine). Names the chunks probe with `typeof` are allowed. esbuild is not + * checked: it is no supported production bundler of the ruling. + */ +async function checkDanglingNames(bundler: Bundler, chunks: Array) { + const [{ unboundNames }, { default: globals }] = await Promise.all([ + import('./validate-module'), + import('globals'), + ]) + clientGlobals ??= new Set([ + ...Object.keys(globals.builtin), + ...Object.keys(globals.browser), + ...Object.keys(globals.nodeBuiltin), + ...frameworkGlobals, + ]) + const dangling: Record> = {} + for (const chunk of chunks) { + const names = [...unboundNames(chunk.code)].filter( + (name) => !clientGlobals!.has(name), + ) + if (names.length > 0) { + dangling[chunk.fileName] = names + } + } + if (Object.keys(dangling).length > 0) { + throw new DanglingNameError(bundler, dangling) + } +} + /** * The segments of a source map that carry a name (`names`), with the * original position each maps to: `source` as the map names it, `line` diff --git a/packages/compiler-test-utils/tests/build-harness.test.ts b/packages/compiler-test-utils/tests/build-harness.test.ts index 7d7802e0a6b..39aca7b36f8 100644 --- a/packages/compiler-test-utils/tests/build-harness.test.ts +++ b/packages/compiler-test-utils/tests/build-harness.test.ts @@ -279,6 +279,79 @@ export const noSideEffects = recordAnnotated('MARK_NO_SIDE_EFFECTS') ) }) +// User ruling 2026-10-17 (dead-code references, widened): a name that is +// never declared or imported must not survive into production client +// output. The harness checks every production client build of the supported +// bundlers. +describe.each(['vite', 'rspack', 'webpack'] as const)( + 'dangling names in production client output (%s)', + (bundler) => { + test( + 'a chunk that reads a dangling name fails the build description', + async () => { + await expect( + bundleApp({ + bundler, + env: 'client', + files: { + 'entry.ts': `export function read() { + return [notDeclaredAnywhere, document.title] +} +`, + }, + }), + ).rejects.toMatchObject({ + name: 'DanglingNameError', + names: { 'entry.mjs': ['notDeclaredAnywhere'] }, + }) + }, + timeout, + ) + + test( + 'chunks that read only globals, probe names with typeof, and drop dead code with a dangling name pass', + async () => { + const app = await bundleApp({ + bundler, + env: 'client', + files: { + 'entry.ts': `function unused() { + return notDeclaredAnywhere +} +export async function read() { + queueMicrotask(() => {}) + const { lazy } = await import('./lazy') + return [ + lazy(), + typeof maybeDefined === 'undefined', + structuredClone({ a: 1 }).a, + window.location.pathname, + document.getElementById('root')?.id, + new URL('http://localhost/x').pathname, + Math.max(1, 2), + ] +} +declare const maybeDefined: unknown +`, + 'lazy.ts': `export const lazy = () => new Map([[1, 'lazy']]).get(1) +`, + }, + }) + expect((await app.run('return entry.read()')).result).toEqual([ + 'lazy', + true, + 1, + '/', + 'root', + '/x', + 2, + ]) + }, + timeout, + ) + }, +) + describe('registerCleanup', () => { test('receives the dispose() that removes the app directory', async () => { const cleanups: Array<() => Promise> = [] 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 2685e25907b..6cfca012396 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -1197,8 +1197,9 @@ export const Route = ${route} }) // The unused `makeOptions` of the two rows above may name a `Page` its - // module no longer declares (user ruling 2026-10-17: it never runs), as - // long as that dead code does not reach a production client bundle. + // module no longer declares (user ruling 2026-10-17, widened: the bundlers + // delete it), as long as that dead code does not reach a production client + // bundle (which the build harness also checks for any dangling name). test.each( clientBundlers.flatMap((bundler) => [ @@ -2774,8 +2775,6 @@ export const Route = createFileRoute('$PATH')({ component: () =>

    {label('page')}

    , })`, ), - // Its placement only; that it renders the property is the goal below. - displayName, ] // Bug: page code the route file exports as a variable, and an exported @@ -2943,6 +2942,22 @@ ${component}`, destructured, unreadExport, ] + /** Mounts the component of the route at `path` into #root. */ + const mountEntry = `import { createElement } from 'react' +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { routeTree } from './routeTree.gen' +export { routeTree } +/** Mounts the component of the route at \`path\` into #root. */ +export async function mount(path: string) { + const route = (routeTree.children as Array).find( + (child) => child.options.path === path, + ) + await route.options.component.preload?.() + const root = createRoot(document.getElementById('root')!) + flushSync(() => root.render(createElement(route.options.component))) +} +` const files = { 'label.ts': `;(globalThis as any).labelLoaded = true export const label = 'label' @@ -2963,21 +2978,7 @@ export const Route = createFileRoute('/imports-exported')({ component: Page }) ...Object.fromEntries( cases.map((row) => [`routes/${row.file}.tsx`, row.route]), ), - 'entry.ts': `import { createElement } from 'react' -import { flushSync } from 'react-dom' -import { createRoot } from 'react-dom/client' -import { routeTree } from './routeTree.gen' -export { routeTree } -/** Mounts the component of the route at \`path\` into #root. */ -export async function mount(path: string) { - const route = (routeTree.children as Array).find( - (child) => child.options.path === path, - ) - await route.options.component.preload?.() - const root = createRoot(document.getElementById('root')!) - flushSync(() => root.render(createElement(route.options.component))) -} -`, + 'entry.ts': mountEntry, } describe.each(clientBundlers)('%s', (bundler) => { @@ -3013,14 +3014,25 @@ export async function mount(path: string) { // writes to the split component (property assignment) stay with the // binding they write"). Keeping a copy of the component and its write // in the route module as well is no fix: the page code then loads - // with the entry chunk. The control above checks the placement only. + // with the entry chunk. Its own app: the build harness rejects the + // production client output, whose entry chunk reads the undeclared + // `Page`. // Source: the #8504 probe (memos/splitter.md B6). // Impact: the app's entry throws when it loads ("Page is not // defined"). test.fails( `${displayName.name} loads with its chunk and renders the property`, async () => { - const built = await app() + const built = await build({ + bundler, + env: 'client', + sideEffects, + files: { + 'routes/__root.tsx': rootRoute, + [`routes/${displayName.file}.tsx`]: displayName.route, + 'entry.ts': mountEntry, + }, + }) const { html } = await built.run(`await entry.mount('/display-name')`) expect({ placement: built.where(displayName.marker), html }).toEqual({ placement: 'lazy', @@ -3811,7 +3823,8 @@ export const Route = createFileRoute('/')({ component: Page }) // Bug: same root cause as above, for an unused module-level object that // names the split component: the route module keeps the object but loses // `Page`. Rolldown keeps the object, as reading an undeclared binding can - // throw. + // throw. (The build harness now rejects that output before it runs: its + // entry chunk reads the undeclared `Page`.) // Source: Wave B (a Vite Start client bundle crashed on it). // Impact: the client bundle throws when the route module loads ("Page is // not defined"), so the app never renders. diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index 69163fa0da0..bec33942bd1 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -367,6 +367,7 @@ export async function buildStart( bundler: bundler === 'vite' ? 'vite' : 'rspack', env, mode, + minify: target.minify, root: app.root, outDir: outputs[env].outDir, chunks: outputs[env].chunks, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d12bcf58bb..aa5b40bb028 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13932,6 +13932,9 @@ importers: esbuild: specifier: ^0.28.2 version: 0.28.2 + globals: + specifier: ^16.2.0 + version: 16.4.0 jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) From 1f252dcf46529b61a91275f53cabc2d8e0524cda Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 22:22:24 +0200 Subject: [PATCH 0334/1425] test(compiler-test-utils): narrow the known globals and check unminified Rspack and webpack builds as minified - One allowlist (src/known-globals.ts) for the module validator and the production client chunk check: language globals, Node.js ES module globals, a curated list of browser APIs plus the element and event constructors, and Solid's _$HY. Generic window properties (name, status, event, top, parent...) no longer count as globals; the validator no longer accepts whatever the test process put on globalThis. - Unminified (source map) production client builds of Rspack and webpack are checked on a copy of each chunk minified as the bundler's production minifier does (SWC minify / webpack's terser, compress passes 2, mangle, module output as a module). - Controls: name/status read by a chunk is rejected (Vite, Rspack, webpack) and by the validator; unminified Rspack and webpack builds reject a dangling name in kept code and pass one in code the minifier deletes. Co-Authored-By: Claude Opus 5.5 --- .../compiler-test-utils/src/build-harness.ts | 116 +++++++++++------ .../compiler-test-utils/src/known-globals.ts | 121 ++++++++++++++++++ .../src/validate-module.ts | 42 +----- .../tests/build-harness.test.ts | 83 ++++++++++++ .../tests/validate-module.test.ts | 14 ++ 5 files changed, 300 insertions(+), 76 deletions(-) create mode 100644 packages/compiler-test-utils/src/known-globals.ts diff --git a/packages/compiler-test-utils/src/build-harness.ts b/packages/compiler-test-utils/src/build-harness.ts index 94d12030876..562cb17ed2a 100644 --- a/packages/compiler-test-utils/src/build-harness.ts +++ b/packages/compiler-test-utils/src/build-harness.ts @@ -122,7 +122,9 @@ export interface BundleTarget { env?: BuildEnv /** * Defaults to `mode === 'production'`, like a real app build. webpack and - * Rspack only drop unused code inside a kept module when minifying. + * Rspack only drop unused code inside a kept module when minifying (the + * dangling-name check of an unminified production client build checks + * its chunks as the bundler's minifier emits them). */ minify?: boolean /** Output directory relative to the app root (default `dist`). */ @@ -914,12 +916,9 @@ export async function describeBuild( if ( input.env === 'client' && input.mode === 'production' && - bundler !== 'esbuild' && - // webpack and Rspack only drop unused code inside a module when - // minifying; Rolldown's tree-shaking does not depend on it. - ((input.minify ?? true) || bundler === 'vite') + bundler !== 'esbuild' ) { - await checkDanglingNames(bundler, chunks) + await checkDanglingNames(bundler, chunks, input.minify ?? true) } const entries = chunks.filter((chunk) => chunk.isEntry) if (entries.length !== 1) { @@ -990,44 +989,38 @@ export async function describeBuild( } } -/** - * Names production client chunks may read without declaring or importing - * them: the globals of browsers, of Node.js (ES modules: no CommonJS - * wrapper variables) and of the language, as the `globals` package lists - * them, and the globals framework runtimes define for themselves. - */ -let clientGlobals: Set | undefined - -/** - * Globals a framework's own code defines: `_$HY` is the hydration state the - * script of Solid's `generateHydrationScript()` creates, which `solid-js/web` - * reads while hydrating. - */ -const frameworkGlobals = ['_$HY'] - /** * Throws a `DanglingNameError` when production client chunks read a name - * that is neither declared, imported nor a global (user ruling "Dead-code - * references, widened", 2026-10-17: a dangling name that survives into a - * production client bundle is a bug; one in code the bundler deletes is - * fine). Names the chunks probe with `typeof` are allowed. esbuild is not - * checked: it is no supported production bundler of the ruling. + * that is neither declared, imported nor a known global (`known-globals.ts`) + * (user ruling "Dead-code references, widened", 2026-10-17: a dangling name + * that survives into a production client bundle is a bug; one in code the + * bundler deletes is fine). Names the chunks probe with `typeof` are allowed. + * esbuild is not checked: it is no supported production bundler of the + * ruling. + * + * webpack and Rspack drop unused code inside a module only when minifying, + * as their production builds do. The chunks of a build that did not minify + * (`minified: false`, e.g. to test unminified source maps) are checked as the + * bundler's production minifier would emit them. Rolldown's tree-shaking + * does not depend on minifying. */ -async function checkDanglingNames(bundler: Bundler, chunks: Array) { - const [{ unboundNames }, { default: globals }] = await Promise.all([ +async function checkDanglingNames( + bundler: Exclude, + chunks: Array, + minified: boolean, +) { + const [{ unboundNames }, { knownGlobals }] = await Promise.all([ import('./validate-module'), - import('globals'), - ]) - clientGlobals ??= new Set([ - ...Object.keys(globals.builtin), - ...Object.keys(globals.browser), - ...Object.keys(globals.nodeBuiltin), - ...frameworkGlobals, + import('./known-globals'), ]) const dangling: Record> = {} for (const chunk of chunks) { - const names = [...unboundNames(chunk.code)].filter( - (name) => !clientGlobals!.has(name), + const code = + minified || bundler === 'vite' + ? chunk.code + : await minifyAsInProduction(bundler, chunk.code) + const names = [...unboundNames(code)].filter( + (name) => !knownGlobals.has(name), ) if (names.length > 0) { dangling[chunk.fileName] = names @@ -1038,6 +1031,55 @@ async function checkDanglingNames(bundler: Bundler, chunks: Array) { } } +/** + * A chunk minified the way the bundler's production build minifies it: + * Rspack's `SwcJsMinimizerRspackPlugin` (which Rsbuild configures too) and + * webpack's terser both default to `compress: { passes: 2 }` and mangling, + * and minify ES module output as a module (its top level is not global). + */ +async function minifyAsInProduction( + bundler: 'rspack' | 'webpack', + code: string, +): Promise { + const { parseSync } = await import('vite') + const { program } = parseSync('chunk.js', code, { sourceType: 'module' }) + const module = program.body.some((statement) => + /^(Import|Export)/.test(statement.type), + ) + if (bundler === 'rspack') { + const { rspack } = await import('@rsbuild/core') + const result = await rspack.experiments.swc.minify(code, { + compress: { passes: 2 }, + mangle: true, + module, + }) + return result.code + } + // The terser of webpack's own minimizer + const terser = resolveFrom(['webpack', 'minimizer-webpack-plugin', 'terser']) + const { minify } = (await import(pathToFileURL(terser).href)) as { + minify: ( + code: string, + options: Record, + ) => Promise<{ code?: string }> + } + const result = await minify(code, { + compress: { passes: 2 }, + mangle: true, + module, + }) + return result.code ?? '' +} + +/** Resolves the last package of `chain`, each from the one before it. */ +function resolveFrom(chain: Array): string { + let resolved = require.resolve(chain[0]!) + for (const name of chain.slice(1)) { + resolved = createRequire(resolved).resolve(name) + } + return resolved +} + /** * The segments of a source map that carry a name (`names`), with the * original position each maps to: `source` as the map names it, `line` diff --git a/packages/compiler-test-utils/src/known-globals.ts b/packages/compiler-test-utils/src/known-globals.ts new file mode 100644 index 00000000000..f3dd51dce5d --- /dev/null +++ b/packages/compiler-test-utils/src/known-globals.ts @@ -0,0 +1,121 @@ +/** + * Names code may read without declaring or importing them: the globals it + * can count on at run time. Shared by the validator of generated modules + * (`validate-module.ts`) and the check of production client chunks + * (`build-harness.ts`). + * + * Browsers define hundreds of window properties with generic names (`name`, + * `status`, `event`, `length`, `origin`, `top`, `parent`, `close`...). A + * compiler bug that drops a local binding with such a name leaves code that + * reads the window property instead, and fails later or not at all. So only + * the browser APIs code really uses count, not every name of the `globals` + * package's browser set. + */ +import globals from 'globals' + +/** Browser globals that are no Node.js global, by name. */ +const browserNames = [ + 'window', + 'self', + 'document', + 'location', + 'history', + 'localStorage', + 'sessionStorage', + 'indexedDB', + 'caches', + 'customElements', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'getComputedStyle', + 'getSelection', + 'matchMedia', + 'addEventListener', + 'removeEventListener', + 'dispatchEvent', + 'postMessage', + 'reportError', + 'alert', + 'confirm', + 'prompt', + 'scrollTo', + 'scrollBy', + 'scrollX', + 'scrollY', + 'pageXOffset', + 'pageYOffset', + 'innerWidth', + 'innerHeight', + 'devicePixelRatio', + 'visualViewport', + 'isSecureContext', + 'crossOriginIsolated', + 'trustedTypes', + 'createImageBitmap', + 'Element', + 'Node', + 'NodeList', + 'NodeFilter', + 'HTMLCollection', + 'Document', + 'DocumentFragment', + 'ShadowRoot', + 'ElementInternals', + 'CustomElementRegistry', + 'DOMParser', + 'DOMRect', + 'DOMRectReadOnly', + 'DOMTokenList', + 'XMLSerializer', + 'XMLHttpRequest', + 'EventSource', + 'Worker', + 'SharedWorker', + 'MutationObserver', + 'MutationRecord', + 'IntersectionObserver', + 'IntersectionObserverEntry', + 'ResizeObserver', + 'ResizeObserverEntry', + 'FileReader', + 'FileList', + 'DataTransfer', + 'ImageData', + 'OffscreenCanvas', + 'CanvasRenderingContext2D', + 'Path2D', + 'TreeWalker', + 'NodeIterator', + 'MediaQueryList', + 'CSS', + 'CSSStyleSheet', + 'CSSStyleDeclaration', +] + +/** + * Browser constructors of whole families: elements (`HTMLDivElement`, + * `SVGSVGElement`...) and events (`MouseEvent`, `PopStateEvent`...). + */ +const browserFamilies = /^(HTML|SVG|MathML)\w*Element$|^\w+Event$/ + +/** Globals a framework's own code defines for itself, which its runtime reads. */ +const frameworkNames = [ + // the hydration state the script of Solid's `generateHydrationScript()` + // creates, which `solid-js/web` reads while hydrating. + '_$HY', +] + +/** + * The language's globals, those of Node.js as an ES module sees them (no + * CommonJS wrapper variables such as `require` or `__dirname`), the browser + * APIs above and the framework globals. + */ +export const knownGlobals: ReadonlySet = new Set([ + ...Object.keys(globals.builtin), + ...Object.keys(globals.nodeBuiltin), + ...browserNames, + ...Object.keys(globals.browser).filter((name) => browserFamilies.test(name)), + ...frameworkNames, +]) diff --git a/packages/compiler-test-utils/src/validate-module.ts b/packages/compiler-test-utils/src/validate-module.ts index 631c0b3a7c2..92c7c3309ea 100644 --- a/packages/compiler-test-utils/src/validate-module.ts +++ b/packages/compiler-test-utils/src/validate-module.ts @@ -12,6 +12,7 @@ import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' import { stripVTControlCharacters } from 'node:util' import { parseSync, transformWithOxc } from 'vite' +import { knownGlobals } from './known-globals' import type { ESTree, Rolldown } from 'vite' /** @@ -26,7 +27,7 @@ import type { ESTree, Rolldown } from 'vite' * in the module, once JSX is compiled the way a bundler compiles it (so a * classic `@jsx` or `@jsxFrag` pragma reads its factory or fragment, and * `@jsxImportSource` its runtime module): such a reference throws a - * ReferenceError when it runs. Known globals of browsers and Node.js, names + * ReferenceError when it runs. Known globals (`known-globals.ts`), names * the module probes with `typeof`, and `globals` (e.g. the globals the route * file itself reads) are allowed, and so are references in code that * tree-shaking deletes (see the top of this file). @@ -112,7 +113,7 @@ async function compileModule(code: string, filename: string) { function freeNamesOf(javascript: string): Set { return new Set( - [...unboundNames(javascript)].filter((name) => !isKnownGlobal(name)), + [...unboundNames(javascript)].filter((name) => !knownGlobals.has(name)), ) } @@ -260,43 +261,6 @@ export function declaredNames( return names } -/** Globals of browsers that Node.js does not define. */ -const browserGlobals = [ - 'window', - 'document', - 'navigator', - 'location', - 'history', - 'self', - 'top', - 'parent', - 'localStorage', - 'sessionStorage', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'requestIdleCallback', - 'cancelIdleCallback', - 'getComputedStyle', - 'matchMedia', - 'scrollTo', - 'alert', - 'HTMLElement', - 'Element', - 'Node', - 'customElements', - 'IntersectionObserver', - 'ResizeObserver', - 'MutationObserver', -] -const knownGlobals = new Set([ - ...Object.getOwnPropertyNames(globalThis), - ...browserGlobals, -]) - -function isKnownGlobal(name: string) { - return knownGlobals.has(name) -} - type Scope = { parent: Scope | undefined names: Set diff --git a/packages/compiler-test-utils/tests/build-harness.test.ts b/packages/compiler-test-utils/tests/build-harness.test.ts index 39aca7b36f8..f5952cad45d 100644 --- a/packages/compiler-test-utils/tests/build-harness.test.ts +++ b/packages/compiler-test-utils/tests/build-harness.test.ts @@ -308,6 +308,31 @@ describe.each(['vite', 'rspack', 'webpack'] as const)( timeout, ) + // Browsers define window properties with generic names: a chunk that + // reads one without declaring it most likely lost a local binding. + test( + 'a chunk that reads a dropped local named like a window property fails the build description', + async () => { + await expect( + bundleApp({ + bundler, + env: 'client', + files: { + 'entry.ts': `export function read() { + return [name, status] +} +declare const name: string, status: string +`, + }, + }), + ).rejects.toMatchObject({ + name: 'DanglingNameError', + names: { 'entry.mjs': ['name', 'status'] }, + }) + }, + timeout, + ) + test( 'chunks that read only globals, probe names with typeof, and drop dead code with a dangling name pass', async () => { @@ -352,6 +377,64 @@ declare const maybeDefined: unknown }, ) +// A build that does not minify (e.g. to test unminified source maps) keeps +// the unused code webpack and Rspack only drop when minifying. Its chunks +// are checked as the bundler's production minifier emits them. +describe.each(['rspack', 'webpack'] as const)( + 'dangling names in an unminified production client build (%s)', + (bundler) => { + test( + 'a dangling name in code the minifier keeps fails the build description', + async () => { + await expect( + bundleApp({ + bundler, + env: 'client', + minify: false, + files: { + 'entry.ts': `export function read() { + return [notDeclaredAnywhere, document.title] +} +`, + }, + }), + ).rejects.toMatchObject({ + name: 'DanglingNameError', + names: { 'entry.mjs': ['notDeclaredAnywhere'] }, + }) + }, + timeout, + ) + + test( + 'a dangling name in code the minifier deletes passes', + async () => { + const app = await bundleApp({ + bundler, + env: 'client', + minify: false, + files: { + 'entry.ts': `function unused() { + return [notDeclaredAnywhere, 'MARK_UNUSED'] +} +export function read() { + return 'MARK_USED' +} +`, + }, + }) + // The unminified output still has the unused function + expect({ + used: app.where('MARK_USED'), + unused: app.where('MARK_UNUSED'), + }).toEqual({ used: 'eager', unused: 'eager' }) + expect((await app.run('return entry.read()')).result).toBe('MARK_USED') + }, + timeout, + ) + }, +) + describe('registerCleanup', () => { test('receives the dispose() that removes the app directory', async () => { const cleanups: Array<() => Promise> = [] diff --git a/packages/compiler-test-utils/tests/validate-module.test.ts b/packages/compiler-test-utils/tests/validate-module.test.ts index a4bdc19e1a1..04d686eeb20 100644 --- a/packages/compiler-test-utils/tests/validate-module.test.ts +++ b/packages/compiler-test-utils/tests/validate-module.test.ts @@ -138,6 +138,20 @@ export const Page = () =>
    {imported}
    `), ).toEqual([]) }) + // Browsers define window properties with generic names. A module that + // reads one without declaring it most likely lost a local binding. + test('rejects a read of a generic window property name', async () => { + expect( + await getModuleErrors( + `export const read = () => [name, status, event, parent, top]`, + ), + ).toEqual( + ['name', 'status', 'event', 'parent', 'top'].map( + (name) => `\`${name}\` is referenced but never declared or imported`, + ), + ) + }) + // User ruling 2026-10-17 (dead-code references, widened): a reference to // an undefined name in code the bundlers delete from a production build is // no error of the module. The validator deletes what Rolldown's From d4321e5cc92480348d304daba76f9292521c9689 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 22:47:15 +0200 Subject: [PATCH 0335/1425] test(compiler-test-utils): wait for the Rsbuild compile that includes an edit The Rsbuild dev-server harness read its compile counter after writing the edited files. When the watcher started the compile of the edit before the writes were seen as done (a loaded machine), the harness waited for a later compile that never came: "Rsbuild did not compile src/page.tsx within 20000 ms" (start-plugin-core hydrate-build, rsbuild/dev-server, compiler-test-utils rsbuild-dev-server). - trackCompiles() (rsbuild-dev-server.ts) listens before writing and ends the wait at the end of the compile whose changed files (compiler.modifiedFiles / removedFiles) include the edit: every compiler that read an edited file reported it, and new files were read (or are looked for by no compile). A compile Rspack gives up on fails the wait with its error. The timeout is only a hang guard, with a message naming what no compile included. - Start's Rsbuild watch build (tests/build-harness.ts watchEdits) uses it too, instead of 'the next onAfterBuild after the writes'. - known-bugs onViolation test: compile guard 3 s -> 10 s (the failed compile ends the wait; a slow first compile must not fail it). - Control: an edit whose compile starts while a large unrelated file is still being written (fails on the old harness with the flake's message). Co-Authored-By: Claude Opus 5.5 --- .../src/rsbuild-dev-server.ts | 400 ++++++++++++++---- .../tests/rsbuild-dev-server.test.ts | 23 + .../start-plugin-core/tests/build-harness.ts | 91 +--- .../tests/importProtection/known-bugs.test.ts | 4 +- 4 files changed, 353 insertions(+), 165 deletions(-) diff --git a/packages/compiler-test-utils/src/rsbuild-dev-server.ts b/packages/compiler-test-utils/src/rsbuild-dev-server.ts index 2402d1d33e4..3d0713d1679 100644 --- a/packages/compiler-test-utils/src/rsbuild-dev-server.ts +++ b/packages/compiler-test-utils/src/rsbuild-dev-server.ts @@ -5,8 +5,8 @@ * the rebuild the edit causes, and reads what each environment emitted. * * Unlike the Vite harness, file events come from Rspack's own file watcher: - * `edit()` writes the files and waits for the next compile that starts after - * the write. + * `edit()` writes the files and waits for the compile that includes them + * (`trackCompiles`). * * Plugin-agnostic: the caller supplies the Rsbuild plugins and the directory * the apps live in (where their imports resolve). @@ -20,6 +20,7 @@ import type { RsbuildConfig, RsbuildInstance, RsbuildPlugin, + Rspack, } from '@rsbuild/core' export interface RsbuildDevServerOptions extends AppOptions { @@ -31,7 +32,11 @@ export interface RsbuildDevServerOptions extends AppOptions { entry?: string /** Rsbuild configuration merged over the harness defaults. */ config?: RsbuildConfig - /** How long a compile may take, in milliseconds. Default 20 000. */ + /** + * How long to wait for a compile, in milliseconds: only a guard against a + * compile that never comes (default 20 000). The wait ends as soon as the + * compile ends or fails. + */ compileTimeout?: number } @@ -41,7 +46,8 @@ export interface RsbuildDevServer { rsbuild: RsbuildInstance /** * Writes app files (relative to the app root; `null` deletes a file) and - * waits for the compile Rspack's watcher starts for them. + * waits for the compile that includes them; rejects with the error of a + * compile of them Rspack gave up on. */ edit: (files: Record) => Promise /** @@ -76,54 +82,19 @@ export async function createRsbuildDevServer( }) const root = app.root - let compiles = 0 const rebuilt: Array = [] const errors: Array = [] const warnings: Array = [] - 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 + const compiles = trackCompiles() /** Output directory of each environment. */ const distPaths: Record = {} const harnessPlugin: RsbuildPlugin = { name: 'rsbuild-dev-server-harness', setup(api) { - 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) - } - } - }, - ) - } + compiles.track(compiler) }) api.onDevCompileDone(({ stats, environments }) => { - finished = compiles for (const [name, environment] of Object.entries(environments)) { distPaths[name] = environment.distPath } @@ -146,49 +117,11 @@ export async function createRsbuildDevServer( warnings.push(warning.message) } } - for (const waiter of [...waiters]) { - if (finished > waiter.after) { - waiters.splice(waiters.indexOf(waiter), 1) - waiter.resolve() - } - } + compiles.done() }) }, } - /** Waits until a compile that started after `after` compiles finished. */ - const compiled = (after: number, what: string) => - new Promise((resolve, reject) => { - if (finished > after) { - resolve() - return - } - if (failure && failure.after >= after) { - reject(failure.error) - return - } - const timer = setTimeout(() => { - waiters.splice(waiters.indexOf(waiter), 1) - reject( - new Error( - `Rsbuild did not compile ${what} within ${compileTimeout} ms`, - ), - ) - }, compileTimeout) - const waiter = { - after, - resolve: () => { - clearTimeout(timer) - resolve() - }, - reject: (error: Error) => { - clearTimeout(timer) - reject(error) - }, - } - waiters.push(waiter) - }) - let rsbuild: RsbuildInstance let server: Awaited> /** The dev server once created, closed if its first compile fails. */ @@ -217,7 +150,7 @@ export async function createRsbuildDevServer( }) server = await rsbuild.createDevServer() created = server - await compiled(0, 'the app') + await compiles.first(compileTimeout) } catch (error) { try { // e.g. the first compile timed out: stop watching the app @@ -244,18 +177,7 @@ export async function createRsbuildDevServer( return { root, rsbuild, - edit: async (files) => { - for (const [relative, code] of Object.entries(files)) { - const file = path.join(root, relative) - if (code === null) { - await rm(file, { force: true }) - } else { - await mkdir(path.dirname(file), { recursive: true }) - await writeFile(file, code) - } - } - await compiled(compiles, Object.keys(files).join(', ')) - }, + edit: (files) => compiles.edit(root, files, compileTimeout), outputs: async (environment) => { const name = environment ?? Object.keys(distPaths)[0]! const distPath = distPaths[name] @@ -273,6 +195,298 @@ export async function createRsbuildDevServer( } } +export interface CompileTracker { + /** + * Follows the compiles of an Rspack compiler, or of each compiler of a + * multi-compiler (one per Rsbuild environment). + */ + track: (compiler: Rspack.Compiler | Rspack.MultiCompiler) => void + /** + * To call once the outputs of a compile of every compiler are recorded + * (e.g. Rsbuild's `onDevCompileDone`): settles the waits it completes. + */ + done: () => void + /** + * Waits for the first compile; rejects with the error of a compile Rspack + * gave up on (a plugin hook threw), or after `timeout` ms. + */ + first: (timeout: number) => Promise + /** + * Writes files (relative to `root`; `null` deletes a file) and waits for + * the compile that includes the edit (see `CompileTracker`). + */ + edit: ( + root: string, + files: Record, + timeout: number, + ) => Promise +} + +/** + * Follows the compiles of a watch mode Rspack build, to wait for the + * compile that includes an edit, by what each compile reports changed + * (`compiler.modifiedFiles` and `removedFiles`) rather than by when it + * starts: the watcher may start the compile of an edit before the writes + * finish, all the more on a loaded machine. + * + * The wait for an edit begins before the files are written. It ends at the + * end of a compile once a compile reported an edited file (or a directory + * of one) changed, every compiler that read an edited file reported it + * changed, and every other written file (e.g. a new module) was read or is + * looked for by no compile (which then failed to find it, before the + * write). A compile Rspack gives up on (a plugin hook threw) fails the waits + * for the edits it includes, with its error. `timeout` only guards against + * a compile that never comes. + */ +export function trackCompiles(): CompileTracker { + interface EditWait { + files: Array + /** Edited files a compiler read and did not report changed yet. */ + pending: Map> + /** Written files no compiler read. */ + unread: Array + written: boolean + /** A compile reported an edited file (or a directory of one). */ + reached: boolean + /** `started` when a compile last reported an edited file. */ + lastReport: number + resolve: () => void + reject: (error: Error) => void + } + const compilers: Array = [] + /** + * The dependencies of each compiler's last compile: the files it read, + * and the paths it looked for and did not find. + */ + const dependencies = new Map< + Rspack.Compiler, + { files: Set; missing: Set } + >() + const waits = new Set() + /** Compiles started, by any compiler. */ + let started = 0 + /** `started` at the end of the last compile of every compiler. */ + let startedBeforeDone = 0 + let finished = 0 + let failure: Error | undefined + const firstWaits = new Set<{ + resolve: () => void + reject: (error: Error) => void + }>() + + const touches = (changed: string, file: string) => + file === changed || file.startsWith(changed + path.sep) + + const isRead = (file: string) => + compilers.some((compiler) => dependencies.get(compiler)?.files.has(file)) + + /** Whether a compile looked for the file, or a directory of it. */ + const isMissing = (file: string) => + compilers.some((compiler) => + [...(dependencies.get(compiler)?.missing ?? [])].some( + (missing) => missing !== '/' && touches(missing, file), + ), + ) + + const unreported = (wait: EditWait) => [ + ...new Set([...wait.pending.values()].flatMap((files) => [...files])), + ] + + /** Written files a compile still has to read. */ + const unreadButNeeded = (wait: EditWait) => + wait.unread.filter((file) => !isRead(file) && isMissing(file)) + + /** + * Whether the compiles include the whole edit, and the last one that + * reported part of it ended (no compiler compiles when `done()` is called). + */ + const settled = (wait: EditWait) => + wait.written && + wait.reached && + unreported(wait).length === 0 && + unreadButNeeded(wait).length === 0 && + startedBeforeDone >= wait.lastReport + + const withTimeout = ( + timeout: number, + message: () => string, + register: (handlers: { + resolve: () => void + reject: (error: Error) => void + }) => () => void, + ) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unregister() + reject(new Error(message())) + }, timeout) + const unregister = register({ + resolve: () => { + clearTimeout(timer) + unregister() + resolve() + }, + reject: (error) => { + clearTimeout(timer) + unregister() + reject(error) + }, + }) + }) + + return { + track(compiler) { + for (const environmentCompiler of 'compilers' in compiler + ? compiler.compilers + : [compiler]) { + compilers.push(environmentCompiler) + const name = 'compile-tracker' + environmentCompiler.hooks.watchRun.tap(name, () => { + started++ + const changed = [ + ...(environmentCompiler.modifiedFiles ?? []), + ...(environmentCompiler.removedFiles ?? []), + ] + for (const wait of waits) { + const pending = wait.pending.get(environmentCompiler) + for (const file of changed) { + pending?.delete(file) + } + if ( + wait.files.some((file) => + changed.some((changedPath) => touches(changedPath, file)), + ) + ) { + wait.reached = true + wait.lastReport = started + } + } + }) + // Before Rsbuild's own `done` taps, which announce the end of the + // compile: the next edit may begin right then. + environmentCompiler.hooks.done.tap({ name, stage: -100 }, (stats) => { + const { compilation } = stats + dependencies.set(environmentCompiler, { + files: new Set(compilation.fileDependencies), + missing: new Set(compilation.missingDependencies), + }) + }) + environmentCompiler.hooks.failed.tap(name, (cause) => { + const error = new Error( + `Rsbuild failed to compile the app: ${cause.message}`, + { cause }, + ) + if (finished === 0) { + failure = error + for (const wait of firstWaits) { + wait.reject(error) + } + } + for (const wait of waits) { + if (wait.reached) { + wait.reject(error) + } + } + }) + } + }, + done() { + finished++ + startedBeforeDone = started + for (const wait of firstWaits) { + wait.resolve() + } + for (const wait of waits) { + if (settled(wait)) { + wait.resolve() + } + } + }, + first(timeout) { + if (finished > 0) { + return Promise.resolve() + } + if (failure) { + return Promise.reject(failure) + } + return withTimeout( + timeout, + () => `Rsbuild did not finish the first compile within ${timeout} ms`, + (handlers) => { + firstWaits.add(handlers) + return () => firstWaits.delete(handlers) + }, + ) + }, + async edit(root, edits, timeout) { + const files = Object.keys(edits).map((file) => path.join(root, file)) + const startedBefore = started + const wait: EditWait = { + files, + pending: new Map( + compilers.map((compiler) => [ + compiler, + new Set( + files.filter((file) => + dependencies.get(compiler)?.files.has(file), + ), + ), + ]), + ), + unread: Object.entries(edits) + .filter(([, code]) => code !== null) + .map(([file]) => path.join(root, file)) + .filter((file) => !isRead(file)), + written: false, + reached: false, + lastReport: 0, + resolve: () => {}, + reject: () => {}, + } + // Listens before writing: the watcher may start the compile of the + // edit before the writes finish. + const compiled = withTimeout( + timeout, + () => { + const missing = [...unreported(wait), ...unreadButNeeded(wait)] + return `Rsbuild did not finish a compile of the edit of ${Object.keys(edits).join(', ')} within ${timeout} ms (${started - startedBefore} compiles started since the edit; no compile included ${ + missing.length + ? missing.map((file) => path.relative(root, file)).join(', ') + : 'an edited file' + })` + }, + (handlers) => { + wait.resolve = handlers.resolve + wait.reject = handlers.reject + waits.add(wait) + return () => waits.delete(wait) + }, + ) + // A compile of the edit may fail while the writes finish + compiled.catch(() => {}) + try { + for (const [relative, code] of Object.entries(edits)) { + const file = path.join(root, relative) + if (code === null) { + await rm(file, { force: true }) + } else { + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, code) + } + } + } catch (error) { + wait.reject(error as Error) + } + wait.written = true + // The compile of the edit may have ended while the writes finished + if (settled(wait)) { + wait.resolve() + } + return compiled + }, + } +} + async function readJavaScript(directory: string) { const files: Record = {} const visit = async (current: string) => { 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 e7c99ac15c4..6cc096c532d 100644 --- a/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts +++ b/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts @@ -29,6 +29,29 @@ test('rebuilds the edited module and emits the edit', async () => { expect(await joined()).not.toContain('MARK_V1') }, 30_000) +// The watcher may start the compile of an edit before its writes finish +// (on a loaded machine, before the harness even sees the write done): the +// wait must still end with that compile. Here a large file nothing imports +// keeps the writes going after the module's. +test('a compile that starts before the writes of the edit finish ends the wait', async () => { + const server = await createRsbuildDevServer({ + dir: testsDir, + registerCleanup: onTestFinished, + files: { + 'src/entry.ts': `import { leaf } from './leaf'\nconsole.log(leaf)\n`, + 'src/leaf.ts': `export const leaf = 'MARK_V1'\n`, + }, + }) + + await server.edit({ + 'src/leaf.ts': `export const leaf = 'MARK_V2'\n`, + 'notes/large.txt': 'x'.repeat(64 * 1024 * 1024), + }) + + expect(server.rebuilt).toContain('src/leaf.ts') + expect(Object.values(await server.outputs()).join('\n')).toContain('MARK_V2') +}, 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({ diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index bec33942bd1..725000d5b0b 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -24,7 +24,6 @@ * Import protection is on, with Start's defaults unless the test configures * it; it checks the modules of the source directory (`src`) only. */ -import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -38,6 +37,7 @@ import { stripAnsi, withBuildEnvironment, } from '@tanstack/compiler-test-utils/build-harness' +import { trackCompiles } from '@tanstack/compiler-test-utils/rsbuild-dev-server' import { onTestFinished } from 'vitest' import { tanStackStartVite } from '../src/vite/plugin' import { tanStackStartRsbuild } from '../src/rsbuild/plugin' @@ -500,14 +500,17 @@ async function buildWithRsbuild( const { createRsbuild, mergeRsbuildConfig } = await import('@rsbuild/core') const outputs: Partial = {} const errors: Array = [] - /** Resolves when the current (watch mode) build is done. */ - let built = deferred() + /** The compiles of a watch mode build. */ + const compiles = trackCompiles() /** Records what each environment emitted. */ const outputPlugin: RsbuildPlugin = { name: 'start-build-harness:outputs', setup(api) { + api.onAfterCreateCompiler(({ compiler }) => { + compiles.track(compiler) + }) api.onAfterBuild(() => { - built.resolve() + compiles.done() }) api.onAfterEnvironmentCompile(({ stats, environment }) => { const env = environment.name as Env @@ -565,19 +568,16 @@ async function buildWithRsbuild( ), }) if (target.watchEdits) { - await watchRebuild(target.root, target.watchEdits, { - build: () => rsbuild.build({ watch: true }), - nextBuild: () => { - built = deferred() - return built.promise - }, - firstBuild: () => built.promise, - reset: () => { - errors.length = 0 - warnings.client.length = 0 - warnings.ssr.length = 0 - }, - }) + const watching = await rsbuild.build({ watch: true }) + try { + await compiles.first(watchTimeout) + errors.length = 0 + warnings.client.length = 0 + warnings.ssr.length = 0 + await compiles.edit(target.root, target.watchEdits, watchTimeout) + } finally { + await watching?.close() + } } else { await rsbuild.build() } @@ -597,61 +597,10 @@ async function buildWithRsbuild( } /** - * Starts a watch mode build (`build`), waits for its first build, applies - * `edits` to the app at `root`, waits for the rebuild they cause (at most - * 20 s) and stops watching. + * How long a watch mode build waits for a compile: only a guard against a + * compile that never comes (see `trackCompiles`). */ -async function watchRebuild( - root: string, - edits: Record, - watcher: { - build: () => Promise<{ close: () => Promise } | undefined> - firstBuild: () => Promise - nextBuild: () => Promise - reset: () => void - }, -): Promise { - const firstBuild = watcher.firstBuild() - const result = await watcher.build() - try { - await withTimeout(firstBuild, 'the first watch mode build') - watcher.reset() - const rebuild = watcher.nextBuild() - for (const [file, code] of Object.entries(edits)) { - const filePath = path.join(root, file) - if (code === null) { - await fs.rm(filePath, { force: true }) - } else { - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, code) - } - } - await withTimeout(rebuild, 'the watch mode rebuild of the edits') - } finally { - await result?.close() - } -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve = () => {} - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function withTimeout(promise: Promise, what: string): Promise { - let timer: ReturnType | undefined - return Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`${what} did not finish within 20 s`)), - 20_000, - ) - }), - ]).finally(() => clearTimeout(timer)) -} +const watchTimeout = 20_000 function checkOutputs(outputs: Partial): Outputs { if (!outputs.client || !outputs.ssr) { 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 120d9fe02f5..ed9a6312809 100644 --- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts @@ -452,7 +452,9 @@ describe('Rsbuild dev', () => { throw new Error('MARK_THROWN') }, }, - compileTimeout: 3_000, + // Only a guard: the failed compile ends the wait (a slow first + // compile on a loaded machine must not fail the test). + compileTimeout: 10_000, files: { 'src/secret.server.ts': `export const secret = () => 'secret'\n`, 'src/entry.ts': `import { secret } from './secret.server'\nconsole.log(secret())\n`, From b3f52fbfb5565fef5e6a5497762fc5151ce16652 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 23:13:25 +0200 Subject: [PATCH 0336/1425] test(router-plugin): pin fuzz round-2 findings F1 and split-binding variants F1 (MED): an exported component named by a split option written as `X as any`, `X as typeof X`, `withAuth(X)` or `memo(X)` is copied into the option's chunk, so the route renders another instance than its importers get. Compiler-level `.fails.each` over the four shapes (through a real importer, ruling P1) with the bare identifier as passing control; build pin per client bundler (Vite, Rspack, webpack): the component is in the client once, split or reported (decision D11). Red: other instance; `eager+lazy` on all three bundlers. Variants (all `.fails`, red on main): - an unsplit option that reads a Route-reading split component with `typeof` sees 'undefined' (split-binding-declaration-deleted-while-read); - `((flag) as any) ? -x : y` loses its parentheses in a split chunk (upstream @babel/generator 7.29.8, no workaround): PARSE_ERROR; - N3 row `(

    page

    )?.valueOf()`: PARSE_ERROR. Co-Authored-By: Claude Opus 5.5 --- .../tests/known-bugs-code-splitter.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 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 6cfca012396..6a305d187c0 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -2596,6 +2596,146 @@ return globalThis.__inits === undefined ? [html] : [html, globalThis.__inits]`, }, 30_000, ) + + /** + * Loads a route whose errorComponent option is the exported + * `PostErrorComponent`, written as `value`, next to another module of the + * app that imports `PostErrorComponent` from the route file (ruling P1: + * through a real importer). `withAuth` (`./with-auth`) and `memo` + * (`react`) are identity stubs, so the option's value is the component + * itself. Returns whether the route renders the importer's instance, and + * whether the option is split or the user is told it is not (a warning + * naming the route file and the option or the component). + */ + async function exportedErrorComponent(value: string) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const route = await loadRoute( + `${head}import { withAuth } from './with-auth' +import { memo } from 'react' +export const Route = createFileRoute('/posts/$postId')({ + errorComponent: ${value}, + component: () =>

    post

    , +}) +export function PostErrorComponent() { + return

    error

    +} +`, + { + stubs: { + './with-auth': { withAuth: (component: unknown) => component }, + react: { memo: (component: unknown) => component }, + }, + }, + ) + const importer = await route.importFromRoute( + `import { PostErrorComponent } from '${routeSpecifier}' +export const imported = PostErrorComponent`, + ) + const errorComponent = await route.load('errorComponent') + const file = route.ids.reference! + return { + rendered: errorComponent(), + sameInstance: errorComponent === importer.imported, + splitOrReported: + route.isLazy('errorComponent') || + warn.mock.calls.some(([message]) => { + const text = String(message) + return ( + text.includes(file) && + /\b(?:PostErrorComponent|errorComponent)\b/.test(text) + ) + }), + } + } finally { + warn.mockRestore() + } + } + + // Control for the pin below (same harness): the bare identifier. + test('an exported errorComponent named by the option is the instance its importers get', async () => { + expect(await exportedErrorComponent('PostErrorComponent')).toEqual({ + rendered: '

    error

    ', + sameInstance: true, + splitOrReported: true, + }) + }) + + // Bug: an exported component named by a split option is only recognised + // as an export when the option is a bare identifier. Written in another + // shape (`as any`, `as typeof X`, an HOC call such as `withAuth(X)` or + // `memo(X)`), the option is split and its chunk declares its own copy of + // the exported component, so the route renders another instance than the + // one other modules import (and no "not code-split" warning is shown). + // Either outcome of the bare identifier (one instance, split or unsplit + // with a warning) is fine (ruling P1, decision D11). + // Reach: the repository's examples (react and solid + // basic-virtual-file-based posts-detail.tsx) and e2e apps (react, solid + // and vue basic-file-based-code-splitting, virtual and rspack-virtual + // routes, solid-start virtual-routes) write `errorComponent: + // PostErrorComponent as any`; no other module imports it there, so they + // show no symptom. + // Source: compilers.ts:945-960 (the export check only handles a bare + // Identifier), :572-591, the copy via :1170-1242; fuzzing (oracle mode, + // round 2, F1). + // Impact: code that relies on the identity of the component (`===` + // checks, registries, memoised props) sees two of them, and the component + // code ships twice (see the build pin below). + test.fails.each([ + 'PostErrorComponent as any', + 'PostErrorComponent as typeof PostErrorComponent', + 'withAuth(PostErrorComponent)', + 'memo(PostErrorComponent)', + ])( + 'an exported errorComponent written as `%s` is the instance its importers get', + async (value) => { + expect(await exportedErrorComponent(value)).toEqual({ + rendered: '

    error

    ', + sameInstance: true, + splitOrReported: true, + }) + }, + ) + + // Bug: same as above, in a real client build whose entry imports the + // exported component (`export * from './routes/index'`): the component is + // in both the entry chunk and the split chunk. One instance (split, or + // unsplit with a warning naming the route file, decision D11) is the goal. + // Impact: the component's code ships twice and runs as two components. + test.fails.each(clientBundlers)( + 'an exported errorComponent written as `PostErrorComponent as any` is in the client once (%s build)', + async (bundler) => { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `${head}export const Route = createFileRoute('/posts/$postId')({ + errorComponent: PostErrorComponent as any, + component: () =>

    post

    , +}) +export function PostErrorComponent() { + return

    {'MARK_POST_ERROR_END'}

    +} +`, + { env: 'client' }, + ), + }) + const { result: lazy } = await app.run( + `return typeof entry.Route.options.errorComponent.preload === 'function'`, + ) + const reported = [...app.warnings, ...app.consoleMessages].some( + (message) => + message.includes('routes/index.tsx') && + /\b(?:PostErrorComponent|errorComponent)\b/.test(message), + ) + const placement = app.where('MARK_POST_ERROR_END') + expect(['lazy', 'eager']).toContain(placement) + expect({ splitOrReported: lazy === true || reported }).toEqual({ + splitOrReported: true, + }) + }, + 60_000, + ) }) /** @@ -3238,6 +3378,36 @@ export const Route = createFileRoute('/')({ component: Page }) }, ) + // Bug: the split chunk prints a type assertion that is the test of a + // conditional whose branch starts with a unary minus without its + // parentheses (`a as any ? -x : y`), which does not parse (`any ? -x` + // reads as the start of a conditional type). The root cause is upstream: + // @babel/generator 7.29.8 drops the parentheses (parse+generate of + // `f = ((a) as any) ? -x : y` gives `f = a as any ? -x : y;`; no + // workaround here; router-utils/src/ast.ts:72 `generateFromAst` prints + // with it). The Start compiler has the same bug (pin in + // start-plugin-core/tests/start-compiler-preserves-user-code.test.ts). + // Source: fuzzing (oracle mode, round 2). + // Impact: the split component's chunk fails to compile, so the build + // fails. + test.fails( + 'an asserted conditional test keeps its parentheses in a split component', + async () => { + const code = `${head}function pick(flag: unknown, x: number, y: number) { + return ((flag) as any) ? -x : y +} +function Page() { + return

    {pick(true, 1, 2)}

    +} +export const Route = createFileRoute('/')({ component: Page }) +` + await expectValidModules(compileRouteModules(code).modules) + expect((await (await loadRoute(code)).load('component'))()).toBe( + '

    -1

    ', + ) + }, + ) + /** * Compiles a route whose split component returns `expression`, checks that * every emitted module is valid and returns what the component renders. @@ -3273,6 +3443,10 @@ export const Route = createFileRoute('/')({ component: Page }) rendered: '', }, { name: 'a member call', expression: '(

    page

    ).valueOf()' }, + { + name: 'an optional member call', + expression: '(

    page

    )?.valueOf()', + }, ])( 'parenthesized JSX under $name keeps working in a split component', async ({ expression, rendered = '

    page

    ' }) => { @@ -3589,6 +3763,33 @@ function Page() { }, ) + // Bug: same root cause as above, for an unsplit option that reads the + // split component (which reads `Route`, so it moves to its chunk) with + // `typeof`. Reading an undeclared name with `typeof` does not throw, so + // the route module loads and the option silently sees `'undefined'`. + // Source: fuzzing (oracle mode, round 2: `beforeLoad` and `loaderDeps` + // readers of components of the repository's e2e routes). + // Impact: code that checks the component (`typeof Page === 'function'`) + // takes the wrong branch on every navigation, with no error. + test.fails( + 'an unsplit option that reads the split component with typeof sees a function', + async () => { + const route = + await loadRoute(`${head}export const Route = createFileRoute('/')({ + component: Page, + beforeLoad: () => typeof Page, +}) +function Page() { + return

    {typeof Route}

    +} +`) + expect([ + route.options.beforeLoad(), + (await route.load('component'))(), + ]).toEqual(['function', '

    object

    ']) + }, + ) + // Bug: same root cause as above, for a top-level call that reads the split // component. The call runs once, also after the chunk loads (a chunk that // calls `register` again registers a second component). Source: #8504 From 613fb80bd7fa9386f2c07699550c40949366e9b0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 23:13:25 +0200 Subject: [PATCH 0337/1425] test(start-plugin-core): pin fuzz round-2 Hydrate findings F2, F4 and a nested children-prop id F4 (HIGH): split route options with boundaries that another option reads (`head` reads `Page.name`) go to a shared module without the lazy component of the boundary. Vite and Rsbuild Start builds, `.fails`; control: the existing default-groupings route test (same route, now built from a shared fixture helper). Red: the build harness rejects the client output (dangling generated name in the entry chunk). F2 (LOW): an unused exported component's boundary still ships (its module-level lazyRouteComponent call is kept). One shared build per bundler; control without a boundary passes. Red: marker `lazy`, not `nowhere`. Variant: a boundary nested in a `children` prop hydrates without the id the server renders (hydrate-children-prop family). Red: client id undefined. Co-Authored-By: Claude Opus 5.5 --- .../tests/hydrate-build.test.ts | 168 +++++++++++++++--- .../tests/known-bugs-hydrate.test.ts | 23 +++ 2 files changed, 164 insertions(+), 27 deletions(-) diff --git a/packages/start-plugin-core/tests/hydrate-build.test.ts b/packages/start-plugin-core/tests/hydrate-build.test.ts index 94cb2bdc90c..140c82b8655 100644 --- a/packages/start-plugin-core/tests/hydrate-build.test.ts +++ b/packages/start-plugin-core/tests/hydrate-build.test.ts @@ -4,7 +4,7 @@ * bundler's decision (with the side-effect hints of the app), so it is * asserted on build output. */ -import { describe, expect, onTestFinished, test } from 'vitest' +import { afterAll, describe, expect, onTestFinished, test } from 'vitest' import path from 'node:path' import { fileURLToPath } from 'node:url' import { originalFrameOf } from '@tanstack/compiler-test-utils/build-harness' @@ -23,6 +23,7 @@ import { startDevServer } from './dev-server' import { startRsbuildDevServer } from './rsbuild-dev-server' import { getChunkComponent, getChunkIds } from './regression-helpers' import type { BuildResult } from '@tanstack/compiler-test-utils/build-harness' +import type { StartBuild } from './build-harness' const timeout = 60_000 @@ -325,6 +326,38 @@ export function Page() { timeout, ) + /** + * A route whose split options `pendingComponent` and `component` render + * boundaries, with `reader` as more route options, and the router that + * imports the route tree. + */ + const splitBoundariesFiles = (reader = '') => ({ + 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' +import { Hydrate } from '@tanstack/react-start' +export const Route = createFileRoute('/')({ + pendingComponent: Pending, + component: Page,${reader} +}) +function Pending() { + return

    pending-marker

    +} +function Page() { + return

    page-marker

    +} +`, + 'src/router.ts': `import { routeTree } from './routeTree.gen' +export const getRouter = () => ({ routeTree }) +`, + 'entry.ts': `export { getRouter } from './src/router'\n`, + }) + /** Renders the split options of the route `/` of `splitBoundariesFiles`. */ + const renderSplitBoundaries = `(() => { + const route = Object.values(entry.getRouter().routeTree.children).find( + (candidate) => candidate.options.id === '/', + ) + return [route.options.pendingComponent(), route.options.component()] +})()` + // A route whose split options (`component` and `pendingComponent`) // contain boundaries: the client hydrates each boundary with the id the // server renders it with, and loads its own children. @@ -345,37 +378,13 @@ export function Page() { start: groupings && { router: { codeSplittingOptions: { defaultBehavior: groupings } }, }, - files: { - ...hydrateRuntimeFiles, - 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' -import { Hydrate } from '@tanstack/react-start' -export const Route = createFileRoute('/')({ - pendingComponent: Pending, - component: Page, -}) -function Pending() { - return

    pending-marker

    -} -function Page() { - return

    page-marker

    -} -`, - 'src/router.ts': `import { routeTree } from './routeTree.gen' -export const getRouter = () => ({ routeTree }) -`, - 'entry.ts': `export { getRouter } from './src/router'\n`, - }, + files: { ...hydrateRuntimeFiles, ...splitBoundariesFiles() }, }) expect(build.client.where('page-marker')).toBe('lazy') expect(build.client.where('pending-marker')).toBe('lazy') const server = await expectHydratesLikeServer( build, - `(() => { - const route = Object.values(entry.getRouter().routeTree.children).find( - (candidate) => candidate.options.id === '/', - ) - return [route.options.pendingComponent(), route.options.component()] -})()`, + renderSplitBoundaries, ) expect(server.html).toEqual([ '[

    pending-marker

    ]', @@ -386,6 +395,111 @@ export const getRouter = () => ({ routeTree }) timeout, ) + // Bug: a split component with a boundary that another route option + // reads (`head` reads `Page.name`) goes to a module the route module and + // the chunk share, without the lazy component of its boundary, which + // stays in the chunk: the shared module reads an undeclared name (the + // build harness rejects the client output). Root cause: the code + // splitter takes a JSX tag whose name starts with `_` for an intrinsic + // element and does not count it as a read of the binding, here a name + // the `` transform generates (router-utils/src/ + // compiler-helpers.ts:268; same root cause as the `_Widget` row in + // router-plugin/tests/code-splitter-jsx.test.ts). + // Control: the test above (same route, without `head`). + // Source: fuzzing (oracle mode, round 2, F4: real Start builds, Vite and + // Rsbuild). + // Impact: the route's component throws a ReferenceError when it + // renders, so the page never renders. + test.fails( + 'boundaries in split route options that another option reads hydrate like the server renders them', + async () => { + const build = await buildStart({ + bundler, + files: { + ...hydrateRuntimeFiles, + ...splitBoundariesFiles( + `\n head: () => ({ meta: [{ title: Page.name }] }),`, + ), + }, + }) + const server = await expectHydratesLikeServer( + build, + renderSplitBoundaries, + ) + expect(server.html).toEqual([ + '[

    pending-marker

    ]', + '
    [

    page-marker

    ]
    ', + ]) + }, + timeout, + ) + + // One build for the control and the pin below: a module with unused + // exported components, one with a boundary + // (`src/hydrated-widgets.tsx`) and one without (`src/plain-widgets.tsx`); + // the entry imports only their used neighbours. + let widgetsBuild: Promise | undefined + let disposeWidgets: (() => Promise) | undefined + const widgetsApp = () => + (widgetsBuild ??= buildStart({ + bundler, + registerCleanup: (cleanup) => { + disposeWidgets = cleanup + }, + files: { + ...hydrateRuntimeFiles, + 'src/hydrated-widgets.tsx': `import { Hydrate } from '@tanstack/react-start' +export function UnusedHydratedWidget() { + return
    unused-hydrated-marker
    +} +export function UsedHydratedWidget() { + return

    used

    +} +`, + 'src/plain-widgets.tsx': `export function UnusedPlainWidget() { + return
    unused-plain-marker
    +} +export function UsedPlainWidget() { + return

    used

    +} +`, + 'entry.ts': `export { UsedHydratedWidget } from './src/hydrated-widgets' +export { UsedPlainWidget } from './src/plain-widgets' +`, + }, + })) + afterAll(() => disposeWidgets?.()) + + // Control for the pin below (same build): an unused export without a + // boundary is not in the client. + test( + 'an unused exported component without a boundary is not in the client', + async () => { + expect((await widgetsApp()).client.where('unused-plain-marker')).toBe( + 'nowhere', + ) + }, + timeout, + ) + + // Bug: the client parent declares the lazy component of each boundary + // with a module-level call (`lazyRouteComponent(() => import(...))`) + // that bundlers must keep (it is not marked pure), so the children of an + // unused exported component still ship: the call stays in the eager + // code, and its chunk is emitted. + // Source: hydrate-when-transform.ts:653-690 (the call at :676); fuzzing + // (oracle mode, round 2, F2). + // Impact: the code of components nothing renders loads in the client. + test.fails( + 'an unused exported component with a boundary is not in the client', + async () => { + expect( + (await widgetsApp()).client.where('unused-hydrated-marker'), + ).toBe('nowhere') + }, + timeout, + ) + /** * Builds a page whose split children render `Widget`, an export no other * module imports, declared as `export const Widget = `. 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 c014ec4a9c0..85249870a28 100644 --- a/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts +++ b/packages/start-plugin-core/tests/known-bugs-hydrate.test.ts @@ -297,6 +297,29 @@ export function Page() { }, ) + // Bug: same root cause as above, for a `children` prop that holds another + // boundary: the client does not compile the JSX of the prop, so the nested + // boundary hydrates without the id the server renders it with. + // Source: fuzzing (oracle mode, round 2; mutants of the repository's + // e2e/react-start/deferred-hydration EnhancedNestedWidget.tsx). + // Impact: the nested boundary hydrates without its id (a hydration + // mismatch). + test.fails( + 'client: a boundary nested in a children prop hydrates with the id the server renders', + async () => { + const code = `${head}export function Page() { + return

    inner

    } /> +}` + const render = (module: Record) => module.Page() + const server = await renderBoundaries('server', code, render) + expect(server).toEqual({ + html: '[
    [

    inner

    ]
    ]', + ids: [expect.any(String), expect.any(String)], + }) + expect(await renderBoundaries('client', code, render)).toEqual(server) + }, + ) + // Bug: a multi-line text-only child moves into the chunk as a string // literal of the raw text, without the line collapsing of JSX text. // Impact: the client renders other text than the server (a hydration From 639d11a1c80504709160f75a1ad36b701909b7d0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 17 Oct 2026 23:13:25 +0200 Subject: [PATCH 0338/1425] test(start-plugin-core): pin fuzz round-2 F3 and Start compiler variants F3 (MED): an isomorphic fn whose `.server()` or `.client()` link is stored in a variable loses that implementation. Compiler level (both directions, evaluated with the Start runtime of each environment) next to the N4 builder-variable pins, control: the inline chain. Build rows on Vite and Rsbuild in the shared env-separation build (new module src/iso-builders.ts), control: the existing inline isomorphic fn. Red: SSR or client returns undefined. Variants (all `.fails`, red on main): - `applyStep(createServerFn().validator(schema)).handler(...)`: module left untransformed, client imports the schema; - `withBuilder(createIsomorphicFn().server(f)).client(g)`: module throws "withBuilder(...).client is not a function"; - V1 mirror: SSR keeps a client-only declaration a `typeof` type alias reads, with its import (control without the alias); - a class with a call in its extends clause used by a handler keeps its server-only import in the client (dce-keeps-class row; the server-only stub now also exports TaggedError); - `((flag) as any) ? -x : y` loses its parentheses in the client output (upstream @babel/generator 7.29.8, no workaround); - N3 row `(

    page

    )?.valueOf()`. Co-Authored-By: Claude Opus 5.5 --- .../tests/known-bugs-start-compiler.test.ts | 140 +++++++++++++++++- .../tests/start-build-env-separation.test.ts | 43 ++++++ .../tests/start-compiler-factories.test.ts | 56 +++++++ ...start-compiler-preserves-user-code.test.ts | 30 ++++ 4 files changed, 268 insertions(+), 1 deletion(-) 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 89d80472c0d..db75577c121 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 @@ -597,6 +597,88 @@ export const fn = authed().handler(async () => db.x())`, }, ) + // Bug: same family as above, for a builder the user passes through a + // helper function (`applyStep(createServerFn().validator(schema))`): the + // `.handler()` chain is not traced through the call, so the module is + // left untransformed. Rejecting it with a clear compile error naming + // `createServerFn` is a valid fix; supporting it means the client gets + // neither the validator nor the handler, and the provider runs both. + // Source: fuzzing (oracle mode, round 2: builders passed through user + // helpers, 88 mutants). + // Impact: the validator, the handler and what they import ship to the + // client, and the client runs the handler instead of calling the server. + test.fails( + 'client builds compile a server fn whose builder passes through a helper function', + async () => { + const code = `${head}import { schema } from './schema' +function applyStep(builder: T) { + return builder +} +export const fn = applyStep( + createServerFn({ method: 'POST' }).validator(schema), +).handler(async ({ data }) => data)` + const client = await compileCode('client', code).catch( + (error: Error) => error, + ) + if (client instanceof Error) { + expect(compileErrorMessage(client)).toMatch(/\bcreateServerFn\b/) + return + } + expect(importSources(client ?? code)).toEqual([]) + const compiled = await compileAll(code) + expect( + await callProvider( + compiled, + 'fn', + { './schema': { schema: (data: string) => `${data}!` } }, + 'data', + ), + ).toBe('data!') + }, + ) + + // Bug: same family, for an isomorphic fn whose chain passes through a + // helper function (`withBuilder(createIsomorphicFn().server(f)).client(g)`): + // the inner `createIsomorphicFn().server(f)` is compiled on its own into a + // plain function, so `.client(g)` is then called on that function. + // Rejecting it with a clear compile error naming `createIsomorphicFn` is a + // valid fix; supporting it means each output runs its implementation. + // Source: fuzzing (oracle mode, round 2: builders passed through user + // helpers). + // Impact: the module throws when it loads ("withBuilder(...).client is not + // a function"), on the client and on the server. + test.fails( + 'each output keeps its implementation of an isomorphic fn whose chain passes through a helper function', + async () => { + const code = `import { createIsomorphicFn } from '@tanstack/react-start' +function withBuilder(builder: T) { + return builder +} +export const getEnv = withBuilder( + createIsomorphicFn().server(() => 'server'), +).client(() => 'client')` + const results: Record = {} + for (const output of ['client', 'ssr'] as const) { + const compiled = await compileCode(output, code).catch( + (error: Error) => error, + ) + if (compiled instanceof Error) { + expect(compileErrorMessage(compiled)).toMatch( + /\bcreateIsomorphicFn\b/, + ) + return + } + const module = await evaluateModule( + compiled ?? code, + {}, + { env: output === 'ssr' ? 'server' : 'client' }, + ) + results[output] = settle(module.getEnv) + } + expect(results).toEqual({ client: 'client', ssr: 'server' }) + }, + ) + // Bug: chain methods are only recognized when called through a static // member (`.handler(...)`), so one called through a computed member // (`builder['handler'](...)`) leaves the module untransformed. Unusual @@ -1169,7 +1251,8 @@ export const fn = createServerFn().handler(async () => 1)` /** A server-only module that fails loudly when a client module loads it. */ const serverModuleStub = { [serverOnly]: `throw new Error('server module loaded on the client') -export const db = {}`, +export const db = {} +export const TaggedError = () => class {}`, } // Control for the pin below (same harness): a plain function only the @@ -1200,6 +1283,20 @@ class Repo { get() { return db.users() } } export const fn = createServerFn().handler(async () => new Repo().get())`, check: (module: Record) => expect(module.fn).toBeDefined(), }, + { + // A class whose `extends` clause calls an imported factory, as error + // classes of effect (`Data.TaggedError('NotFound')<...>`) and + // better-result are written. + // Source: fuzzing (oracle mode, round 2; two corpus files). + name: 'a class with a call in its extends clause used by a server fn handler', + code: `import { createServerFn } from '@tanstack/react-start' +import { TaggedError } from './db.server' +class NotFound extends TaggedError('NotFound')<{ id: string }>() {} +export const fn = createServerFn().handler(async () => { + throw new NotFound({ id: '1' }) +})`, + check: (module: Record) => expect(module.fn).toBeDefined(), + }, { name: 'an enum used by a server fn handler', code: `import { createServerFn } from '@tanstack/react-start' @@ -1378,6 +1475,47 @@ export const setTheme = createServerFn({ method: 'POST' }) }, ) + /** + * A client-only fn whose implementation reads a module-level chart that a + * client-only module creates. + */ + const clientOnlyChartModule = ( + typeAlias: string, + ) => `import { createClientOnlyFn } from '@tanstack/react-start' +import { createChart } from './chart.client' +const chart = createChart() +${typeAlias} +export const draw = createClientOnlyFn(() => chart.draw())` + + // Control for the pin below (same harness): a declaration only the + // client-only implementation reads leaves the SSR output with its import. + test('ssr: a declaration only a client-only fn reads is not loaded', async () => { + const code = clientOnlyChartModule('') + expect( + await erasedImportSources((await compileCode('ssr', code)) ?? code), + ).toEqual([]) + }) + + // Bug: same root cause as the pin above (upstream + // babel-dead-code-elimination 1.0.12 counts a `typeof` type query as a + // reference; no workaround here), mirrored on the server: a module-level + // declaration that only a client-only implementation and a `typeof` type + // query read stays in the SSR output, with its initializer and imports. + // Source: fuzzing (oracle mode, round 2, V1 mirror; an unmodified file of + // tanstack.com, src/components/ThemeProvider.tsx). + // Impact: the server loads the client-only module and runs the + // initializer (e.g. `window.matchMedia(...)`), which can throw during + // SSR. + test.fails( + 'ssr: a declaration a client-only fn reads and a type alias reads with typeof is not loaded', + async () => { + const code = clientOnlyChartModule('type Chart = typeof chart') + expect( + await erasedImportSources((await compileCode('ssr', code)) ?? code), + ).toEqual([]) + }, + ) + // Bug: removing a server fn handler from the callers keeps the comments // attached to it, which move to the RPC that replaces it. Middleware, // isomorphic and server-only fns drop them. 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 2f05d94b8de..31661b42af5 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 @@ -210,6 +210,13 @@ type Theme = ReturnType export const setTheme = createServerFn({ method: 'POST' }) .validator(themeValidator) .handler(async ({ data }): Promise => data) +`, + // Isomorphic fns whose first link is stored in a variable. + 'src/iso-builders.ts': `import { createIsomorphicFn } from '@tanstack/react-start' +const serverBase = createIsomorphicFn().server(() => 'iso-var-server') +export const isoServerVar = serverBase.client(() => 'iso-var-client') +const clientBase = createIsomorphicFn().client(() => 'iso-var-client') +export const isoClientVar = clientBase.server(() => 'iso-var-server') `, 'src/middleware.ts': `import { createMiddleware } from '@tanstack/react-start' import { middlewareServer } from './deps/middlewareServer' @@ -277,6 +284,7 @@ export const getRouter = () => ({ routeTree }) `, 'src/entry.ts': `export { fn, fromBase, isomorphic, serverOnly } from './fns' export { setTheme, validatedFn } from './builders' +export { isoClientVar, isoServerVar } from './iso-builders' export { serverMiddleware, validatedMiddleware } from './builder-middleware' export { fnMiddleware } from './middleware' export { startInstance } from './start' @@ -455,6 +463,41 @@ const next = (options) => options }, ) + /** What `run` returns in the client and in the SSR build. */ + const callInBoth = async (run: string) => ({ + client: (await build.client.run(run)).result, + ssr: (await build.ssr.run(run)).result, + }) + + // Control for the pin below (same build): the inline chain. + test('an isomorphic fn written as one chain runs its implementation in each build', async () => { + expect(await callInBoth(`return entry.isomorphic()`)).toEqual({ + client: 'MARK_ISOMORPHIC_CLIENT', + ssr: 'MARK_ISOMORPHIC_SERVER', + }) + }) + + // Bug: an isomorphic fn whose first link is stored in a variable loses + // that link's implementation in its environment's build (no-op, returns + // undefined). Pinned at compiler level in + // start-compiler-factories.test.ts. + // Source: start-compiler/handleCreateIsomorphicFn.ts:17-25, + // start-compiler/compiler.ts:1296-1330; fuzzing (oracle mode, round 2, + // F3). + // Impact: the server (or client) call silently returns undefined. + test.fails.each([ + { name: '.server() link', run: `return entry.isoServerVar()` }, + { name: '.client() link', run: `return entry.isoClientVar()` }, + ])( + 'an isomorphic fn whose $name is stored in a variable runs its implementation in each build', + async ({ run }) => { + expect(await callInBoth(run)).toEqual({ + client: 'iso-var-client', + ssr: 'iso-var-server', + }) + }, + ) + // 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). 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 c6403ada5d6..19d9a746c69 100644 --- a/packages/start-plugin-core/tests/start-compiler-factories.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-factories.test.ts @@ -348,6 +348,62 @@ export default createMiddleware().server(async ({ next }) => { ) }) + /** + * Compiles `code` (which exports `getEnv`) for the client and SSR and + * calls `getEnv` in each output, with the Start runtime of its + * environment. + */ + async function callIsomorphicFn(code: string) { + const client = (await compileCode('client', code)) ?? code + const ssr = (await compileCode('ssr', code)) ?? code + return { + client: settle((await evaluateModule(client)).getEnv), + ssr: settle((await evaluateModule(ssr, {}, { env: 'server' })).getEnv), + } + } + + // Control for the pin below (same harness): the inline chain. + test('each output keeps its implementation of an isomorphic fn written as one chain', async () => { + expect( + await callIsomorphicFn(`import { createIsomorphicFn } from '@tanstack/react-start' +export const getEnv = createIsomorphicFn().server(() => 'server').client(() => 'client')`), + ).toEqual({ client: 'client', ssr: 'server' }) + }) + + // Bug: an isomorphic fn whose first link is stored in a variable + // (`const base = createIsomorphicFn().server(...)`, then + // `export const getEnv = base.client(...)`) loses that link's + // implementation: each call is compiled on its own, the chain walk from + // `base.client(...)` stops at the identifier, so the output of the stored + // link's environment gets a no-op. Same chain walk as the `.validator()` + // pin of a builder stored in a variable above (fuzz N4). + // Source: start-compiler/handleCreateIsomorphicFn.ts:17-25, + // start-compiler/compiler.ts:1296-1330; fuzzing (oracle mode, round 2, + // F3; Vite and Rsbuild builds return undefined too). + // Impact: the server (or client) call silently returns undefined instead + // of running its implementation. + test.fails.each([ + { + name: '.server() link', + code: `const base = createIsomorphicFn().server(() => 'server') +export const getEnv = base.client(() => 'client')`, + }, + { + name: '.client() link', + code: `const base = createIsomorphicFn().client(() => 'client') +export const getEnv = base.server(() => 'server')`, + }, + ])( + 'each output keeps its implementation of an isomorphic fn whose $name is stored in a variable', + async ({ code }) => { + expect( + await callIsomorphicFn( + `import { createIsomorphicFn } from '@tanstack/react-start'\n${code}`, + ), + ).toEqual({ client: 'client', ssr: 'server' }) + }, + ) + // Bug: an isomorphic chain link wrapped in `satisfies` / `as` is not // followed: the implementation of the wrapped link is lost. // Impact: the output that needs it gets a no-op isomorphic fn (the server diff --git a/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts b/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts index 074ce7602d1..4844dc7e727 100644 --- a/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts @@ -340,6 +340,32 @@ export function bump() { expect((await evaluateModule(client!)).bump()).toBe(4) }) +// Bug: the client output prints a type assertion that is the test of a +// conditional whose branch starts with a unary minus without its parentheses +// (`a as any ? -x : y`), which does not parse (`any ? -x` reads as the start +// of a conditional type). The root cause is upstream: @babel/generator 7.29.8 +// drops the parentheses (parse+generate of `f = ((a) as any) ? -x : y` gives +// `f = a as any ? -x : y;`; no workaround here; router-utils/src/ast.ts:72 +// `generateFromAst` prints with it). The code splitter has the same bug (pin +// in router-plugin/tests/known-bugs-code-splitter.test.ts). +// Impact: the module fails to parse, so the build fails. +// Source: fuzzing (oracle mode, round 2). +test.fails( + 'keeps the parentheses of an asserted conditional test', + async () => { + const client = await compileCode( + 'client', + `import { createServerFn } from '@tanstack/react-start' +export const fn = createServerFn().handler(async () => 1) +export function pick(flag: unknown, x: number, y: number) { + return ((flag) as any) ? -x : y +}`, + ) + expect(await getModuleErrors(client!)).toEqual([]) + expect((await evaluateModule(client!)).pick(true, 1, 2)).toBe(-1) + }, +) + /** Compiles a module whose `Page` returns `expression` for the client. */ async function compilePageReturning(expression: string) { const client = await compileCode( @@ -377,6 +403,10 @@ test.fails.each([ rendered: '', }, { name: 'a member call', expression: '(

    page

    ).valueOf()' }, + { + name: 'an optional member call', + expression: '(

    page

    )?.valueOf()', + }, ])( 'client: keeps parenthesized JSX under $name working', async ({ expression, rendered = '

    page

    ' }) => { From 69ab04aaa5e1862adfb29d6bbe9732beccd928d7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 08:48:33 +0200 Subject: [PATCH 0339/1425] test(compiler-test-utils): keep Vite's default pre-transform requests in the dev harness The Vite dev-server harness turned `server.preTransformRequests` off for every test, unlike a real app, where the client environment transforms the static imports of each served module in the background. That hid r5 M1 (a false import-protection warning). The harness now keeps Vite's default; a test can still turn it off through `config`. Adds `idle()`, which waits until no environment has a transform in flight (background pre-transforms included), so tests can read what those transforms log deterministically. No status change in router-plugin, start-plugin-core, react-start-rsc, router-generator or compiler-test-utils with the default on. Co-Authored-By: Claude Opus 5.5 --- .../compiler-test-utils/src/dev-server.ts | 57 ++++++++++++++++++- .../tests/dev-server.test.ts | 41 +++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/compiler-test-utils/src/dev-server.ts b/packages/compiler-test-utils/src/dev-server.ts index 9348daf216e..787b33727e5 100644 --- a/packages/compiler-test-utils/src/dev-server.ts +++ b/packages/compiler-test-utils/src/dev-server.ts @@ -137,6 +137,14 @@ export interface DevServer { url: string, options?: { environment?: string; skip?: RegExp }, ) => Promise> + /** + * Waits until no environment has a module transform in flight, including + * the ones Vite starts in the background: with its default + * `server.preTransformRequests`, the client environment transforms the + * static imports of each module it serves before anything requests them. + * What those transforms log is in `logs` afterwards. + */ + idle: () => Promise /** Requests a URL over HTTP (`listen: true` only). */ fetch: (url: string) => Promise<{ status: number; body: string }> /** Imports a module in a runnable environment (default `ssr`). */ @@ -269,8 +277,10 @@ export async function createDevServer( server: { middlewareMode: !listen, ws: false, - // `load()` requests the imports a test needs - preTransformRequests: false, + // Vite's default `preTransformRequests` stays on, as in an app: + // the client environment transforms the static imports of each + // module it serves in the background, before the browser + // requests them. // `settle()` reports file events instead of a watcher watch: null, hotUpdateEnvironments: async ( @@ -493,6 +503,48 @@ export async function createDevServer( return found } + /** + * The transforms each environment has in flight. Vite has no public API + * for them: `waitForRequestsIdle()` only covers the first requests. + */ + const pendingTransforms = () => + Object.values(vite.environments).flatMap((environment) => [ + ...(( + environment as unknown as { + _pendingRequests?: Map }> + } + )._pendingRequests?.values() ?? []), + ]) + + async function idle() { + const deadline = Date.now() + eventTimeout + // Quiet for two turns of the event loop: a transform that ends may + // start the pre-transforms of its imports right after. + for (let quiet = 0; quiet < 2; ) { + await new Promise((resolve) => setImmediate(resolve)) + const pending = pendingTransforms() + if (!pending.length) { + quiet++ + continue + } + quiet = 0 + const left = deadline - Date.now() + if (left <= 0) { + throw new Error( + `the dev server still had ${pending.length} transforms in flight after ${eventTimeout} ms`, + ) + } + let timer: ReturnType | undefined + await Promise.race([ + Promise.allSettled(pending.map(({ request }) => request)), + new Promise((resolve) => { + timer = setTimeout(resolve, left) + }), + ]) + clearTimeout(timer) + } + } + async function transform(url: string, environmentName = 'client') { const result = await environment(environmentName).transformRequest(url) if (!result) { @@ -534,6 +586,7 @@ export async function createDevServer( } return loaded }, + idle, fetch: async (url) => { const base = vite.resolvedUrls?.local[0] if (!base) { diff --git a/packages/compiler-test-utils/tests/dev-server.test.ts b/packages/compiler-test-utils/tests/dev-server.test.ts index 066e23a5ac7..654d910dcd2 100644 --- a/packages/compiler-test-utils/tests/dev-server.test.ts +++ b/packages/compiler-test-utils/tests/dev-server.test.ts @@ -223,6 +223,47 @@ describe('dev server harness', () => { timeout, ) + // Vite's default `server.preTransformRequests`, as in an app: serving a + // client module starts the transforms of its static imports in the + // background. + test( + 'idle() waits for the background transforms of the imports of a served module', + async () => { + let release!: () => void + const released = new Promise((resolve) => { + release = resolve + }) + const transformed: Array = [] + const server = await createDevServer({ + files, + plugins: [ + { + name: 'slow-leaf', + async transform(_code, id) { + if (id.endsWith('/leaf.ts')) { + await released + transformed.push('leaf.ts') + } + return null + }, + }, + ], + }) + await server.transform('/dep.ts') + const idle = server.idle() + let settled = false + void idle.then(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(settled).toBe(false) + release() + await idle + expect(transformed).toEqual(['leaf.ts']) + }, + timeout, + ) + test( 'registerCleanup receives close(), which stops the server and removes the app', async () => { From 9184990047aede665d5b0f49a3a897c27bb557a5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 08:48:38 +0200 Subject: [PATCH 0340/1425] test(start-plugin-core): pin M1, a handler-only helper logged as a client violation With Vite's default pre-transform requests, the Start compiler's lookup of a server fn factory module pre-transforms a helper the factory uses only inside `.server()`, and dev import protection logs the helper's denied import as a client violation (the 'unknown' reachability fallback). The client never loads the helper. Pin (red for the right reason: `client src/helper.ts:3:34` logged, 10 of 10 runs with `idle()`), plus a passing control with pre-transform requests off. Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/dev.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index 555cd95e5c1..4852c8400c7 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -926,6 +926,84 @@ describe('reachability from the router entry', () => { timeout, ) + /** + * `serverFnFactoryApp` without the leak, whose server fn factory module + * uses a helper `src/helper.ts` (which uses the denied file and the denied + * specifier) only inside `createMiddleware().server()`: the client never + * imports the helper. + */ + const handlerOnlyHelperApp = () => { + const app: Record = { + ...serverFnFactoryApp(), + 'src/helper.ts': `import { getSecret } from './secret.server' +import { getRequest } from '@tanstack/react-start/server' +export const readSecret = () => [getSecret(), getRequest()] +`, + 'src/factory.ts': `import { createMiddleware, createServerFn } from '@tanstack/react-start' +import { readSecret } from './helper' +const mw = createMiddleware({ type: 'function' }).server(({ next }) => { + return next({ context: { secret: readSecret() } }) +}) +export const createSecretFn = createServerFn().middleware([mw]) +`, + 'src/entry.ts': `export { fn } from './usage'\n`, + } + delete app['src/leak.ts'] + return app + } + + // Control for the pin below: without Vite's pre-transform requests, the + // helper is never transformed for the client, and nothing is logged. + // Source: harness realism (r5-gaps M1): the harness used to turn + // pre-transform requests off for every test. + test( + 'with preTransformRequests off, a helper a server fn factory module uses only inside .server() is not logged', + async () => { + const server = await startProtectedDevServer( + handlerOnlyHelperApp(), + {}, + { config: { server: { preTransformRequests: false } } }, + ) + const client = await server.load('/src/entry.ts') + // (what the transforms Vite runs in the background log) + await server.idle() + expect(Object.keys(client)).not.toContain('/src/helper.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([]) + }, + timeout, + ) + + // Bug: with Vite's default pre-transform requests, the Start compiler's + // lookup of the server fn factory module (`?server-fn-module-lookup`, in + // the client environment) goes through Vite's import analysis, which + // pre-transforms `src/helper.ts` for the client. Import protection stores + // the lookup's transform under the factory's file + // (`cacheTransformResult`), so the factory → helper edge stays pending + // until the factory's own client transform lands, and the 'unknown' + // reachability fallback of `processPendingViolations` + // (vite/import-protection-plugin/plugin.ts) logs the helper's denied file + // import conservatively. + // Impact: in dev (Vite's defaults), a module a server fn factory uses only + // inside `.server()` is logged as a client violation, with a trace through + // an import the client never has; the client is correct (it never loads + // the helper). A false warning in the most common server fn layout. + // Source: mutation run R4 final re-audit (M1), hidden while the harness + // turned pre-transform requests off. + test.fails( + 'a helper a server fn factory module uses only inside .server() is not logged', + async () => { + const server = await startProtectedDevServer(handlerOnlyHelperApp()) + const client = await server.load('/src/entry.ts') + // (what the transforms Vite runs in the background log) + await server.idle() + expect(Object.keys(client)).not.toContain('/src/helper.ts') + expect(modulesContaining(client, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([]) + }, + timeout, + ) + // The start entry (`src/start.ts`) is an entry like the router entry: the // client loads it, and so what it imports. // Source: mutation run R4 (spc:4524). From d37d7d19243df61b62fb78832635a33fdf7f5653 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 08:58:18 +0200 Subject: [PATCH 0341/1425] test(start-plugin-core): close the Vite dev import-protection gaps of the final mutation pass N1: an edit of a module, then of its importer, updates without an error. N2: an edit of a CSS file another CSS module @imports updates without an error. N3: a denied import of a module a server fn factory re-exports is logged, with a trace to the use. N6: full-bundle dev logs a violation of a module the bundle input reaches before the router entry does. N7: a factory calling a denied file at the top level is logged at that use only; a module without exports is located on its source line; full-bundle dev mocks a server fn built with a factory of a denied file. N8: dev traces start at the router entry when something imports it, are the shortest chain, keep the importer while it is still transforming, and full-bundle client and SSR traces lead from the router entry. Pins (red for the right reason): - M2: full-bundle dev with mockAccess 'off' fails the client bundle (UNLOADABLE_DEPENDENCY ...mock?server-fn-module-lookup). - M3: a client-only import reached only from a custom src/server.ts is mocked on the server without a warning (control: via the router entry). - M4: the full-bundle client trace under a route module stops at the route. - NEW: the Importer of a module a factory re-exports is `other.ts:2:24` (source map source taken as is, sourceLocation.ts). Kills: spc:1680, 4916, 5428, 4641, 4144, 2154, 4060, 4062, 4072, 4073, 4216, 4218, 4219, 4664, 4669, 4670, 4672, 4674, 4675, 4676, 4697, 4698, 4991, 4992, 4997, 4998, 5511, 5516, 4984, 4990, 4993, 4995, 4996, 4999, 5000, 5001 Co-Authored-By: Claude Opus 5.5 --- .../tests/importProtection/dev.test.ts | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index 4852c8400c7..81207cb906c 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -218,8 +218,93 @@ describe('the trace of a client import of a denied file', () => { }, timeout, ) + + // In every Start app, a module imports the router entry: Start's client + // runtime. The trace still starts at the router entry. + // Source: r5-gaps N8 (spc:4060, 4062: the module graph trace does not stop + // at an entry). + test( + 'starts at the router entry when another module imports the router entry', + async () => { + const server = await startProtectedDevServer({ + ...deniedFileApp, + 'src/main.ts': `export * from './entry'\n`, + }) + await server.load('/src/main.ts') + const reports = violationReports(server.logs) + expect(reports).not.toEqual([]) + for (const report of reports) { + expect(traceModules(report)).toEqual(['src/entry.ts', 'src/a.ts']) + } + }, + timeout, + ) + + // The dev twin of the build test in reported-location.test.ts "the trace is + // the shortest import chain". + // Source: r5-gaps N8 (spc:4072, 4073: the module graph search revisits + // modules). + test( + 'is the shortest import chain', + async () => { + const server = await startProtectedDevServer({ + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + '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`, + }) + await server.load('/src/entry.ts') + const reports = violationReports(server.logs) + expect(reports).not.toEqual([]) + for (const report of reports) { + expect(traceModules(report)).toEqual([ + 'src/entry.ts', + 'src/a.ts', + 'src/s.ts', + ]) + } + }, + timeout, + ) }) +// Edits in the order people make them: a module, then the module importing +// it, each loaded by the client in between. +// Source: r5-gaps N1 (spc:1680: dropping the edges of the first edit left +// the importer's edges pointing at nothing, and the second edit's HMR +// handling threw). +test( + 'an edit of a module, then of its importer, updates both without an error', + async () => { + const server = await startProtectedDevServer({ + 'src/b.ts': `export const b = () => 'b1'\n`, + 'src/a.ts': `import { b } from './b'\nexport const a = () => 'a1' + b()\n`, + 'src/entry.ts': `export * from './a'\n`, + }) + await server.load('/src/entry.ts') + const logged = server.logs.length + const updates = [ + ...(await server.edit({ 'src/b.ts': `export const b = () => 'b2'\n` })), + ] + await server.load('/src/entry.ts') + updates.push( + ...(await server.edit({ + 'src/a.ts': `import { b } from './b'\nexport const a = () => 'a2' + b()\n`, + })), + ) + expect(updates.filter(({ type }) => type === 'error')).toEqual([]) + expect(server.logs.slice(logged)).toEqual([]) + expect((await evaluateServed(server, '/src/entry.ts')).a()).toBe('a2b2') + }, + timeout, +) + // Import protection checks only JavaScript and TypeScript modules; edits of // the app's other modules update as usual. // Source: mutation run R4 (spc:4497: the edit of a module import protection @@ -247,6 +332,30 @@ export const count = () => data.count timeout, ) +// A CSS file that another one `@import`s is in Vite's module graph only as +// a file (a module without an id). +// Source: r5-gaps N2 (spc:4916: such a module reached import protection's +// HMR handling, which threw). +test( + 'an edit of a CSS file another CSS module @imports updates it without an error', + async () => { + const server = await startProtectedDevServer({ + 'src/base.css': `.page { color: red }\n`, + 'src/style.css': `@import './base.css';\n.title { color: green }\n`, + 'src/entry.ts': `import './style.css'\nexport const page = 1\n`, + }) + await server.load('/src/entry.ts') + const logged = server.logs.length + const updates = await server.edit({ + 'src/base.css': `.page { color: blue }\n`, + }) + expect(updates.filter(({ type }) => type === 'error')).toEqual([]) + expect(updates).not.toEqual([]) + expect(server.logs.slice(logged)).toEqual([]) + }, + timeout, +) + describe('log', () => { /** * The violations logged when the client loads the app, and those logged @@ -1047,6 +1156,85 @@ export const startInstance = { leak } timeout, ) + // Source: r5-gaps N7 (spc:4641: the factory module's violations found when + // the Start compiler looked it up, which include the imports used inside + // `.server()`, were logged without waiting for its compiled imports). + test( + 'a server fn factory module calling a denied file at the top level is logged at that use only, not at the imports it uses inside .server()', + async () => { + const tail = `export const secret = getSecret()\n` + const server = await startProtectedDevServer(serverFnFactoryApp(tail)) + await server.load('/src/entry.ts') + await server.idle() + expect(loggedViolations(server.logs).sort()).toEqual([ + `client src/factory.ts:9:${tail.indexOf('getSecret') + 1}`, + 'client src/leak.ts:2:27', + ]) + }, + timeout, + ) + + /** + * `serverFnFactoryApp` without the leak, whose server fn factory module + * re-exports `request` of `src/other.ts`, which calls the denied + * `getRequest()` at the top level. The client loads the app. Returns the + * reports logged and the use in `src/other.ts`. + */ + async function reExportedByAFactory() { + const other = `import { getRequest } from '@tanstack/react-start/server' +${factoryTopLevelCall}` + const app: Record = { + ...serverFnFactoryApp(`export { request } from './other'\n`), + 'src/other.ts': other, + 'src/entry.ts': `export { fn } from './usage'\n`, + } + delete app['src/leak.ts'] + const server = await startProtectedDevServer(app) + const client = await server.load('/src/entry.ts') + await server.idle() + expect(Object.keys(client)).toContain('/src/other.ts') + return { + reports: violationReports(server.logs), + use: locationIn('src/other.ts', other, 2, 'getRequest()'), + } + } + + // Source: r5-gaps N3 (spc:5428: the client's edge from a module the Start + // compiler looked up to the modules it imports). + test( + 'a denied import of a module a server fn factory module re-exports is logged, with a trace to the use', + async () => { + const { reports, use } = await reExportedByAFactory() + expect(reports).toHaveLength(1) + expect(reports[0]!.trace.map(({ at }) => at)).toEqual([ + expect.stringMatching(/^src\/entry\.ts\b/), + expect.stringMatching(/^src\/usage\.ts\b/), + expect.stringMatching(/^src\/factory\.ts\b/), + use, + ]) + }, + timeout, + ) + + // Bug: the location of the use is read from a source map of the module + // whose `sources` are relative to it (`other.ts`), and import protection + // takes the source as it is (import-protection/sourceLocation.ts + // `mapGeneratedToOriginal`, `orig.source`), so `Importer:` names the + // file without its directory. The trace, located from the module's id, + // is right. + // Impact: in dev, the warning for a module a server fn factory module + // re-exports names `other.ts:2:24` instead of `src/other.ts:2:24` (low: + // diagnostics only; an editor link does not open the file). + // Source: found writing the r5-gaps N3 test above. + test.fails( + 'a denied import of a module a server fn factory module re-exports is reported at the use, with its path', + async () => { + const { reports, use } = await reExportedByAFactory() + expect(reports.map(({ importer }) => importer)).toEqual([use]) + }, + timeout, + ) + // Source: mutation run R3 ip-vite V7 (spc:5415). The factory module's // violations wait for its compiled imports (the Start compiler looked it // up), and its denied file import comes before its denied specifier. @@ -1153,6 +1341,13 @@ export const q = () => [read(), request(), slow] `client ${locationIn('src/p.ts', p, 3, 'secret()')}`, `client ${locationIn('src/p.ts', p, 4, 'getRequest()')}`, ]) + // Source: r5-gaps N8 (spc:4216, 4218, 4219: the trace from Vite's + // module graph, which does not have the importer yet, replaced it). + for (const report of violationReports(server.logs)) { + expect( + report.trace.map(({ at }) => at.replace(/:\d+:\d+$/, '')), + ).toEqual(['src/entry.ts', 'src/q.ts', 'src/p.ts']) + } }, timeout, ) @@ -1320,6 +1515,73 @@ export const leak = () => secret() ) }) +describe("an app's server entry, with Start's whole plugin", () => { + const handler = `import { widget } from './widget.client' +export const handler = () => widget() +` + /** + * An app whose `src/handler.ts` uses the client-only file + * `src/widget.client.ts`, imported by the app's server entry + * `src/server.ts` or by its router entry `src/router.ts`. + */ + const handlerApp = (importer: 'src/server.ts' | 'src/router.ts') => ({ + 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, + 'src/handler.ts': handler, + ...(importer === 'src/server.ts' && { + 'src/server.ts': `import { handler } from './handler' +export default { fetch: () => new Response(String(handler())) } +`, + }), + 'src/router.ts': + importer === 'src/router.ts' + ? `import { handler } from './handler'\nexport const getRouter = () => handler\n` + : `export const getRouter = () => undefined\n`, + }) + const handlerUse = `server ${locationIn('src/handler.ts', handler, 2, 'widget()')}` + + const startApp = (files: Record) => + startDevServer({ start: {}, files, config: { server: { hmr: false } } }) + + // Control for the pin below (same harness). + test( + 'a client-only import of a module the router entry imports is mocked on the server and logged', + async () => { + const server = await startApp(handlerApp('src/router.ts')) + const router = await server.import('/src/router.ts') + expect(String(router.getRouter()())).not.toContain('MARK_CLIENT') + expect(loggedViolations(server.logs)).toEqual([handlerUse]) + }, + timeout, + ) + + // Bug: dev import protection only logs violations it can reach from the + // entries it registers, Start's router entry and start entry + // (vite/import-protection-plugin/plugin.ts `registerEntries`). The app's + // server entry is not one, and the entry branch of `resolveId` (no + // importer) never runs in dev, where Vite passes `/index.html` as + // the importer, so the modules only the server entry imports are + // unreachable and their violations are discarded. The server twin of the + // client entry pin above. + // Impact: in dev (mock mode), a client-only import reached from the app's + // custom server entry (`src/server.ts`) is mocked on the server without + // any warning (the request returns the mock's value). + // Source: mutation run R4 final re-audit (M3). + test.fails( + 'a client-only import of a module the server entry imports is mocked on the server and logged', + async () => { + const server = await startApp(handlerApp('src/server.ts')) + // (what Start's dev server runs for a request) + const entry = await server.import('virtual:tanstack-start-server-entry') + const response: Response = await entry.default.fetch( + new Request('http://localhost/'), + ) + expect(await response.text()).not.toContain('MARK_CLIENT') + expect(loggedViolations(server.logs)).toEqual([handlerUse]) + }, + timeout, + ) +}) + // Vite's full-bundle dev (`experimental.bundledDev`), which Start supports: // Rolldown bundles the client, so a module can be transformed before its // importers. @@ -1376,6 +1638,211 @@ export const request = () => getRequest() }, timeout, ) + + /** The modules of the trace of each report logged in `logs`. */ + const traces = (logs: Array) => + violationReports(logs).map((report) => + report.trace.map(({ at }) => at.replace(/:\d+:\d+$/, '')), + ) + + // Source: r5-gaps N8 (spc:4997, 4998: every importer taken as an entry). + test( + 'the trace of a denied file imported through a chain of modules leads from the router entry', + async () => { + const server = await startProtectedDevServer( + { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/lib/a.ts': `import { secret } from '../secret.server' +export const read = () => secret() +`, + 'src/b.ts': `export { read } from './lib/a'\n`, + 'src/entry.ts': `export * from './b'\n`, + }, + {}, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + await server.clientBundle() + const logged = traces(server.logs) + expect(logged).not.toEqual([]) + for (const trace of logged) { + expect(trace).toEqual(['src/entry.ts', 'src/b.ts', 'src/lib/a.ts']) + } + }, + timeout, + ) + + // Bug: in full-bundle dev, import protection takes each client module of + // `src/routes` as an entry, so that a violation under a route the bundle + // reaches before the router entry's imports are known is not discarded + // (vite/import-protection-plugin/plugin.ts `resolveId`, bundled dev + // `env.graph.addEntry(normalizedImporter)`); the trace then stops at the + // first entry it meets. + // Impact: in full-bundle dev, the trace of a violation under a route + // starts at the route module: it does not show how the app reaches the + // route (unbundled dev and builds start at the router entry). Low: + // diagnostics only. + // Source: mutation run R4 final re-audit (M4). + test.fails( + 'the trace of a denied file imported from a module of src/routes leads from the router entry', + async () => { + const server = await startProtectedDevServer( + { + ...deniedFileApp, + 'src/routes/page.ts': `export { read } from '../a'\n`, + 'src/entry.ts': `export * from './routes/page'\n`, + }, + {}, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + await server.clientBundle() + const logged = traces(server.logs) + expect(logged).not.toEqual([]) + for (const trace of logged) { + expect(trace).toEqual(['src/entry.ts', 'src/routes/page.ts', 'src/a.ts']) + } + }, + timeout, + ) + + // Full-bundle dev bundles only the client: the server still loads modules + // one by one, and its traces do not change. + // Source: r5-gaps N8 (spc:4991, 4992: route modules taken as server + // entries). + test( + 'the SSR trace of a .client file imported from a module of src/routes leads from the router entry', + async () => { + const server = await startProtectedDevServer( + { + 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, + 'src/a.ts': `import { widget } from './widget.client' +export const read = () => widget() +`, + 'src/routes/page.ts': `export { read } from '../a'\n`, + 'src/entry.ts': `export * from './routes/page'\n`, + }, + {}, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + await server.load('/src/entry.ts', { environment: 'ssr' }) + const logged = traces(server.logs) + expect(logged).not.toEqual([]) + for (const trace of logged) { + expect(trace).toEqual(['src/entry.ts', 'src/routes/page.ts', 'src/a.ts']) + } + }, + timeout, + ) + + /** + * An app whose server fn factory module is a denied file + * (`src/factory.server.ts`); `src/usage.ts`, which the router entry + * re-exports, defines a server fn with it. + */ + const deniedFactoryApp = { + 'src/factory.server.ts': `import { createMiddleware, createServerFn } from '@tanstack/react-start' +const mw = createMiddleware({ type: 'function' }).server(({ next }) => next()) +export const createSecretFn = createServerFn().middleware([mw]) +export const marker = 'MARK_SECRET' +`, + 'src/usage.ts': `import { createSecretFn } from './factory.server' +export const fn = createSecretFn().handler(async () => 'data') +`, + 'src/entry.ts': `export { fn } from './usage'\n`, + } + const deniedFactoryUse = 'src/usage.ts:2:19' + + // Source: r5-gaps N7 (spc:2154: the mock the Start compiler's lookup of the + // denied factory module gets). + test( + 'a server fn built with a factory of a denied file gets the mock in the client bundle and is logged at the use', + async () => { + const server = await startProtectedDevServer( + deniedFactoryApp, + {}, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + const bundle = await server.clientBundle() + expect(modulesContaining(bundle, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toContain( + `client ${deniedFactoryUse}`, + ) + }, + timeout, + ) + + // Bug: the Start compiler looks up the factory in the denied file's mock + // (`import mock from 'tanstack-start-import-protection:mock'`) with its + // lookup query, and the Vite adapter loads the silent mock only by its + // exact id (vite/import-protection-plugin/virtualModules.ts + // `loadResolvedVirtualModule`): Rolldown cannot load + // `\0tanstack-start-import-protection:mock?server-fn-module-lookup`. With + // the default `mockAccess`, the mock-runtime module hides it (the control + // above). + // Impact: in full-bundle dev with `mockAccess: 'off'`, an app with a server + // fn built from a factory in a denied file fails to bundle the client + // (UNLOADABLE_DEPENDENCY). Low: opt-in option, unusual layout. + // Source: mutation run R4 final re-audit (M2). + test.fails( + 'with mockAccess off, a server fn built with a factory of a denied file gets the mock in the client bundle and is logged at the use', + async () => { + const server = await startProtectedDevServer( + deniedFactoryApp, + { mockAccess: 'off' }, + { bundledDev: { entry: 'src/entry.ts' } }, + ) + const bundle = await server.clientBundle() + expect(modulesContaining(bundle, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toContain( + `client ${deniedFactoryUse}`, + ) + }, + timeout, + ) + + // Start's virtual client entry, the bundle's input, imports the app's + // `src/client.tsx`, which may import modules before the router entry's + // imports reach them. + // Source: r5-gaps N6 (spc:4664-4698: the violations of a module the bundle + // reaches before the router entry does are kept until it does). + test( + 'a denied import of a module the bundle input imports before the router entry reaches it is logged at the use', + async () => { + let release!: () => void + const released = new Promise((resolve) => { + release = resolve + }) + // Holds the router entry until the page module is transformed + const slowEntry: Plugin = { + name: 'slow-entry', + enforce: 'pre', + async transform(_code, id) { + if (id.endsWith('/src/entry.ts')) { + await released + } else if (id.endsWith('/src/page.ts')) { + setTimeout(release, 50) + } + return null + }, + } + const server = await startProtectedDevServer( + { + ...deniedFileApp, + 'src/page.ts': `export { read } from './a'\n`, + 'src/entry.ts': `export { read } from './page'\n`, + 'src/client.ts': `import { read } from './page' +import * as router from './entry' +export const app = [read, router] +`, + }, + {}, + { bundledDev: { entry: 'src/client.ts' }, plugins: [slowEntry] }, + ) + const bundle = await server.clientBundle() + expect(modulesContaining(bundle, 'MARK_SECRET')).toEqual([]) + expect(loggedViolations(server.logs)).toEqual([`client ${deniedFileUse}`]) + }, + timeout, + ) }) // The router plugin splits a route's component into a module of its own, @@ -1451,6 +1918,17 @@ export const Page = () =>
    {String(secret())}
    importProtection: { client: { files: ['**/private/**'] } }, expected: 'client src/a.tsx:2:40', }, + // Source: r5-gaps N7 (spc:4144: a module without exports, imported for + // its side effects, was located in its compiled code). + { + name: 'a use of a denied file in a module without exports (client)', + a: `import { secret } from './private/db' +console.log(
    {String(secret())}
    ) +`, + environment: 'client', + importProtection: { client: { files: ['**/private/**'] } }, + expected: 'client src/a.tsx:2:26', + }, { name: 'a client-only marker (SSR)', a: `import '@tanstack/react-start/client-only' From 2b9d1b918c825a68979e01c6518d36a1c5014da2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 09:04:51 +0200 Subject: [PATCH 0342/1425] test(start-plugin-core): cover build traces with bundledDev, Rsbuild entry traces and Rsbuild dev ids N8: a `vite build` with `experimental.bundledDev` in the Vite config (as apps that enable full-bundle dev keep it) traces a violation under a route module from the entry through it. The Vite build harness takes a `viteConfig` (additive). N13: in Rsbuild dev, server fn ids do not come from `serverFns.generateFunctionId`, which the docs scope to production builds. The Rsbuild compiler harness passes the option through. N10: the trace starts at the entry when a module imports the entry back. Vite passes; Rsbuild is pinned (NEW, low): production builds concatenate the entry module, which has no resourceResolveData, so the adapter registers the first loader's path as the entry and the trace collapses to the importer. spc:7260/7261 only remove that wrong registration and stay equivalent until it is fixed (a scratch fix with the concatenated module's rootModule flips the pin). Kills: spc:4988, 4989, 6812 Co-Authored-By: Claude Opus 5.5 --- .../start-plugin-core/tests/build-harness.ts | 20 ++++- .../reported-location.test.ts | 78 ++++++++++++++++++- .../tests/rsbuild-dev-server.ts | 7 +- .../tests/rsbuild/dev-server.test.ts | 37 +++++++++ 4 files changed, 134 insertions(+), 8 deletions(-) diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index 725000d5b0b..c70e19c56ab 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -51,7 +51,7 @@ import type { import type { TanStackStartInputConfig } from '../src/schema' import type { StartCompilerImportTransform } from '../src/types' import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core' -import type { Plugin } from 'vite' +import type { InlineConfig, Plugin } from 'vite' const startPackage = 'node_modules/@tanstack/react-start' @@ -251,6 +251,11 @@ export interface StartBuildOptions { builds?: number /** Rsbuild configuration merged over the harness's (Rsbuild builds). */ rsbuildConfig?: RsbuildConfig + /** + * Vite configuration merged over the harness's (Vite builds), e.g. an + * app's `experimental.bundledDev`, which stays set for `vite build`. + */ + viteConfig?: InlineConfig /** * Rsbuild builds: builds in watch mode (`rsbuild build --watch`), applies * these app file edits (`null` deletes a file) once the first build is @@ -344,6 +349,7 @@ export async function buildStart( start: options.start ?? {}, compilerTransforms: options.compilerTransforms, rsbuildConfig: options.rsbuildConfig, + viteConfig: options.viteConfig, watchEdits: options.watchEdits, } const warnings = { client: [], ssr: [] } as Record> @@ -391,6 +397,7 @@ interface BuildTarget { start: TanStackStartInputConfig compilerTransforms: Array | undefined rsbuildConfig: RsbuildConfig | undefined + viteConfig?: InlineConfig watchEdits?: Record } @@ -411,7 +418,7 @@ async function buildWithVite( target: BuildTarget, warnings: Record>, ): Promise { - const { createBuilder } = await import('vite') + const { createBuilder, mergeConfig } = await import('vite') const outputs: Partial = {} const errors: Array = [] /** Records what each environment emitted. */ @@ -445,7 +452,9 @@ async function buildWithVite( warnings.ssr.push(stripAnsi(message)) } try { - const builder = await createBuilder({ + const builder = await createBuilder( + mergeConfig( + { root: target.root, configFile: false, envDir: false, @@ -479,7 +488,10 @@ async function buildWithVite( ), outputPlugin, ], - }) + }, + target.viteConfig ?? {}, + ), + ) await builder.buildApp() } catch (error) { throw new BuildError( 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 019877abdd9..e746bea40f4 100644 --- a/packages/start-plugin-core/tests/importProtection/reported-location.test.ts +++ b/packages/start-plugin-core/tests/importProtection/reported-location.test.ts @@ -20,7 +20,7 @@ import { startProtectedDevServer, } from './start-apps' import { violationReports } from './violation-report' -import type { StartBundler } from '../build-harness' +import type { StartBuildOptions, StartBundler } from '../build-harness' import type { ReportedTraceStep, ViolationReport } from './violation-report' const timeout = 60_000 @@ -205,10 +205,13 @@ describe('the reported location of a violation is the leaking use', () => { async function buildReport( bundler: StartBundler, files: Record, - { sourcemap = false } = {}, + { + sourcemap = false, + viteConfig, + }: Pick = {}, ): Promise { const error = await rejection( - buildStart({ bundler, files, entry: 'src/entry.ts', sourcemap }), + buildStart({ bundler, files, entry: 'src/entry.ts', sourcemap, viteConfig }), ) const [report, ...others] = violationReports(error.message) expect(others).toEqual([]) @@ -352,6 +355,75 @@ export const page = () => leak() }, ) +// An app that sets Vite's full-bundle dev (`experimental.bundledDev`) keeps +// it set for `vite build` (as the e2e apps do with E2E_VITE_BUNDLED_DEV): +// the build's trace does not change. +// Source: r5-gaps N8 (spc:4988, 4989: route modules taken as entries in the +// build, as full-bundle dev does). +test( + 'vite build with experimental.bundledDev: the trace of a violation under a module of src/routes leads from the entry through it', + async () => { + const { trace } = await buildReport( + 'vite', + { + ...leakingB, + 'src/routes/page.ts': `export { leak } from '../b'\n`, + 'src/entry.ts': `export * from './routes/page'\n`, + }, + { viteConfig: { experimental: { bundledDev: true } } }, + ) + expect(modulesOf(trace).slice(-3)).toEqual([ + 'src/entry.ts', + 'src/routes/page.ts', + 'src/b.ts', + ]) + }, + timeout, +) + +// Bug (Rsbuild): the trace stops at an entry only when it finds no importer +// of the module: in a production build the entry module is a concatenated +// module (scope hoisting), which has no `resourceResolveData`, so the +// Rsbuild adapter registers it by its identifier, the loader request, read +// as the path of the first loader (rsbuild/import-protection.ts +// `addEntryModulesToGraph` → `getModuleResource`), and no module of the +// graph is a registered entry. When a module imports the entry back, the +// trace search finds no root, and the trace is the importer alone, tagged +// as the entry. (A scratch registration of the concatenated module's +// `rootModule` passes this pin.) +// Impact: an Rsbuild build reports such a violation with a one-step trace +// (low: diagnostics only, and an import of the app's entry is rare). Vite is +// right. +// Source: found writing the r5-gaps N10 test (spc:7260, 7261 remove that +// registration, which changes nothing while it registers the wrong path). +describe.each(startBundlers)('%s build', (bundler) => { + const entryBackTest = bundler === 'rsbuild' ? test.fails : test + entryBackTest( + 'the trace starts at the entry when a module imports the entry back', + async () => { + const { importer, trace } = await buildReport(bundler, { + ...leakingB, + 'src/back.ts': `import * as app from '../harness/client-entry'\nexport const back = app\n`, + 'src/a.ts': `import { leak } from './b' +import { back } from './back' +export const page = () => [leak(), back] +`, + 'src/entry.ts': `export * from './a'\n`, + }) + expect(trace.map(({ entry }) => entry)).toEqual( + trace.map((_, index) => index === 0), + ) + expect(modulesOf(trace).slice(-3)).toEqual([ + 'src/entry.ts', + 'src/a.ts', + 'src/b.ts', + ]) + expect(trace.at(-1)!.at).toBe(importer) + }, + timeout, +) +}) + // The trace locates each import on its line in the user's file, whatever // compiling the module moves. const movedImports: Record< diff --git a/packages/start-plugin-core/tests/rsbuild-dev-server.ts b/packages/start-plugin-core/tests/rsbuild-dev-server.ts index 9387fa62a1b..7c175c24104 100644 --- a/packages/start-plugin-core/tests/rsbuild-dev-server.ts +++ b/packages/start-plugin-core/tests/rsbuild-dev-server.ts @@ -19,6 +19,7 @@ import { tanStackStartRsbuild } from '../src/rsbuild/plugin' import { startRuntimeFiles } from './build-harness' import { testLoaderFiles, useTestLoaders } from './rsbuild-test-loaders' import type { ImportProtectionOptions } from '../src/schema' +import type { GenerateFunctionIdFnOptional } from '../src/start-compiler/types' import type { RsbuildDevServer, RsbuildDevServerOptions, @@ -34,9 +35,12 @@ const harnessDir = path.dirname(fileURLToPath(import.meta.url)) export function startRsbuildDevServer( options: Omit & { importProtection?: ImportProtectionOptions + /** Start's `serverFns.generateFunctionId` option. */ + generateFunctionId?: GenerateFunctionIdFnOptional }, ): Promise { - const { files, importProtection, ...devServerOptions } = options + const { files, importProtection, generateFunctionId, ...devServerOptions } = + options return createRsbuildDevServer({ dir: harnessDir, prefix: '.start-rsbuild-dev-server-', @@ -57,6 +61,7 @@ export function startRsbuildDevServer( root, environments: [{ name: 'client', type: 'client' }], providerEnvName: 'ssr', + generateFunctionId, }) if (importProtection) { registerImportProtection(api, { 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 b761e6d106c..880102a4771 100644 --- a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts @@ -141,4 +141,41 @@ import { getData as b } from './b' }, timeout, ) + + // `serverFns.generateFunctionId` customizes the ids of the production + // build (guide/server-functions.md, "Function ID generation for production + // build"); the dev server keeps its own. + // Source: r5-gaps N13 (spc:6812: the Rsbuild dev server compiling in build + // mode). + test( + 'the server fn ids do not come from generateFunctionId, which applies to production builds', + async () => { + const generated: Array = [] + const server = await startRsbuildDevServer({ + generateFunctionId: ({ functionName }) => { + generated.push(functionName) + return `custom-${functionName}` + }, + files: { + 'src/a.ts': `import { createServerFn } from '@tanstack/react-start' +export const getData = createServerFn().handler(async () => 'x') +`, + 'src/entry.ts': `import { getData } from './a' +;(globalThis as Record).ids = [getData.functionId] +`, + }, + }) + const context = vm.createContext({}) + context.self = context + for (const code of Object.values(await server.outputs())) { + vm.runInContext(code, context) + } + expect(context.ids).toEqual([expect.any(String)]) + expect(context.ids).not.toContainEqual( + expect.stringMatching(/^custom-/), + ) + expect(generated).toEqual([]) + }, + timeout, + ) }) From 67a5c6346b23c6f990bc7c4f83ec37cc34cefdd4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 09:10:23 +0200 Subject: [PATCH 0343/1425] test(router-generator): close the piece and pathless-layout gaps, pin G1-G3 N4: a route with a `.lazy` file next to a route with a component piece both get their lazy parts (the lazy route the router loads is the file's Route). N5: a pathless layout with only a Vue piece in a route group has no path option (router-core would match it as a page). N11: a routeToken regex matching names that contain a word makes such a file a layout. Helpers (route-tree.ts, additive): `routePathsOf` and `loadLazyRoute` (runs a route's `lazy()` with the dynamic imports stubbed). Pins (red for the right reason): - G1: a pathless layout with only a lazy file or a Vue piece under a path drops the path from its children's URLs (`/bar` instead of `/foo/bar`; the types say `/foo/bar`). Not PIN-A: different shape and cause (the virtual node is typed `static`). Control: a route file. - G2: `posts/route.component.vue` becomes a route `/posts/route`. - G3: a `.lazy.vue` file is wired as a lazy route that cannot load (the SFC has no lazy route options); an empty `.lazy.vue` is scaffolded with TypeScript route code. Kills: rg:414, 455 (also flips G1), 2723 Not killed: rg:2725, 2729 (need a file name with a single bracket). Co-Authored-By: Claude Opus 5.5 --- .../tests/generator-options.test.ts | 29 ++++ .../tests/route-paths.test.ts | 95 +++++++++++- .../tests/route-pieces.test.ts | 146 +++++++++++++++++- packages/router-generator/tests/route-tree.ts | 73 ++++++++- 4 files changed, 334 insertions(+), 9 deletions(-) diff --git a/packages/router-generator/tests/generator-options.test.ts b/packages/router-generator/tests/generator-options.test.ts index 54c8fad8d63..bad160f1760 100644 --- a/packages/router-generator/tests/generator-options.test.ts +++ b/packages/router-generator/tests/generator-options.test.ts @@ -788,6 +788,35 @@ export const Route = createFileRoute('/posts/')({}) }) }) +describe('custom tokens matching longer names', () => { + // A file name is escaped only when brackets wrap it (`[layout]`); a + // routeToken regex that matches whole names containing a word applies to + // every such name. + // Source: r5-gaps N11 (rg:2723: every name read as bracket-wrapped). + test('a routeToken regex matching names that contain "layout" makes such a file the layout route of its directory', async () => { + const root = await createApp({ + 'src/routes/__root.tsx': rootRoute(), + 'src/routes/posts/mylayouts.tsx': `import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts')({}) +`, + 'src/routes/posts/index.tsx': `import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/')({}) +`, + }) + const { generatedRouteTree } = await generate(root, { + routeToken: /.*layout.*/, + }) + + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/posts': 'routes/posts/mylayouts.tsx', + '/posts/': 'routes/posts/index.tsx', + }) + }) +}) + describe('route tree path', () => { test('with disableTypes, only the extension of the route tree file becomes .js', () => { const config = getConfig( diff --git a/packages/router-generator/tests/route-paths.test.ts b/packages/router-generator/tests/route-paths.test.ts index eecc1e7f5f4..94509ab5614 100644 --- a/packages/router-generator/tests/route-paths.test.ts +++ b/packages/router-generator/tests/route-paths.test.ts @@ -16,7 +16,7 @@ import { } from '@tanstack/virtual-file-routes' import { parseSync } from 'vite' import { createApp, fileRoute, generate, lazyFileRoute, rootRoute } from './app' -import { routesOf, urlsOf } from './route-tree' +import { routePathsOf, routesOf, urlsOf } from './route-tree' import type { Config } from '../src' /** An app with a root route and a route file for each of `files`. */ @@ -220,6 +220,99 @@ describe('layouts and groups', () => { }) }) + // A layout with a piece file only (no route file) still adds no path + // segment, and is no page of its own. + // Source: r5-gaps N5 (rg:455: such a layout got `path: '/'`, which makes + // the router match the layout itself at `/`). + test('a pathless layout with only a Vue component piece in a route group has no path', async () => { + const root = await createApp({ + 'src/routes/__root.ts': rootRoute('vue'), + 'src/routes/(group)/_layout.component.vue': `\n`, + 'src/routes/(group)/_layout/inner.ts': fileRoute( + '/(group)/_layout/inner', + '{}', + 'vue', + ), + }) + const { generatedRouteTree } = await generate(root, { target: 'vue' }) + + expect(await urlsOf(generatedRouteTree)).toEqual({ + __root__: '/', + '/(group)/_layout': '/', + '/(group)/_layout/inner': '/inner', + }) + expect((await routePathsOf(generatedRouteTree))['/(group)/_layout']).toBe( + undefined, + ) + }) + + // Bug: a pathless layout with a lazy route file only (`foo/_auth.lazy.tsx`, + // no `foo/_auth.tsx`) or a Vue piece only (`foo/_layout.component.vue`) + // becomes a route without a file whose node is typed `static`, and the + // route tree prints a `path` only for non-pathless nodes and for nodes + // typed `pathless_layout` (src/generator.ts, the `path:` option of + // `createUpdateRoutes`), so the layout loses the `/foo` prefix it carries + // when nothing above it is a route. The route tree's types still say + // `/foo/bar`. A layout with a route file (`foo/_auth.tsx`) is right. + // Impact: the child route matches `/bar` instead of `/foo/bar`, and + // links typed `/foo/bar` lead nowhere. Unlike the pending PIN-A ruling + // (a pathless layout with a route file under a lazy-only route: which + // route is its parent), here the URL disagrees with the generated types. + // Source: mutation run R4 final re-audit (G1). + test.fails.each<{ + name: string + target: 'react' | 'vue' + files: Record + child: string + }>([ + { + name: 'a lazy route file', + target: 'react', + files: { + 'src/routes/__root.tsx': rootRoute(), + 'src/routes/foo/_auth.lazy.tsx': lazyFileRoute('/foo/_auth'), + 'src/routes/foo/_auth/bar.tsx': fileRoute('/foo/_auth/bar'), + }, + child: '/foo/_auth/bar', + }, + { + name: 'a Vue component piece', + target: 'vue', + files: { + 'src/routes/__root.ts': rootRoute('vue'), + 'src/routes/foo/_layout.component.vue': `\n`, + 'src/routes/foo/_layout/bar.ts': fileRoute( + '/foo/_layout/bar', + '{}', + 'vue', + ), + }, + child: '/foo/_layout/bar', + }, + ])( + 'a pathless layout with only $name under a path keeps the path in the URLs of its children', + async ({ target, files, child }) => { + const root = await createApp(files) + const { generatedRouteTree } = await generate(root, { target }) + + expect((await urlsOf(generatedRouteTree))[child]).toBe('/foo/bar') + }, + ) + + // Control for the pin above (same harness). + test('a pathless layout with a route file under a path keeps the path in the URLs of its children', async () => { + const root = await createApp({ + 'src/routes/__root.tsx': rootRoute(), + 'src/routes/foo/_auth.tsx': fileRoute('/foo/_auth'), + 'src/routes/foo/_auth/bar.tsx': fileRoute('/foo/_auth/bar'), + }) + const { generatedRouteTree } = await generate(root) + + expect((await urlsOf(generatedRouteTree))['/foo/_auth/bar']).toBe( + '/foo/bar', + ) + }) + test('two sections with grandchildren give a valid route tree without formatting', async () => { const root = await appWithRoutes([ 'a.tsx', diff --git a/packages/router-generator/tests/route-pieces.test.ts b/packages/router-generator/tests/route-pieces.test.ts index 0489a55cfa7..206c8ace025 100644 --- a/packages/router-generator/tests/route-pieces.test.ts +++ b/packages/router-generator/tests/route-pieces.test.ts @@ -4,10 +4,21 @@ * become that option of the route, loaded lazily from the file's default * export. The generator never writes to them. */ -import { describe, expect, test } from 'vitest' -import { createApp, fileRoute, generate, read, rootRoute } from './app' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { + createApp, + fileRoute, + generate, + lazyFileRoute, + read, + rootRoute, +} from './app' import { routeIdOf } from './route-id' -import { routeOptionsOf, routesOf } from './route-tree' +import { loadLazyRoute, routeOptionsOf, routesOf, urlsOf } from './route-tree' + +afterEach(() => { + vi.restoreAllMocks() +}) /** A Vue single-file component rendering `text`. */ const sfc = (text: string) => ``, + ...app('import.meta.hot'), + }, + }) + await expect(server.settle()).rejects.toThrow(/watcher/) + const page = await openPage(server.url!, { + registerCleanup: onTestFinished, + }) + await page.waitForText('text v1 other v1') + expect(page.loads()).toBe(1) + + await server.write({ 'text.js': `export const text = 'text v2'\n` }) + await page.waitForText('text v2 other v1') + expect(page.loads()).toBe(1) + + await server.write({ 'other.js': `export const other = 'other v2'\n` }) + await page.waitForText('text v2 other v2') + await expect.poll(() => page.loads()).toBe(2) + }, + timeout, +) + +test( + 'rsbuild: the page shows an accepted update in place, and reloads for one nothing accepts', + async () => { + const server = await createRsbuildDevServer({ + dir: testsDir, + registerCleanup: onTestFinished, + browser: true, + entry: 'main.js', + config: { html: { mountId: 'app' } }, + files: app('import.meta.webpackHot'), + }) + const page = await openPage(server.url!, { + registerCleanup: onTestFinished, + }) + await page.waitForText('text v1 other v1') + expect(page.loads()).toBe(1) + + await server.edit({ 'text.js': `export const text = 'text v2'\n` }) + await page.waitForText('text v2 other v1') + expect(page.loads()).toBe(1) + + await server.edit({ 'other.js': `export const other = 'other v2'\n` }) + await page.waitForText('text v2 other v2') + await expect.poll(() => page.loads()).toBe(2) + }, + timeout, +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c38703b4432..2b7bdcc562e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13926,6 +13926,9 @@ importers: packages/compiler-test-utils: dependencies: + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@rsbuild/core': specifier: 'catalog:' version: 2.2.5 From 3ad15c48608c0a314fb93642c646dfff99999858 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 13:51:47 +0200 Subject: [PATCH 0404/1425] test(router-plugin): route HMR tests drive real dev servers and a real page; delete the React Refresh model User ruling 2026-10-18 ("absolutely insane"): no copies of Vite / webpack / Rspack / React HMR or update-propagation logic in tests. Deleted: - tests/react-refresh-model.ts (port of @pmmmwh/react-refresh-webpack-plugin's executeRuntime, React's resolveFunctionForHotReloading, plugin-react's boundary check) and the propagation / refresh parts of `hotUpdate` in regression-helpers.ts (boundaries, invalidation, refresh families, render/remounted). `hotUpdate` keeps only our route HMR code running against the bundler's hot API contract (Vite accept callback gets the new module; webpack re-runs the module with its dispose data), as before w2-hmr. - the react-refresh devDependency (lockfile regenerated). - the w2-hmr pin "vite: applies route options a factory call builds" (ruling 2026-10-18: reloading is fine); the edit now must show in the real page. - model-only tests: route-hmr hot updates of split / root / unsplit routes and their React Refresh assertions, regression-helpers "with React Refresh" controls and "reloads the page exactly when a Vite dev server does", react-refresh-route-components "route HMR code next to user bindings", the two editRouteFile tests of route-hmr (covered by the page steps below). New real harness (tests/route-hmr-page.ts, on compiler-test-utils `browser`): a Vite dev server with the router plugin and @vitejs/plugin-react / vite-plugin-solid / @vitejs/plugin-vue-jsx, or an Rsbuild dev server with the router plugin's Rspack entry and @rsbuild/plugin-react (new devDependency, ^2.0.0 as in the e2e apps), serves a file-based app with the real router package to headless Chromium; edits go through the dev server's own watcher; the test reads the DOM, clicks counters (state), and gets `reloaded` (page loads, plus Vite full-reload messages). Ported (tests/route-hmr-browser.test.ts, Vite and Rsbuild, autoCodeSplitting on and off, several edits per server, concurrent): - edited component body + loader + `Route` read: new text, new loader data, `useState` count kept, no reload; - a hook added: remount (count 0), no reload; - errorComponent added / edited / removed, pendingComponent and notFoundComponent edited: new text, no reload (removal: text only, see pin); - arrow component, two split components, quoted keys, loader in its own chunk, loader in the component chunk (Vite; Rsbuild pinned), a value loader and component share, user bindings named hotData / handleRouteUpdate: state kept, new text, no reload; - root route: body + loader in place with state, shellComponent added / edited (state kept) / removed, no reload; factory-built options: the edit shows (reload allowed); - Solid / Vue in Vite: the edit shows; Solid unsplit without a reload. regression-helpers.test.ts controls (Vite and Rsbuild): a body edit keeps state, a renamed child component loses it, an edit of a module no boundary accepts reloads the page. Scratch control (not committed): with the route HMR code removed (codeSplittingOptions.addHmr: false for split routes; the route HMR plugin patched out of router-composed-plugin.ts for unsplit routes, reverted), a loader + component edit reloads the page and loses state on both bundlers. Pins (known-bugs-route-hmr.test.ts "route HMR in a real page", each verified red for the right reason by one run without .fails): - moved: D10 Solid / Vue split (red: reloaded true); without React Refresh, now Rsbuild with pluginReact({ fastRefresh: false }) (red: page stays v1). - NEW: renaming the route component keeps the old one (Vite, Rsbuild x split, unsplit; red: page stays "Page v1"). - NEW: Rsbuild: an edit of a split loader never shows (red: "loader v1"). - NEW: Vite: removing a split option the page loaded logs "require is not defined" / "Failed to reload ...?tsr-split=errorComponent" (red: those two page errors). Kept on the slim hotUpdate (our code only): regression-helpers hotUpdate controls, route-hmr "the route a hot update applies to", Solid / Vue route HMR code (now also unsplit and the new component), webpack factory options, known-bugs "route updates", code-splitter-plugin-pipeline (dropped `refresh: true`), code-splitter-route-options root staticData. Co-Authored-By: Claude Opus 5.5 --- packages/router-plugin/AGENTS.md | 1 + packages/router-plugin/package.json | 2 +- .../code-splitter-plugin-pipeline.test.ts | 1 - .../tests/known-bugs-route-hmr.test.ts | 248 ++++++++- .../tests/react-refresh-model.ts | 234 -------- .../react-refresh-route-components.test.ts | 41 -- .../tests/regression-helpers.test.ts | 222 +++----- .../router-plugin/tests/regression-helpers.ts | 481 +++------------- .../tests/route-hmr-browser.test.ts | 500 +++++++++++++++++ .../router-plugin/tests/route-hmr-page.ts | 313 +++++++++++ .../router-plugin/tests/route-hmr.test.ts | 526 ++---------------- pnpm-lock.yaml | 6 +- 12 files changed, 1215 insertions(+), 1360 deletions(-) delete mode 100644 packages/router-plugin/tests/react-refresh-model.ts create mode 100644 packages/router-plugin/tests/route-hmr-browser.test.ts create mode 100644 packages/router-plugin/tests/route-hmr-page.ts diff --git a/packages/router-plugin/AGENTS.md b/packages/router-plugin/AGENTS.md index 355f9cef9c6..6a68037a1ae 100644 --- a/packages/router-plugin/AGENTS.md +++ b/packages/router-plugin/AGENTS.md @@ -6,3 +6,4 @@ - Route HMR rebuilds indexes, clears `resolvePathCache`, replaces lazy-chunk ownership, and refreshes active routes while preserving component identities (React, Solid and Vue). Keep these steps coordinated when adding derived state. - Test code-splitting changes by behaviour: compile the route and load the emitted modules with `loadRoute` from `tests/regression-helpers.ts` (built on the module linker from `@tanstack/compiler-test-utils`), and assert what users see (rendered output, loader results, init counts). Outcomes the bundler decides (dead code, chunk contents, `sideEffects` / `@__PURE__` hints) are asserted in a real build with `build` from `tests/build-harness.ts`, per bundler. Do not add output snapshots. - Run behavioural tests for React, Solid and Vue through the `targetFramework` option of the test helpers. +- Test what the page shows after a route file edit with `openRouteHmrPage` (`tests/route-hmr-page.ts`): a real dev server with the framework's plugin, a real browser page and the real HMR client. Never copy bundler, HMR-client or React Refresh logic into tests; `hotUpdate` only runs our route HMR code against the bundler's hot API. diff --git a/packages/router-plugin/package.json b/packages/router-plugin/package.json index 9ee55673235..bc0801e6adb 100644 --- a/packages/router-plugin/package.json +++ b/packages/router-plugin/package.json @@ -129,6 +129,7 @@ }, "devDependencies": { "@rsbuild/core": "catalog:", + "@rsbuild/plugin-react": "^2.0.0", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/node": ">=20", @@ -140,7 +141,6 @@ "jsdom": "catalog:", "react": "catalog:", "react-dom": "catalog:", - "react-refresh": "^0.18.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@arethetypeswrong/cli": "catalog:", 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 ab44824c96b..bdcae03f31d 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -723,7 +723,6 @@ export const Route = (() => hotUpdate(route('old'), { style, codeSplitting, - refresh: true, routeId: '/made', updated: route('new'), }), 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 0395f0d1a18..fba5c9bc305 100644 --- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts @@ -4,12 +4,15 @@ * * Route HMR keeps the previous `component` (and the other component options) * so that React Refresh can patch it in place; a component React Refresh does - * not register keeps rendering the old code until a full reload. + * not register keeps rendering the old code until a full reload. Pins of what + * the page shows run a real dev server and a real browser page + * (`route-hmr-page.ts`). */ import react from '@vitejs/plugin-react' import vueJsx from '@vitejs/plugin-vue-jsx' import solid from 'vite-plugin-solid' import { describe, expect, test } from 'vitest' +import type { TestContext } from 'vitest' import { build, routeApp } from './build-harness' import { compileRouteModules, @@ -23,6 +26,7 @@ import { routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' +import { openRouteHmrPage } from './route-hmr-page' import { editRouteFile } from './vite-hot-update' import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' import type { PluginOption } from 'vite' @@ -607,39 +611,227 @@ export { MyRoute as Route }`, ).toBe(true) }, ) +}) - // Bug: `handleRouteUpdate` keeps the previous component options for React - // Refresh to patch in place, also when no React Refresh runtime is installed - // (webpack or Rspack without a refresh plugin, like the webpack quickstart - // example with the default `addHmr`), so the new component never renders. - // With automatic code splitting, the kept component is the old chunk's. - // Impact: webpack reports the route (and its split chunk) as hot-updated, - // but the page shows the old component until a reload; without the route - // HMR code the same edit reloads the page. - // The harness gives no React Refresh runtime and configures the plugins - // with a webpack compiler without a refresh plugin, so a fix may detect - // React Refresh at runtime or from the bundler configuration. - test.fails.each([false, true])( - 'webpack: without React Refresh, a hot update renders the new component (autoCodeSplitting: %s)', - async (codeSplitting) => { - const route = ( - text: string, - ) => `${head}export const Route = createFileRoute('/')({ - loader: () => 'data', - component: () =>

    ${text}

    , -})` - const update = await hotUpdate(route('old'), { - style: 'webpack', - codeSplitting, - refresh: false, - updated: route('new'), +// Route HMR in a real dev server and a real browser page +// (`route-hmr-page.ts`, as route-hmr-browser.test.ts). A pin waits for the +// new code at most `pinTimeout`, well under the test timeout. +// Concurrent: each pin mostly waits (`pinTimeout`) for an edit that never +// shows. +describe.concurrent('route HMR in a real page', { timeout: 60_000 }, () => { + const pinTimeout = 3_000 + + /** A React route at `/` whose component, `name`, keeps a click count. */ + const counterRoute = ( + version: string, + name = 'Page', + options = '', + ) => `import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +function ${name}() { + const [count, setCount] = useState(0) + return +} +function Failed() { + return

    failed ${version}

    +} +export const Route = createFileRoute('/')({ + component: ${name}, + ${options} +}) +` + + // Bug: route HMR keeps the previous split components only for React: for + // Solid and Vue, the route module creates a new lazy component on every + // update and `handleRouteUpdate` keeps no component option + // (`getFrameworkHmrCompilerPlugins` returns no plugin and the kept + // component keys are empty for other targets); in Vite, the route module + // is not an HMR boundary either (its accept call is on an alias), so the + // page reloads. + // Impact: every edit of a Solid or Vue route file with automatic code + // splitting reloads the page in Vite dev, losing its state, instead of + // patching it in place. + // Goal (any fix): in a Vite dev server with the framework's plugin, the + // edit shows without a full reload, and the live route keeps the component + // the page rendered. + // Source: decision D10 (Solid/Vue split components keep their identity + // across hot updates, as React's do); gaps H8/H9. + test.fails.for(['solid', 'vue'] as const)( + 'vite: an edit of a split %s route shows in place, keeping the split component', + async ( + target: 'solid' | 'vue', + { expect, onTestFinished }: TestContext, + ) => { + const route = (version: string) => + `import { createFileRoute } from '@tanstack/${target}-router' +export const Route = createFileRoute('/')({ + loader: () => 'data ${version}', + component: Page, +}) +function Page() { + const data = Route.useLoaderData() + return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    +} +` + const app = await openRouteHmrPage({ + target, + onTestFinished, + files: { 'routes/index.tsx': route('v1') }, + ready: 'page v1 data v1', }) + const liveComponent = `window.__TSR_ROUTER__.routesById['/'].options.component` + await app.evaluate(`window.shownComponent = ${liveComponent}`) + expect( + await app.edit({ 'routes/index.tsx': route('v2') }, 'page v2 data v2', { + timeout: pinTimeout, + }), + ).toEqual({ reloaded: false }) expect( - update.invalidated || - (await update.render('component')) === '

    new

    ', + await app.evaluate(`window.shownComponent === ${liveComponent}`), ).toBe(true) }, ) + + // Bug: `handleRouteUpdate` keeps the previous component options so that + // React Refresh patches them in place, also when the edit replaced the + // component with another one (renamed, so another component family for + // React Refresh), which React Refresh never patches. With automatic code + // splitting, the kept lazy component keeps the component its chunk loaded + // first. The same happens when an edit replaces inline route options of a + // split route with options a factory call builds. + // Impact: after renaming a route's component, the page keeps showing the + // old component until a reload (Vite and Rsbuild). + // Source: router-plugin src/core/hmr/handle-route-update.ts (component + // keys kept whenever both versions have them); NEW (hmr-real, found by the + // real-page harness; the former model treated it as expected). + // Any fix passes: a hot update rendering the new component (React mounts + // it anew), or a reload. + describe.each(['vite', 'rsbuild'] as const)('%s', (bundler) => { + test.fails.for([true, false])( + 'renaming the route component shows the renamed component (autoCodeSplitting: %s)', + async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': counterRoute('v1') }, + ready: 'Page v1 count 0', + }) + await app.edit( + { 'routes/index.tsx': counterRoute('v2', 'Renamed') }, + 'Renamed v2 count', + { timeout: pinTimeout }, + ) + }, + ) + }) + + // Bug: on Rsbuild (Rspack), an edit that only changes a loader split from + // the route module (`codeSplitGroupings` with `loader` in a chunk) never + // reaches the page: the route module's code is unchanged, so Rspack does + // not run it again and the route HMR code (`webpack-adapter.ts`, in the + // route module only) never refreshes the route; the split loader module + // accepts no update of its own. With Rsbuild's default lazy compilation + // the loaded chunk is not even updated; without it (`dev.lazyCompilation: + // false`), the next load gets the new loader, but nothing reloads the + // route. Vite updates the route in place (route-hmr-browser.test.ts). + // Impact: the page keeps the old loader's data until a reload or a + // navigation, with no sign that the edit was not applied. + // Source: router-plugin src/core/hmr/webpack-adapter.ts (HMR code only in + // the route module); NEW (hmr-real). + // Any fix passes: a hot update showing the new loader's data, or a reload. + test.fails.for([`[['loader'], ['component']]`, `[['component', 'loader']]`])( + 'rsbuild: an edit of a split loader (codeSplitGroupings: %s) shows its data', + async (groupings: string, { onTestFinished }: TestContext) => { + const route = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +function Page() { + return

    {Route.useLoaderData()}

    +} +export const Route = createFileRoute('/')({ + loader: () => 'loader ${version}', + component: Page, + codeSplitGroupings: ${groupings}, +}) +` + const app = await openRouteHmrPage({ + bundler: 'rsbuild', + onTestFinished, + files: { 'routes/index.tsx': route('v1') }, + ready: 'loader v1', + }) + await app.edit({ 'routes/index.tsx': route('v2') }, 'loader v2', { + timeout: pinTimeout, + }) + }, + ) + + // Bug: an edit that removes a split component option (here + // `errorComponent`, which the page had loaded) leaves its split module in + // Vite's module graph; Vite updates it with the rest of the route file, the + // code splitter serves it without any export or import, and + // `@vitejs/plugin-react` compiles that module's JSX as CommonJS + // (`require("react/jsx-dev-runtime")`). + // Impact: the page logs "[vite] ReferenceError: require is not defined" + // and "[vite] Failed to reload /routes/index.tsx?tsr-split=errorComponent"; + // some runs reload the page. + // Source: router-plugin src/core/code-splitter (the virtual split module + // of an option the route file no longer has); NEW (hmr-real). + // Goal (any fix): the edit applies in place, and the page logs no error. + test.fails( + 'vite: an edit that removes a split option the page loaded applies without errors', + async ({ expect, onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + onTestFinished, + files: { + 'routes/index.tsx': counterRoute( + 'v1', + 'Page', + `errorComponent: Failed, + loader: () => { + throw new Error('loader failed') + },`, + ), + }, + ready: 'failed v1', + }) + const errors = app.page.errors.length + expect( + await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { + timeout: pinTimeout, + }), + ).toEqual({ reloaded: false }) + expect(app.page.errors.slice(errors)).toEqual([]) + }, + ) + + // Bug: `handleRouteUpdate` keeps the previous component options for React + // Refresh to patch in place, also when no React Refresh runtime is + // installed (an Rsbuild React app with `pluginReact({ fastRefresh: false + // })`, or webpack / Rspack without a refresh plugin, like the webpack + // quickstart example with the default `addHmr`), so the new component + // never renders. With automatic code splitting, the kept component is the + // old chunk's. + // Impact: the bundler reports the route as hot-updated, but the page shows + // the old component until a reload; without the route HMR code the same + // edit reloads the page. + // Any fix passes: a hot update rendering the new component, or a reload. + test.fails.for([true, false])( + 'rsbuild without React Refresh: an edit of the route component shows (autoCodeSplitting: %s)', + async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + bundler: 'rsbuild', + onTestFinished, + reactRefresh: false, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': counterRoute('v1') }, + ready: 'Page v1 count 0', + }) + await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { + timeout: pinTimeout, + }) + }, + ) }) describe('Vite HMR boundaries', () => { diff --git a/packages/router-plugin/tests/react-refresh-model.ts b/packages/router-plugin/tests/react-refresh-model.ts deleted file mode 100644 index 604bc4f92d3..00000000000 --- a/packages/router-plugin/tests/react-refresh-model.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * React Refresh as an app runs it during a hot update, for the route HMR - * harness (`hotUpdate` in `regression-helpers.ts`): the real refresh runtime - * (`react-refresh/runtime`, which `@vitejs/plugin-react` copies and the - * webpack / Rspack refresh plugins import), the code the bundler plugins add - * around each module (registration under the module's id, Fast Refresh - * boundaries), and how React renders a component type once the runtime - * refreshed it. - */ -import { createRequire } from 'node:module' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import { - jsxPragma, - moduleLang, -} from '@tanstack/compiler-test-utils/module-linker' -import { transformWithOxc } from 'vite' - -const nodeRequire = createRequire(import.meta.url) - -/** - * Compiles a module the way the bundler's React Refresh setup does: JSX - * (to the module linker's factories), TypeScript erased like a project - * with `verbatimModuleSyntax`, and React Refresh's registrations and hook - * signatures (`$RefreshReg$`, `$RefreshSig$`). Vite (`@vitejs/plugin-react`) - * only adds them to modules with JSX (a `.jsx` / `.tsx` file, or one that - * imports React's JSX runtime); the webpack and Rspack plugins to every - * module their loader compiles. - */ -export async function transformWithRefresh( - code: string, - id: string, - bundler: 'vite' | 'webpack', -) { - const refresh = - bundler === 'webpack' || - id.split('?')[0]!.endsWith('x') || - /\breact\/jsx-(?:dev-)?runtime\b/.test(code) - const result = await transformWithOxc(code, 'module', { - lang: moduleLang(id), - jsx: { runtime: 'classic', ...jsxPragma, refresh: refresh ? {} : false }, - typescript: { onlyRemoveTypeImports: true }, - }) - return result.code -} - -/** A component family: every version of one registered component. */ -export type RefreshFamily = { current: unknown } - -/** What `performReactRefresh()` returns: the families an update touched. */ -export type RefreshUpdate = { - /** Families React re-renders in place (same hook signature). */ - updatedFamilies: Set - /** Families React remounts (hook signature changed). */ - staleFamilies: Set -} - -/** The part of `react-refresh/runtime` the bundler plugins call. */ -export type RefreshRuntime = { - register: (type: unknown, id: string) => void - createSignatureFunctionForTransform: () => (...args: Array) => unknown - performReactRefresh: () => RefreshUpdate | null - getFamilyByType: (type: unknown) => RefreshFamily | undefined - isLikelyComponentType: (type: unknown) => boolean -} - -/** - * A fresh instance of `react-refresh/runtime`: its families are module - * state, and every app (every `hotUpdate`) starts with none. - */ -export function createRefreshRuntime(): RefreshRuntime { - const entry = nodeRequire.resolve('react-refresh/runtime') - const directory = path.dirname(entry) - for (const id of Object.keys(nodeRequire.cache)) { - if (id.startsWith(directory + path.sep)) { - delete nodeRequire.cache[id] - } - } - return nodeRequire(entry) as RefreshRuntime -} - -const memoType = Symbol.for('react.memo') -const forwardRefType = Symbol.for('react.forward_ref') - -/** The function a component type renders with: inside `memo` / `forwardRef`. */ -export function componentFunction(type: unknown): unknown { - let current: any = type - while (current && typeof current === 'object') { - if (current.$$typeof === memoType) { - current = current.type - } else if (current.$$typeof === forwardRefType) { - current = current.render - } else { - break - } - } - return current -} - -/** - * The function React calls to render a component type: the latest version - * of its family once the runtime refreshed it (React's - * `resolveFunctionForHotReloading`), else the function itself. - */ -export function resolveForRender( - runtime: RefreshRuntime | undefined, - type: unknown, -): unknown { - const fn = componentFunction(type) - const current = runtime?.getFamilyByType(fn)?.current - return current === undefined || current === fn - ? fn - : resolveForRender(runtime, current) -} - -/** - * JSX factories (`__jsx`, `__Fragment`, see the module linker's `jsxPragma`) - * that render to text like the linker's default ones, but call each - * component the way React does after a refresh (`resolveForRender`). - */ -export function createRefreshingJsx(runtime: RefreshRuntime | undefined) { - const Fragment = Symbol('Fragment') - const h = (type: unknown, props: unknown, ...children: Array) => { - const text = children - .flat(Infinity) - .filter((child) => child != null && child !== false && child !== true) - .join('') - if (type === Fragment) { - return text - } - if (typeof type === 'string') { - return `<${type}>${text}` - } - const render = resolveForRender(runtime, type) - if (typeof render !== 'function') { - throw new Error(`cannot render ${String(type)}`) - } - return render({ ...(props as object), children: text }, null) - } - return { __jsx: h, __Fragment: Fragment } -} - -/** - * `@vitejs/plugin-react`'s check of a hot update of a module it compiled - * with React Refresh (its refresh runtime's - * `validateRefreshBoundaryAndEnqueueUpdate`): a message when the module's - * exports cannot be refreshed in place (the module then invalidates itself: - * a full reload), else `undefined`. Exports listed by - * `window.__getReactRefreshIgnoredExports` are not checked. Its own refresh - * is a no-op here: the harness's runtime performs the refresh. - */ -export async function vitePluginReactBoundaryCheck(): Promise< - (id: string, previous: object, next: object) => string | undefined -> { - vitePluginReactRuntime ??= import( - /* @vite-ignore */ pathToFileURL( - path.join( - path.dirname(nodeRequire.resolve('@vitejs/plugin-react')), - 'refresh-runtime.js', - ), - ).href - ) - const runtime = await vitePluginReactRuntime - return runtime.validateRefreshBoundaryAndEnqueueUpdate -} -let vitePluginReactRuntime: Promise | undefined - -/** What the webpack / Rspack refresh runtime keeps of a module's hot API. */ -export type WebpackHot = { - data?: Record - accept: (callback?: unknown) => void - dispose: (callback: (data: Record) => void) => void - invalidate: () => void -} - -/** - * The code the webpack and Rspack React Refresh plugins run after each - * module they compile (`executeRuntime` of `@rspack/plugin-react-refresh`'s - * `refreshUtils.js`, from `@pmmmwh/react-refresh-webpack-plugin`): registers - * the module's component exports; a module that exports only components is - * a Fast Refresh boundary (it accepts its own updates and enqueues a - * refresh), unless its exports changed shape, which invalidates it. - */ -export function webpackExecuteRuntime( - runtime: RefreshRuntime, - moduleExports: Record, - moduleId: string, - hot: WebpackHot, - enqueueUpdate: () => void, -) { - for (const key in moduleExports) { - if (runtime.isLikelyComponentType(moduleExports[key])) { - runtime.register(moduleExports[key], `${moduleId} %exports% ${key}`) - } - } - const isBoundary = (candidate: Record | undefined) => { - if (!candidate) { - return false - } - const keys = Object.keys(candidate) - return ( - keys.length > 0 && - keys.every((key) => runtime.isLikelyComponentType(candidate[key])) - ) - } - const signature = (candidate: Record) => - Object.keys(candidate).flatMap((key) => [ - key, - runtime.getFamilyByType(candidate[key]), - ]) - const isHotUpdate = Boolean(hot.data) - const previous = hot.data?.prevExports as Record | undefined - if (isBoundary(moduleExports)) { - hot.dispose((data) => { - data.prevExports = moduleExports - }) - hot.accept() - if (isHotUpdate) { - const before = previous && isBoundary(previous) ? signature(previous) : [] - const after = signature(moduleExports) - const changed = - before.length > 0 && - (before.length !== after.length || - after.some((item, index) => item !== before[index])) - if (changed) { - hot.invalidate() - } else { - enqueueUpdate() - } - } - } else if (isHotUpdate && isBoundary(previous)) { - hot.invalidate() - } -} 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 f8baf2aa63c..b6476c1ffcf 100644 --- a/packages/router-plugin/tests/react-refresh-route-components.test.ts +++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts @@ -26,7 +26,6 @@ import { exportedNames, getRouteOption, head, - hotUpdate, isRefreshable, loadRoute, reactRefresh, @@ -292,46 +291,6 @@ export const Route = createFileRoute('/')({ component: ${component} })`, }) }) -// The route HMR code declares bindings of its own (`hotData`, -// `handleRouteUpdate`, ...); a route file's bindings with the same names keep -// their values, and the route still hot-updates. (A user binding named `hot` -// collides: pinned in known-bugs-route-hmr.test.ts.) -describe('route HMR code next to user bindings with common names', () => { - /** A route file with its own `hotData` and `handleRouteUpdate`. */ - const route = (version: string) => `${head}const hotData = 'data ${version}' -function handleRouteUpdate() { - return 'update ${version}' -} -function Page() { - return

    {hotData} {handleRouteUpdate()}

    -} -export const Route = createFileRoute('/')({ - loader: () => hotData, - component: Page, -})` - - describe.each([ - { compiler: 'automatic code splitting', codeSplitting: true }, - { compiler: 'the route HMR plugin', codeSplitting: false }, - ])('with $compiler', ({ codeSplitting }) => { - it.each(['vite', 'webpack'] as const)( - '%s: keeps the values of hotData and handleRouteUpdate', - async (style) => { - const update = await hotUpdate(route('old'), { - style, - codeSplitting, - updated: route('new'), - }) - expect(update.invalidated).toBe(false) - expect(await (await update.load('loader'))()).toBe('data new') - expect(await update.render('component')).toBe( - '

    data new update new

    ', - ) - }, - ) - }) -}) - describe('React Refresh hoisting of inline route components', () => { it('hoists the root route component but not the components of routes a function creates', async () => { const { reference } = compileWithCodeSplitting(` diff --git a/packages/router-plugin/tests/regression-helpers.test.ts b/packages/router-plugin/tests/regression-helpers.test.ts index 46695a4285e..2b53435ab98 100644 --- a/packages/router-plugin/tests/regression-helpers.test.ts +++ b/packages/router-plugin/tests/regression-helpers.test.ts @@ -1,9 +1,9 @@ /** * Controls for the regression harness itself: module discovery, lazy * evaluation and output validation (module linking and module validation: - * `@tanstack/compiler-test-utils`). + * `@tanstack/compiler-test-utils`), and the route HMR page + * (`route-hmr-page.ts`). */ -import react from '@vitejs/plugin-react' import { describe, expect, it } from 'vitest' import { routerDevServer } from './dev-server' import { @@ -21,7 +21,7 @@ import { routeModuleSources, routeSpecifier, } from './regression-helpers' -import { editRouteFile } from './vite-hot-update' +import { openRouteHmrPage } from './route-hmr-page' describe('compileRouteModules', () => { const route = `${head}export const Route = createFileRoute('/')({ @@ -333,7 +333,7 @@ export const Route = createFileRoute('/')({ }) expect((update.live.loader as () => string)()).toBe('new') expect(update.isLazy('component')).toBe(true) - expect(await update.render('component')).toBe('

    page

    ') + expect((await update.load('component'))()).toBe('

    page

    ') }, ) @@ -361,159 +361,79 @@ export const Route = createFileRoute('/')({ loader: () => '${loader}' })` }) expect((update.live.loader as () => string)()).toBe('new') }) +}) - it('refresh: modules are compiled with React Refresh and webpack enqueues a refresh', async () => { - const update = await hotUpdate(route('old'), { style: 'webpack' }) - expect([...update.refresh.registered.values()]).toContain('Page') - expect(update.refresh.refreshes).toBeGreaterThan(0) - const without = await hotUpdate(route('old'), { - style: 'webpack', - refresh: false, - }) - expect(without.refresh).toEqual({ registered: new Map(), refreshes: 0 }) - }) - - // Controls of the React Refresh model: the page shows the new code of an - // edited component in place, as React does with the real refresh runtime. - describe('with React Refresh', () => { - /** A route whose component (`name`) has `body`, split or not. */ - const page = ( - body: string, - name = 'Page', - ) => `${head}import { useState } from 'react' -function ${name}() { - ${body} -} -export const Route = createFileRoute('/')({ - loader: () => 'data', - component: ${name}, -})` - const modes = [ - { style: 'vite', codeSplitting: true }, - { style: 'webpack', codeSplitting: true }, - { style: 'vite', codeSplitting: false }, - { style: 'webpack', codeSplitting: false }, - ] as const - - it.each(modes)( - '$style (codeSplitting: $codeSplitting): an edited component renders its new body, without a remount', - async (mode) => { - const update = await hotUpdate(page('return

    v1

    '), { - ...mode, - updated: page('return

    v2

    '), - }) - expect(update.invalidated).toBe(false) - expect(update.live.component).toBe(update.previous.component) - expect(await update.render('component')).toBe('

    v2

    ') - expect(update.remounted('component')).toBe(false) - }, - ) - - // React keeps a component's state only while its hooks stay the same. - it.each(modes)( - '$style (codeSplitting: $codeSplitting): a component the edit adds a hook to remounts', - async (mode) => { - const update = await hotUpdate(page('return

    v1

    '), { - ...mode, - updated: page(`const [n] = useState(2)\n return

    {n}

    `), - }) - expect(update.invalidated).toBe(false) - expect(update.remounted('component')).toBe(true) - }, - ) - - // Negative control: React Refresh matches the versions of a component - // by the name it registers it under. Renamed, the edited component is - // another component, and the page keeps rendering the previous one. - it.each([true, false])( - 'vite (codeSplitting: %s): a renamed component is not a new version of the previous one', - async (codeSplitting) => { - const update = await hotUpdate(page('return

    v1

    '), { - codeSplitting, - updated: page('return

    v2

    ', 'Renamed'), - }) - expect(update.invalidated).toBe(false) - expect(await update.render('component')).toBe('

    v1

    ') - }, - ) - - // Without React Refresh, nothing patches a kept component. - it('webpack without React Refresh: a kept component renders its first body', async () => { - const update = await hotUpdate(page('return

    v1

    '), { - style: 'webpack', - refresh: false, - updated: page('return

    v2

    '), - }) - expect(update.live.component).toBe(update.previous.component) - expect(await update.render('component')).toBe('

    v1

    ') - }) - }) - - // Control: the Vite model agrees with a Vite dev server running - // `@vitejs/plugin-react` on whether an edit is a hot update. Vite only takes - // a module that accepts its own updates in its served code as a boundary; - // an edit that reaches no boundary reloads the page. - describe.each([ - { - name: 'a split route', - codeSplitting: true, - file: 'index.tsx', - route: (text: string) => `${head}function Page() { - return

    ${text}

    +// Controls of the route HMR page (`openRouteHmrPage`): it tells kept state +// from lost state, and a hot update from a full reload. A React app whose +// route component renders a counter, and whose entry renders a header +// imported from a module that no HMR boundary accepts. +describe.each(['vite', 'rsbuild'] as const)( + 'openRouteHmrPage (%s)', + (bundler) => { + const route = ( + version: string, + child = 'Counter', + ) => `import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +function ${child}() { + const [count, setCount] = useState(0) + return } -export const Route = createFileRoute('/')({ loader: () => '${text}', component: Page })`, - }, - { - name: 'an unsplit route', - codeSplitting: false, - file: 'index.tsx', - route: (text: string) => `${head}function Page() { - return

    ${text}

    +function Page() { + return
    <${child} />
    } -export const Route = createFileRoute('/')({ loader: () => '${text}', component: Page })`, - }, - { - name: 'an unsplit route without components (.ts)', - codeSplitting: false, - file: 'index.ts', - route: (text: string) => - `${head}export const Route = createFileRoute('/')({ loader: () => '${text}' })`, - }, - { - name: 'an unsplit route whose options a factory call builds', - codeSplitting: false, - file: 'index.tsx', - route: (text: string) => `${head}import { makeOptions } from './-options' -export const Route = createFileRoute('/')(makeOptions('${text}'))`, - }, - ])('$name', ({ codeSplitting, file, route: source }) => { - it('vite: reloads the page exactly when a Vite dev server does', async () => { - const update = await hotUpdate(source('page'), { - codeSplitting, - filename: file, - updated: source('edited'), - stubs: { - './-options': { - makeOptions: (data: string) => ({ loader: () => data }), - }, - }, - }) - const served = await editRouteFile({ - target: 'react', - autoCodeSplitting: codeSplitting, - plugins: [react()], - file: `routes/${file}`, - route: source, - loaded: codeSplitting ? ['?tsr-split=component'] : [], +export const Route = createFileRoute('/')({ component: Page }) +` + const main = `import { createRoot } from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { header } from './header' +const router = createRouter({ routeTree }) +createRoot(document.getElementById('app')!).render( + <> +

    {header}

    + + , +) +` + + it('sees kept state, lost state and full reloads', async () => { + const app = await openRouteHmrPage({ + bundler, files: { - 'routes/-options.ts': `export const makeOptions = (data: string) => ({ loader: () => data })`, + 'main.tsx': main, + 'header.ts': `export const header = 'header v1'`, + 'routes/index.tsx': route('v1'), }, + ready: 'Counter v1 count 0', }) - expect(served.messages.length).toBeGreaterThan(0) - expect(update.invalidated).toBe(!served.hotUpdate) - }, 30_000) - }) -}) + await app.click('button') + await app.page.waitForText('Counter v1 count 1') + // An edit of the component's body: React Refresh keeps its state. + expect( + await app.edit( + { 'routes/index.tsx': route('v2') }, + 'Counter v2 count 1', + ), + ).toEqual({ reloaded: false }) + // A component the edit renames is another component for React Refresh: + // React mounts it anew. + expect( + await app.edit( + { 'routes/index.tsx': route('v3', 'Renamed') }, + 'Renamed v3 count 0', + ), + ).toEqual({ reloaded: false }) + // An edit of a module that no HMR boundary accepts reloads the page. + expect( + await app.edit( + { 'header.ts': `export const header = 'header v2'` }, + 'header v2', + ), + ).toEqual({ reloaded: true }) + }, 60_000) + }, +) describe('routerDevServer', () => { it('serves a route of another target with the extra plugins after the router plugin', async () => { diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index 55455a423f1..3b325327255 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -33,16 +33,6 @@ import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin' import { createRouterPluginContext } from '../src/core/router-plugin-context' import { normalizePath } from '../src/core/utils' import { build, routeApp } from './build-harness' -import { - componentFunction, - createRefreshRuntime, - createRefreshingJsx, - resolveForRender, - transformWithRefresh, - vitePluginReactBoundaryCheck, - webpackExecuteRuntime, -} from './react-refresh-model' -import type { RefreshUpdate, WebpackHot } from './react-refresh-model' import type { Config, DeletableNodes } from '../src/core/config' import type { CodeSplitGroupings } from '../src/core/constants' import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' @@ -1249,11 +1239,7 @@ export type HotUpdateOptions = { * modules linked like `loadRoute`) instead of the route HMR plugin alone. */ codeSplitting?: boolean - /** - * Default `react`. Solid and Vue routes only run with `style: 'webpack'`: - * whether Vite hot-updates them depends on their framework's refresh - * plugin, which only a real dev server runs (`editRouteFile`). - */ + /** Default `react`. */ targetFramework?: TargetFramework /** * Route id the route file is registered under. Default `/`. A root route @@ -1269,62 +1255,34 @@ export type HotUpdateOptions = { * (`routeId`), e.g. after a route configuration change. Default `routeId`. */ previousRouteId?: string - /** - * The app runs React Refresh (default: for React routes), as a React app - * does: `@vitejs/plugin-react` with Vite, a React Refresh plugin with - * webpack or Rspack (the plugins are configured with - * `webpackCompiler(refresh)`). Modules are compiled with the React Refresh - * transform and run with the refresh runtime the bundler plugin adds - * (`react-refresh-model.ts`). `false`: a React app without React Refresh. - */ - refresh?: boolean /** The route file's other imports, by specifier. */ stubs?: Stubs - /** - * File name of the route file. Default `index.tsx` for the route HMR - * plugin, `route.tsx` with automatic code splitting. - */ - filename?: string } /** A version of the route file as the bundler compiled it. */ type CompiledRouteFile = Pick /** - * Compiles a route file for the dev server, then runs a hot update the way - * the bundler and React Refresh do: + * Runs the route HMR code the router plugin emits, on its own: compiles a + * route file for the dev server, loads the route module (one hot context per + * module, its `data` kept across the update), mounts its `Route` in a route + * tree as the generated route tree does and creates a router with it + * (`window.__TSR_ROUTER__`), then loads the modules of the edited file + * (`updated`) and gives each module that accepted its own updates its new + * version, per the bundler's hot API (Vite: its accept callbacks receive the + * new module; webpack: the module runs again with the data its dispose + * handlers wrote). * - * - loads the route module with the bundler's hot API on `import.meta` - * (one hot context per module), mounts its `Route` in a route tree as the - * generated route tree does, creates a router with it - * (`window.__TSR_ROUTER__`) and loads the route's `component` if it is - * lazy, as the page showing the route does; - * - compiles the edited file (`updated`) and applies the update to the - * modules of the file the page loaded. Vite: every module whose served - * code accepts its own updates (a literal `import.meta.hot.accept(`, or - * the accept `@vitejs/plugin-react` adds to a module that registers - * components) is imported again, with its hot data, and its accept - * callbacks get the new module; a module no such boundary covers makes - * the page reload. webpack / Rspack: every module that accepted itself - * when it ran runs again, with the data its dispose handlers wrote. - * Modules of the file loaded for the first time after the update are the - * edited ones, as a browser fetches them; - * - with React Refresh, each module registers its components under its id, - * the bundler's refresh code checks the Fast Refresh boundaries - * (`@vitejs/plugin-react`'s, or the webpack / Rspack plugins'), and the - * runtime refreshes the registered families once the update applied. + * Which modules a bundler updates, and what the page shows after an update, + * are not modelled here: tests of those run a real dev server and a real + * page (`route-hmr-page.ts`). * * Returns the options of the live route before (`previous`) and after * (`live`) the update, a copy of the options each module load created a - * route with (`created`), whether the update reloads the page instead of - * applying in place (`invalidated`: a module of the file that no boundary - * covers, or a module that invalidates itself, e.g. after a failed Fast - * Refresh check, without importers that are boundaries), the `window` the - * modules ran with, what React Refresh registered and how many refreshes - * were enqueued (`refresh`), `load(option)`, which resolves a lazy option of the live - * route to what its module provides, `render(option)`, which renders a - * component option of the live route as React does after the update, and - * `remounted(option)`, whether React remounts it (losing its state). + * route with (`created`), whether a module asked the bundler for a full + * reload (`invalidated`), the `window` the modules ran with, and + * `load(option)`, which resolves a lazy option of the live route to what its + * module provides. */ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { const { @@ -1332,21 +1290,15 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { codeSplitting = false, targetFramework = 'react', routeId = '/', - refresh = targetFramework === 'react', } = options - if (style === 'vite' && targetFramework !== 'react') { - throw new Error( - 'hotUpdate runs Vite updates of React routes only: test Solid and Vue routes in Vite with editRouteFile and their framework plugin', - ) - } - if (refresh && targetFramework !== 'react') { - throw new Error('React Refresh only runs for React routes') - } const pluginOptions: Partial = { target: targetFramework, plugin: { hmr: { style } }, } - const compiler = style === 'webpack' ? webpackCompiler(refresh) : undefined + const compiler = + style === 'webpack' + ? webpackCompiler(targetFramework === 'react') + : undefined const compile = ( source: string, compiledRouteId = routeId, @@ -1358,19 +1310,13 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { routeId: compiledRouteId, pluginOptions, webpackCompiler: compiler, - filename: options.filename, }) } - const file = normalizePath( - path.join(process.cwd(), 'src/routes', options.filename ?? 'index.tsx'), - ) + const file = routeFile('index') const compiled = transformWithRouteHmrPlugin( source, pluginOptions, - { - file, - routeId: compiledRouteId, - }, + { file, routeId: compiledRouteId }, compiler, ) return { @@ -1385,75 +1331,39 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { ? first : compile(options.updated ?? code) - const runtime = refresh ? createRefreshRuntime() : undefined - const jsx = createRefreshingJsx(runtime) - /** Renders a component the way React does after the refresh. */ - const renderComponent = (component: unknown, props: object) => { - const render = resolveForRender(runtime, component) - if (typeof render !== 'function') { - throw new Error(`cannot render ${String(component)}`) - } - return render(props, null) - } - const created: Array> = [] - /** Loader of each lazy value the modules created. */ const lazy = new Map Promise>() - /** The component each lazy route component loaded, once it did. */ - const lazyComponents = new Map unknown>() - const registered = new Map() let invalidated = false - /** Modules that asked the bundler to update their importers instead. */ - const invalidations = new Set() - let refreshes = 0 - let refreshUpdate: RefreshUpdate | null = null - const enqueueRefresh = () => { - refreshes++ - } - type HotState = { - /** Vite: kept across the update. webpack: what the dispose handlers wrote. */ - data: Record | undefined + data: Record accepted: Array<(module: unknown) => void> - selfAccepted: boolean disposers: Array<(data: Record) => void> } const hotStates = new Map() const hotState = (id: string) => { let state = hotStates.get(id) if (!state) { - state = { - // webpack gives a module no data until it is hot-updated - data: style === 'webpack' ? undefined : {}, - accepted: [], - selfAccepted: false, - disposers: [], - } + state = { data: {}, accepted: [], disposers: [] } hotStates.set(id, state) } return state } - /** The hot API of each module evaluation, by version and module id. */ - const hotContexts: Array> = [new Map(), new Map()] - const hotContext = (version: number, id: string) => { + const hotContext = (id: string) => { const state = hotState(id) - const context = { + return { data: state.data, - accept: (callback?: unknown) => { - state.selfAccepted = true - if (typeof callback === 'function') { - state.accepted.push(callback as (module: unknown) => void) + accept: (callback?: (module: unknown) => void) => { + if (callback) { + state.accepted.push(callback) } }, dispose: (callback: (data: Record) => void) => { state.disposers.push(callback) }, invalidate: () => { - invalidations.add(id) + invalidated = true }, } - hotContexts[version]!.set(id, context) - return context } const recording = @@ -1478,117 +1388,28 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { return route } const routerPackage = `@tanstack/${targetFramework}-router` - /** Ids of the modules that registered components, by version. */ - const registering = [new Set(), new Set()] - const graphs: Array> = [] - - /** - * Imports a lazy option's module. A module of the route file that the - * page never loaded before the update is loaded as the bundler serves it - * now: edited. - */ - const importLazy = async ( - version: number, - importer: () => Promise>, - ) => { - const latest = graphs.length - 1 - if (version < latest) { - const graph = graphs[version]! - const ids = graph - .probeDynamicImports(importer) - .filter((id) => second.modules.has(id)) - if (ids.length === 1 && !graph.evaluated.includes(ids[0]!)) { - return graphs[latest]!.import(ids[0]!) - } - } - return importer() + // The route classes of the framework packages add hooks to the router-core + // ones; route HMR only uses what router-core provides. + const routerStub: Record = { + ...createRouterStub(lazy), + createFileRoute: () => recording(createRoute), + createLazyFileRoute: () => recording(createRoute), + createRootRoute: recording(createRootRoute), + createRootRouteWithContext: () => recording(createRootRoute), + ...stubExports(options.stubs?.[routerPackage]), } - - const load = (compiled: CompiledRouteFile) => { - const version = graphs.length - // The route classes of the framework packages add hooks to the - // router-core ones; route HMR only uses what router-core provides. - const routerStub: Record = { - ...createRouterStub(lazy), - createFileRoute: () => recording(createRoute), - createLazyFileRoute: () => recording(createRoute), - createRootRoute: recording(createRootRoute), - createRootRouteWithContext: () => recording(createRootRoute), - lazyRouteComponent: ( - importer: () => Promise>, - exportName = 'default', - ) => { - let component: unknown - const loadComponent = async () => { - if (component === undefined) { - component = (await importLazy(version, importer))[exportName] - } - return component - } - const Lazy = Object.assign( - (props: object) => { - if (component === undefined) { - throw new Error( - 'a lazy route component rendered before it loaded', - ) - } - return renderComponent(component, props) - }, - { preload: loadComponent }, - ) - lazy.set(Lazy, loadComponent) - lazyComponents.set(Lazy, () => component) - return Lazy - }, - ...stubExports(options.stubs?.[routerPackage]), - } - const graph = createModuleGraph({ + const load = (compiled: CompiledRouteFile) => + createModuleGraph({ modules: Object.fromEntries(compiled.modules), resolve: compiled.resolve, - stubs: { - ...(targetFramework === 'react' && { react: React }), - ...options.stubs, - [routerPackage]: routerStub, - }, + stubs: { ...options.stubs, [routerPackage]: routerStub }, importMeta: (id) => ({ - [style === 'webpack' ? 'webpackHot' : 'hot']: hotContext(version, id), - }), - ...(runtime && { - transform: (source: string, id: string) => - transformWithRefresh(source, id, style), - // JSX renders through `createRefreshingJsx`, declared in `scope` - runtime: '', - scope: (id: string) => ({ - // What `@vitejs/plugin-react` and the webpack / Rspack refresh - // plugins declare in each module: registrations under its id. - $RefreshReg$: (type: unknown, name: string) => { - registered.set(type, name) - registering[version]!.add(id) - runtime.register(type, `${id} ${name}`) - }, - $RefreshSig$: runtime.createSignatureFunctionForTransform, - ...jsx, - }), + [style === 'webpack' ? 'webpackHot' : 'hot']: hotContext(id), }), }) - graphs.push(graph) - return graph - } - - /** Ids a module of a compiled route file imports, among its modules. */ - const importsOf = (compiled: CompiledRouteFile, id: string) => - importRequests(compiled.modules.get(id)!, id) - .map(({ specifier }) => compiled.resolve(specifier, id)) - .filter((target) => target !== undefined && compiled.modules.has(target)) const window: { __TSR_ROUTER__?: RouterCore } = {} vi.stubGlobal('window', window) - if (runtime && style === 'webpack') { - // What the webpack and Rspack refresh plugins provide to modules. - vi.stubGlobal('__react_refresh_utils__', { - enqueueUpdate: () => enqueueRefresh(), - }) - } try { const firstGraph = load(first) const firstModule = await firstGraph.import(first.id) @@ -1623,175 +1444,38 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { options: Record } ).options - // The page shows the route: its component is loaded. - const shown = liveOptions().component - if (lazy.has(shown)) { - await lazy.get(shown)!() - } const previous = { ...liveOptions() } - /** The component each lazy option of the page had loaded. */ - const previousComponents = new Map( - Object.entries(previous) - .filter(([, value]) => lazyComponents.has(value)) - .map(([option, value]) => [option, lazyComponents.get(value)!()]), - ) - // The modules of the route file the page loaded, in evaluation order. - const loaded = firstGraph.evaluated.filter((id) => first.modules.has(id)) - const exportsBefore = new Map>() - for (const id of loaded) { - exportsBefore.set(id, await firstGraph.import(id)) - } - const viteBoundaryCheck = - runtime && style === 'vite' - ? await vitePluginReactBoundaryCheck() - : undefined - /** What the bundler's React Refresh code does once a module ran. */ - const afterModuleRuns = ( - version: number, - id: string, - moduleExports: Record, - ) => { - if (!runtime) { - return - } - if (style === 'webpack') { - webpackExecuteRuntime( - runtime, - moduleExports, - id, - hotContexts[version]!.get(id)!, - enqueueRefresh, - ) - } else if (registering[version]!.has(id)) { - // `@vitejs/plugin-react`'s `registerExportsForReactRefresh` - for (const [key, value] of Object.entries(moduleExports)) { - if (runtime.isLikelyComponentType(value)) { - runtime.register(value, `${id} export ${key}`) - } - } + if (style === 'webpack') { + for (const state of hotStates.values()) { + const next: Record = {} + state.disposers.forEach((dispose) => dispose(next)) + state.data = next + state.disposers = [] } } - for (const id of loaded) { - afterModuleRuns(0, id, exportsBefore.get(id)!) - } - - /** Whether the bundler applies an update of the module to the module itself. */ - const isBoundary = (id: string) => - style === 'webpack' - ? hotState(id).selfAccepted - : registering[0]!.has(id) || - /\bimport\.meta\.hot\.accept\(/.test(first.modules.get(id)!) - /** - * Whether an update of the module reaches a boundary on every path to - * the page: itself, or importers that are (the route module's importer, - * the route tree, is not). - */ - const reachesBoundary = (id: string, chain: Array): boolean => { - if (isBoundary(id)) { - return true - } - const importers = loaded.filter( - (importer) => - importer !== id && importsOf(first, importer).includes(id), - ) - return ( - importers.length > 0 && - importers.every( - (importer) => - chain.includes(importer) || - reachesBoundary(importer, [...chain, id]), - ) - ) + const accepted = new Map( + [...hotStates].map(([id, state]) => [id, state.accepted]), + ) + for (const state of hotStates.values()) { + state.accepted = [] } - if (!loaded.every((id) => reachesBoundary(id, []))) { - invalidated = true - } else { - const boundaries = loaded - .filter(isBoundary) - .sort((a, b) => Number(b === first.id) - Number(a === first.id)) - const accepted = new Map( - boundaries.map((id) => [id, hotState(id).accepted]), - ) - for (const id of boundaries) { - const state = hotState(id) - if (style === 'webpack') { - const next: Record = {} - state.disposers.forEach((dispose) => dispose(next)) - state.data = next - } else { - state.disposers.forEach((dispose) => dispose(state.data!)) - } - state.disposers = [] - state.accepted = [] - state.selfAccepted = false - } - const secondGraph = load(second) - const exportsAfter = new Map>() - for (const id of boundaries) { - if (second.modules.has(id)) { - exportsAfter.set(id, await secondGraph.import(id)) - } - } - for (const id of secondGraph.evaluated) { - if (second.modules.has(id)) { - afterModuleRuns(1, id, await secondGraph.import(id)) - } - } - // Vite: the accept callbacks, each with its module's new version - if (style === 'vite') { - for (const id of boundaries) { - const next = exportsAfter.get(id) - if (!next) { - continue - } - accepted.get(id)!.forEach((accept) => accept(next)) - if (viteBoundaryCheck && registering[0]!.has(id)) { - const message = viteBoundaryCheck(id, exportsBefore.get(id)!, next) - if (message) { - // `import.meta.hot.invalidate(message)` - invalidations.add(id) - } else { - enqueueRefresh() - } - } - } - } - // A module that invalidates itself has its importers updated instead - // (Vite re-imports them, webpack runs them again): the page reloads - // unless they are boundaries, which apply the update (and enqueue a - // refresh) as above. - for (const id of invalidations) { - const importers = loaded.filter( - (importer) => - importer !== id && importsOf(first, importer).includes(id), - ) - if ( - importers.length === 0 || - !importers.every((importer) => reachesBoundary(importer, [id])) - ) { - invalidated = true - } - } - if (runtime && refreshes > 0 && !invalidated) { - refreshUpdate = runtime.performReactRefresh() + const secondGraph = load(second) + await secondGraph.import(second.id) + // The route module's update first, then the other modules that accept + // their own updates, each with its new module. + const updates = [...accepted].sort( + ([a], [b]) => Number(b === first.id) - Number(a === first.id), + ) + for (const [id, callbacks] of updates) { + if (callbacks.length === 0 || !second.modules.has(id)) { + continue } + const updatedModule = await secondGraph.import(id) + callbacks.forEach((accept) => accept(updatedModule)) } const live = liveOptions() - const familyOf = (type: unknown) => - runtime?.getFamilyByType(componentFunction(type)) - /** Whether React renders `after` where it rendered `before` as a new component. */ - const replaced = (before: unknown, after: unknown) => - before !== after && - (familyOf(before) === undefined || familyOf(before) !== familyOf(after)) - const stale = (type: unknown) => { - const family = familyOf(type) - return ( - family !== undefined && - (refreshUpdate?.staleFamilies.has(family) ?? false) - ) - } return { /** Id of the route file, as the plugin saw it. */ file: first.id, @@ -1801,7 +1485,6 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { invalidated, /** The `window` the modules ran with (the router is `__TSR_ROUTER__`). */ window, - refresh: { registered, refreshes }, /** Whether an option of the live route loads lazily from another module. */ isLazy: (option: string) => lazy.has(live[option]), /** The value of an option of the live route, once its lazy module loaded. */ @@ -1809,36 +1492,6 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { const loader = lazy.get(live[option]) return loader ? loader() : Promise.resolve(live[option]) }, - /** - * What a component option of the live route renders (once its lazy - * module loaded) after the update: React renders the latest version - * of a component React Refresh refreshed. - */ - render: async (option: string, props: object = {}): Promise => { - const value = live[option] - await lazy.get(value)?.() - return renderComponent(value, props) - }, - /** - * Whether React remounts a component option the page rendered before - * the update (its state is lost): the live route has another component - * React Refresh does not see as a version of the previous one, or the - * update changed its hooks. - */ - remounted: (option: string) => { - const before = previous[option] - const after = live[option] - if (replaced(before, after) || stale(after)) { - return true - } - const componentBefore = previousComponents.get(option) - const componentAfter = lazyComponents.get(after)?.() - return ( - componentBefore !== undefined && - componentAfter !== undefined && - (replaced(componentBefore, componentAfter) || stale(componentAfter)) - ) - }, } } finally { vi.unstubAllGlobals() diff --git a/packages/router-plugin/tests/route-hmr-browser.test.ts b/packages/router-plugin/tests/route-hmr-browser.test.ts new file mode 100644 index 00000000000..f6cd232c0d4 --- /dev/null +++ b/packages/router-plugin/tests/route-hmr-browser.test.ts @@ -0,0 +1,500 @@ +/** + * Route HMR as the developer sees it: a real dev server (Vite with the + * framework's Vite plugin, or Rsbuild with `@rsbuild/plugin-react`) serves a + * file-based app with the real router package to a real browser page, and + * the test edits its route files on disk (`route-hmr-page.ts`). An edit must + * show its new code; where a hot update is feasible, without a full reload, + * keeping the state of the components React Refresh patches in place. + * + * Several edits share one dev server and page. The tests run concurrently + * (each has its own dev server and page; most of their time is waiting). + */ +import { describe, it } from 'vitest' +import type { TestContext } from 'vitest' +import { openRouteHmrPage } from './route-hmr-page' +import type { RouteHmrPage } from './route-hmr-page' + +const timeout = 90_000 + +/** + * A React route file creating the route at `path` with `options`, after + * `declarations` (it imports `createFileRoute` and `notFound` of the router + * package, and React's `useState`). + */ +const fileRoute = ( + path: string, + declarations: string, + options: string, +) => `import { createFileRoute, notFound } from '@tanstack/react-router' +import { useState } from 'react' +${declarations} +export const Route = createFileRoute('${path}')({ + ${options} +}) +` + +/** + * A component named `name` whose button counts its clicks and names the + * version of the route file: ` count `. + */ +const counter = (name: string, version: string) => `function ${name}() { + const [count, setCount] = useState(0) + return +}` + +/** + * Route files at `/` whose component keeps a click count: each is a + * function of the version its component and loader show. Sources: add-hmr + * snapshot fixtures arrow-function, function-declaration, multi-component + * and string-literal-keys; the split loader rows replace + * react-refresh-route-components "does not keep a split loader in the HMR + * data"; "user bindings" is the former react-refresh-route-components + * "route HMR code next to user bindings with common names" (the route HMR + * code declares `hotData` and `handleRouteUpdate` of its own). + */ +const statefulRoutes: Array<{ + name: string + path: string + route: (version: string) => string + shows: (version: string) => string + /** The route's loader is split from the route module. */ + splitLoader?: boolean +}> = [ + { + name: 'an arrow function component', + path: 'arrow', + route: (v) => + fileRoute( + '/arrow', + `const Page = () => { + const [count, setCount] = useState(0) + return +}`, + 'component: Page,', + ), + shows: (v) => `arrow ${v} count`, + }, + { + name: 'two components', + path: 'two', + route: (v) => + fileRoute( + '/two', + `${counter('Two', v)} +function Failed() { + return

    failed ${v}

    +}`, + 'component: Two,\n errorComponent: Failed,', + ), + shows: (v) => `Two ${v} count`, + }, + { + name: 'components under quoted keys', + path: 'quoted', + route: (v) => + fileRoute( + '/quoted', + `${counter('Quoted', v)} +function Failed() { + return

    failed ${v}

    +}`, + `'component': Quoted,\n 'errorComponent': Failed,`, + ), + shows: (v) => `Quoted ${v} count`, + }, + { + name: 'a loader in its own chunk', + path: 'loader-chunk', + route: (v) => + fileRoute( + '/loader-chunk', + `function LoaderChunk() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + return +}`, + `loader: () => 'own chunk ${v}', + component: LoaderChunk, + codeSplitGroupings: [['loader'], ['component']],`, + ), + shows: (v) => `own chunk ${v} count`, + splitLoader: true, + }, + { + name: 'a loader in the component chunk', + path: 'shared-chunk', + route: (v) => + fileRoute( + '/shared-chunk', + `function SharedChunk() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + return +}`, + `loader: () => 'component chunk ${v}', + component: SharedChunk, + codeSplitGroupings: [['component', 'loader']],`, + ), + shows: (v) => `component chunk ${v} count`, + splitLoader: true, + }, + { + name: 'a value the loader and the component share', + path: 'shared-value', + route: (v) => + fileRoute( + '/shared-value', + `const label = 'label ${v}' +function SharedValue() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + return +}`, + `loader: () => label, + component: SharedValue,`, + ), + shows: (v) => `label ${v} label ${v} count`, + }, + { + name: 'user bindings named like the route HMR code', + path: 'names', + route: (v) => + fileRoute( + '/names', + `const hotData = 'data ${v}' +function handleRouteUpdate() { + return 'update ${v}' +} +function Names() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + return +}`, + `loader: () => hotData, + component: Names,`, + ), + shows: (v) => `data ${v} update ${v} count`, + }, +] + +/** The index route: a component reading the live route, and its loader. */ +const indexRoute = (version: string, hooks = '') => + fileRoute( + '/', + `function Page() { + const [count, setCount] = useState(0) + ${hooks} + const data = Route.useLoaderData() + return +}`, + `loader: () => 'data ${version}', + component: Page,`, + ) + +/** The index route with other component options and a loader. */ +const indexRouteWith = (version: string, options: string, loader: string) => + fileRoute( + '/', + `${counter('Page', version)} +function Failed() { + return

    failed ${version}

    +} +function Pending() { + return

    pending ${version}

    +} +function Missing() { + return

    missing ${version}

    +}`, + `loader: ${loader}, + component: Page, + ${options}`, + ) + +/** Navigates the app's router (client side: the page does not reload). */ +async function navigate(app: RouteHmrPage, to: string) { + await app.evaluate(`window.__TSR_ROUTER__.navigate({ to: '${to}' })`) +} + +/** Clicks the counter until the page shows `count `. */ +async function click(app: RouteHmrPage, shows: string, count: number) { + await app.click('button') + await app.page.waitForText(`${shows} ${count}`, { selector: '#app' }) +} + +describe.concurrent.each(['vite', 'rsbuild'] as const)( + '%s: a React app', + (bundler) => { + // Sources: the former route-hmr.test.ts hot updates of split and unsplit + // routes (add-hmr snapshot fixtures), regression-helpers.test.ts React + // Refresh controls (body edit keeps state, added hook remounts). + describe.each([true, false])( + 'with autoCodeSplitting: %s', + (autoCodeSplitting) => { + it( + 'shows an edited route component in place with its state, the options the edit adds, changes or removes, and remounts a component whose hooks changed', + async ({ expect, onTestFinished }) => { + const files: Record = { + 'routes/index.tsx': indexRoute('v1'), + } + for (const { path, route } of statefulRoutes) { + files[`routes/${path}.tsx`] = route('v1') + } + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files, + ready: 'page v1 data v1 / count 0', + }) + await click(app, 'page v1 data v1 / count', 1) + // The new body, the new loader's data and the live route (`Route`) + // show; the component keeps its state. + expect( + await app.edit( + { 'routes/index.tsx': indexRoute('v2') }, + 'page v2 data v2 / count 1', + ), + ).toEqual({ reloaded: false }) + // A component whose hooks changed loses its state (React remounts + // it), as in any React app. + expect( + await app.edit( + { + 'routes/index.tsx': indexRoute( + 'v3', + `const [other] = useState('other')`, + ), + }, + 'page v3 data v3 / count 0', + ), + ).toEqual({ reloaded: false }) + + // Component options the edit adds, changes or removes. + const failing = `() => { + throw new Error('loader failed') + }` + expect( + await app.edit( + { + 'routes/index.tsx': indexRouteWith( + 'v4', + 'errorComponent: Failed,', + failing, + ), + }, + 'failed v4', + ), + ).toEqual({ reloaded: false }) + expect( + await app.edit( + { + 'routes/index.tsx': indexRouteWith( + 'v5', + 'errorComponent: Failed,', + failing, + ), + }, + 'failed v5', + ), + ).toEqual({ reloaded: false }) + // Without its error component, the route shows the router's + // default one. (Whether without a reload: pinned in + // known-bugs-route-hmr.test.ts, "removing a split option".) + await app.edit( + { 'routes/index.tsx': indexRouteWith('v6', '', failing) }, + /^(?!.*failed v).*loader failed/, + ) + const pending = `() => new Promise(() => {})` + for (const version of ['v7', 'v8']) { + expect( + await app.edit( + { + 'routes/index.tsx': indexRouteWith( + version, + 'pendingComponent: Pending,\n pendingMs: 0,\n pendingMinMs: 0,', + pending, + ), + }, + `pending ${version}`, + ), + ).toEqual({ reloaded: false }) + } + for (const version of ['v9', 'v10']) { + expect( + await app.edit( + { + 'routes/index.tsx': indexRouteWith( + version, + 'notFoundComponent: Missing,', + `() => { + throw notFound() + }`, + ), + }, + `missing ${version}`, + ), + ).toEqual({ reloaded: false }) + } + + // Other shapes of route files. (A loader split from the route + // module on Rsbuild: pinned in known-bugs-route-hmr.test.ts.) + for (const { + name, + path, + route, + shows, + splitLoader, + } of statefulRoutes) { + if (bundler === 'rsbuild' && autoCodeSplitting && splitLoader) { + continue + } + await navigate(app, `/${path}`) + await app.page.waitForText(`${shows('v1')} 0`, { + selector: '#app', + }) + await click(app, shows('v1'), 1) + expect( + await app.edit( + { [`routes/${path}.tsx`]: route('v2') }, + `${shows('v2')} 1`, + ), + name, + ).toEqual({ reloaded: false }) + } + }, + timeout, + ) + }, + ) + + // Source: decision on rp:1304 (shellComponent + HMR, 2026-10-18), the + // add-hmr snapshot fixture createRootRoute, and the factory ruling + // (2026-10-18: a reload is fine, the edit must show). + it( + 'shows an edited root route in place, and the shell component the edit adds, changes or removes; an edit of a route whose options a factory call builds shows', + async ({ expect, onTestFinished }) => { + const root = (version: string, shell = '') => + `import { createRootRoute, Outlet } from '@tanstack/react-router' +import { useState } from 'react' +function Root() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + return ( +
    + + +
    + ) +} +export const Route = createRootRoute({ + loader: () => 'data ${version}', + component: Root,${ + shell && + ` + shellComponent: ({ children }) => ( +
    +

    shell ${shell}

    + {children} +
    + ),` + } +}) +` + const factoryRoute = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +import { makeOptions } from './-options' +export const Route = createFileRoute('/')(makeOptions('${version}')) +` + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + files: { + 'routes/__root.tsx': root('v1'), + 'routes/-options.tsx': `export const makeOptions = (version: string) => ({ + component: () =>

    made {version}

    , +}) +`, + 'routes/index.tsx': factoryRoute('v1'), + }, + ready: 'root v1 data v1 count 0made v1', + }) + await click(app, 'root v1 data v1 count', 1) + expect( + await app.edit( + { 'routes/__root.tsx': root('v2') }, + 'root v2 data v2 count 1', + ), + ).toEqual({ reloaded: false }) + // A shell changes the tree the route renders in: adding or removing it + // may remount the route's component; editing it patches both in place. + expect( + await app.edit( + { 'routes/__root.tsx': root('v3', 'v3') }, + /shell v3root v3 data v3 count \d+made v1/, + ), + ).toEqual({ reloaded: false }) + await click(app, 'root v3 data v3 count', 1) + expect( + await app.edit( + { 'routes/__root.tsx': root('v4', 'v4') }, + 'shell v4root v4 data v4 count 1made v1', + ), + ).toEqual({ reloaded: false }) + expect( + await app.edit( + { 'routes/__root.tsx': root('v5') }, + /^root v5 data v5 count \d+made v1$/, + ), + ).toEqual({ reloaded: false }) + // Route options a factory call builds: the edit shows, after a hot + // update or a reload (ruling 2026-10-18). + await app.edit({ 'routes/index.tsx': factoryRoute('v2') }, 'made v2') + }, + timeout, + ) + }, +) + +// Solid and Vue in Vite with their framework's plugin: an edit of a route +// file shows its new code. Whether it does without a full reload, keeping +// the split component (decision D10), is pinned in +// known-bugs-route-hmr.test.ts; Solid without code splitting is a hot update. +describe.concurrent.each(['solid', 'vue'] as const)( + 'vite: a %s app', + (target) => { + const route = (version: string) => + `import { createFileRoute } from '@tanstack/${target}-router' +export const Route = createFileRoute('/')({ + loader: () => 'data ${version}', + component: Page, +}) +function Page() { + const data = Route.useLoaderData() + return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    +} +` + it.for([true, false])( + 'shows the new code of an edited route file (autoCodeSplitting: %s)', + { timeout }, + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + const app = await openRouteHmrPage({ + target, + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': route('v1') }, + ready: 'page v1 data v1', + }) + const { reloaded } = await app.edit( + { 'routes/index.tsx': route('v2') }, + 'page v2 data v2', + ) + if (target === 'solid' && !autoCodeSplitting) { + expect(reloaded).toBe(false) + } + }, + ) + }, +) diff --git a/packages/router-plugin/tests/route-hmr-page.ts b/packages/router-plugin/tests/route-hmr-page.ts new file mode 100644 index 00000000000..2d0b982a9b1 --- /dev/null +++ b/packages/router-plugin/tests/route-hmr-page.ts @@ -0,0 +1,313 @@ +/** + * Route HMR as an app sees it: a real dev server (Vite with the router + * plugin and the framework's own Vite plugin, or Rsbuild with the router + * plugin's Rspack entry and `@rsbuild/plugin-react`) serves a small + * file-based app with the real router package, a real browser page + * (headless Chromium) runs it with the dev server's own HMR client, and the + * test edits route files on disk, which the dev server's own file watcher + * reports. The test reads the page: what it shows, and whether it reloaded. + * + * Nothing of the HMR path is modelled: the bundler decides which modules + * accept the update, the framework's refresh runtime (React Refresh, Solid + * Refresh, Vue's HMR runtime) patches components, and the route HMR code + * the router plugin emits updates the live router. + */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { pluginReact } from '@rsbuild/plugin-react' +import react from '@vitejs/plugin-react' +import vueJsx from '@vitejs/plugin-vue-jsx' +import solid from 'vite-plugin-solid' +import { onTestFinished } from 'vitest' +import { openPage } from '@tanstack/compiler-test-utils/browser' +import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' +import { createRsbuildDevServer } from '@tanstack/compiler-test-utils/rsbuild-dev-server' +import { tanstackRouter as tanstackRouterRspack } from '../src/rspack' +import { tanstackRouter } from '../src/vite' +import type { BrowserPage } from '@tanstack/compiler-test-utils/browser' +import type { RsbuildPlugin } from '@rsbuild/core' +import type { PluginOption } from 'vite' +import type { Config } from '../src/core/config' +import type { TargetFramework } from './regression-helpers' + +const harnessDir = path.dirname(fileURLToPath(import.meta.url)) + +/** How the app mounts the router into `#app`, per framework. */ +const mains: Record = { + react: `import { createRoot } from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +const router = createRouter({ routeTree }) +createRoot(document.getElementById('app')!).render() +`, + solid: `import { render } from 'solid-js/web' +import { RouterProvider, createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' +const router = createRouter({ routeTree }) +render(() => , document.getElementById('app')!) +`, + vue: `import { createApp } from 'vue' +import { RouterProvider, createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' +const router = createRouter({ routeTree }) +createApp({ setup: () => () => }).mount('#app') +`, +} + +/** The framework's own Vite plugin, as an app adds it. */ +const frameworkPlugins: Record PluginOption> = { + react: () => react(), + solid: () => solid(), + vue: () => vueJsx(), +} + +/** + * Dependencies the app imports, pre-bundled up front as Vite does for an app + * that installed them (the router package is a linked workspace package + * here, which Vite would otherwise serve as source and discover the + * dependencies of late, reloading the page). + */ +const prebundled: Record> = { + react: [ + 'react', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + '@tanstack/react-router', + ], + solid: [], + vue: ['vue', '@tanstack/vue-router'], +} + +export interface RouteHmrPageOptions { + /** Default `vite`. Rsbuild runs React apps only. */ + bundler?: 'vite' | 'rsbuild' + /** Default `react`. */ + target?: TargetFramework + /** Router plugin options over the defaults (`autoCodeSplitting: true`). */ + pluginOptions?: Partial + /** + * Whether the app adds its framework's plugin (default `true`): + * `@vitejs/plugin-react`, `vite-plugin-solid` or `@vitejs/plugin-vue-jsx` + * with Vite, `@rsbuild/plugin-react` with Rsbuild. Without it, a React app + * on Rsbuild runs without React Refresh. + */ + frameworkPlugin?: boolean + /** + * Rsbuild: `@rsbuild/plugin-react`'s `fastRefresh` (default `true`); a + * React app on Rsbuild without React Refresh sets it to `false`. + */ + reactRefresh?: boolean + /** + * App files relative to its root: route files in `routes/` + * (`routes/__root.tsx` defaults to an empty root route, which renders its + * child routes) and any other module they import. + */ + files: Record + /** Text the app shows once it rendered. */ + ready: string | RegExp + /** + * Registers the cleanup (closing the page and the dev server) to run when + * the test ends. Default: vitest's `onTestFinished`; a concurrent test + * passes the one of its test context. + */ + onTestFinished?: (cleanup: () => Promise) => void +} + +export interface RouteHmrPage { + /** The browser page. */ + page: BrowserPage + /** Clicks the first element matching `selector`. */ + click: (selector: string) => Promise + /** + * Writes app files (`null` deletes one) for the dev server's watcher to + * pick up, waits until the app shows `expected`, then until the dev server + * is quiet, and returns whether the page reloaded meanwhile (a full reload + * instead of a hot update). + */ + edit: ( + files: Record, + expected: string | RegExp, + options?: { timeout?: number }, + ) => Promise<{ reloaded: boolean }> + /** + * Runs `script` in the page (e.g. reads the live router, + * `window.__TSR_ROUTER__`) and returns its JSON value. + */ + evaluate: (script: string) => Promise +} + +/** The part of a dev server the page needs. */ +interface AppServer { + url: string + /** Writes files for the server's watcher. */ + write: (files: Record) => Promise + /** Waits until the server is quiet. */ + quiet: () => Promise + /** Full reloads the server sent the page so far. */ + fullReloads: () => number +} + +/** How long the dev server must stay silent after an edit showed. */ +const quietTime = 300 + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Starts a dev server on an app of `files` (see `RouteHmrPageOptions`), opens + * it in a browser page and waits until it shows `ready`. + */ +export async function openRouteHmrPage( + options: RouteHmrPageOptions, +): Promise { + const target = options.target ?? 'react' + const files = { + 'main.tsx': mains[target], + 'routes/__root.tsx': `import { createRootRoute } from '@tanstack/${target}-router' +export const Route = createRootRoute({}) +`, + ...options.files, + } + const server = + (options.bundler ?? 'vite') === 'vite' + ? await viteServer(target, files, options) + : await rsbuildServer(target, files, options) + const page = await openPage(server.url, { + registerCleanup: options.onTestFinished ?? onTestFinished, + }) + await page.waitForText(options.ready, { selector: '#app' }) + await server.quiet() + + return { + page, + click: async (selector) => { + await page.page.locator(selector).first().click() + }, + edit: async (edited, expected, editOptions = {}) => { + const loads = page.loads() + const fullReloads = server.fullReloads() + await server.write(edited) + await page.waitForText(expected, { + selector: '#app', + timeout: editOptions.timeout, + }) + await server.quiet() + return { + reloaded: + server.fullReloads() !== fullReloads || page.loads() !== loads, + } + }, + evaluate: (script) => page.page.evaluate(script), + } +} + +async function viteServer( + target: TargetFramework, + files: Record, + options: RouteHmrPageOptions, +): Promise { + const server = await createDevServer({ + dir: harnessDir, + prefix: '.route-hmr-page-', + registerCleanup: options.onTestFinished ?? onTestFinished, + browser: true, + files: { + 'index.html': ` + + +
    + + + +`, + ...files, + }, + config: { optimizeDeps: { include: prebundled[target] } }, + plugins: [ + tanstackRouter({ + target, + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + autoCodeSplitting: true, + ...options.pluginOptions, + }), + ...(options.frameworkPlugin === false + ? [] + : [frameworkPlugins[target]()]), + ], + }) + return { + url: server.url!, + write: server.write, + // Until the server sent no HMR message for a while + quiet: async () => { + let count = server.hmr.length + let since = Date.now() + for (;;) { + await sleep(25) + if (server.hmr.length !== count) { + count = server.hmr.length + since = Date.now() + } else if (Date.now() - since >= quietTime) { + await server.idle() + return + } + } + }, + fullReloads: () => + server.hmr.filter( + (message) => + message.environment === 'client' && message.type === 'full-reload', + ).length, + } +} + +async function rsbuildServer( + target: TargetFramework, + files: Record, + options: RouteHmrPageOptions, +): Promise { + if (target !== 'react') { + throw new Error('the Rsbuild page runs React apps only') + } + const server = await createRsbuildDevServer({ + dir: harnessDir, + prefix: '.route-hmr-page-rsbuild-', + registerCleanup: options.onTestFinished ?? onTestFinished, + browser: true, + entry: 'main.tsx', + files, + config: { html: { mountId: 'app' } }, + plugins: ({ root }) => [ + ...(options.frameworkPlugin === false + ? [] + : [pluginReact({ fastRefresh: options.reactRefresh ?? true })]), + { + name: 'route-hmr-page', + setup(api) { + api.modifyRspackConfig((config) => { + config.plugins.push( + tanstackRouterRspack({ + target, + // The Rspack plugin resolves relative paths from the working + // directory + routesDirectory: path.join(root, 'routes'), + generatedRouteTree: path.join(root, 'routeTree.gen.ts'), + autoCodeSplitting: true, + ...options.pluginOptions, + }), + ) + }) + }, + } satisfies RsbuildPlugin, + ], + }) + return { + url: server.url!, + write: server.edit, + quiet: () => sleep(quietTime), + // Rsbuild's client reloads the page itself + fullReloads: () => 0, + } +} diff --git a/packages/router-plugin/tests/route-hmr.test.ts b/packages/router-plugin/tests/route-hmr.test.ts index 5bebc6d7a4b..c54d13027f3 100644 --- a/packages/router-plugin/tests/route-hmr.test.ts +++ b/packages/router-plugin/tests/route-hmr.test.ts @@ -3,19 +3,16 @@ * the identity of its components so that the framework's refresh patches * them without a remount, and nothing of it ships when HMR is off. * - * Hot updates run with `hotUpdate` (a real `RouterCore` and route classes, - * the bundler's hot API and React Refresh's runtime): `render(option)` is - * what the page shows after the update. Compiled modules are evaluated with - * `loadRoute` / `evaluateModule`; what the route HMR plugin serves, and - * which edits a Vite dev server hot-updates, is read from a real dev server - * with the framework's Vite plugin (`editRouteFile`). + * What the page shows after an edit is tested in a real dev server and a + * real browser page (`route-hmr-browser.test.ts`). Here: the route HMR code + * the router plugin emits, run on its own with the bundler's hot API + * (`hotUpdate`), compiled modules evaluated with `loadRoute` / + * `evaluateModule`, and what the route HMR plugin serves in a real Vite dev + * server. */ import { SourceMap } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' -import react from '@vitejs/plugin-react' -import vueJsx from '@vitejs/plugin-vue-jsx' -import solid from 'vite-plugin-solid' import { describe, expect, it, onTestFinished, test, vi } from 'vitest' import { createDevServer, @@ -26,13 +23,11 @@ import { compileRouteModules, evaluateModule, expectValidModules, - generatedRouteId, head, hotUpdate, importSources, loadRoute, } from './regression-helpers' -import { editRouteFile } from './vite-hot-update' import type { TargetFramework } from './regression-helpers' const harnessDir = path.dirname(fileURLToPath(import.meta.url)) @@ -58,206 +53,42 @@ function PostsErrorComponent() { return
    Error loading posts ${version}
    }` -/** - * Split routes and the component options each one splits. Each fixture is a - * function of the version: its loader returns it and its components render - * it, so the update can tell the new code apart. - */ -const splitRoutes = [ - { - name: 'an arrow function component', - route: (version: string) => - fileRoute(version, 'component: PostsComponent,', '').concat( - `\nconst PostsComponent = () =>
    posts ${version}
    `, - ), - components: ['component'], - }, - { - name: 'a function declaration component', - route: (version: string) => - fileRoute(version, 'component: PostsComponent,', posts(version)), - components: ['component'], - }, - { - name: 'two split components', - route: (version: string) => - fileRoute( - version, - 'component: PostsComponent,\n errorComponent: PostsErrorComponent,', - posts(version), - ), - components: ['component', 'errorComponent'], - }, - { - name: 'components under quoted keys', - route: (version: string) => - fileRoute( - version, - `'component': PostsComponent,\n 'errorComponent': PostsErrorComponent,`, - posts(version), - ), - components: ['component', 'errorComponent'], - }, - { - name: 'a split loader in its own chunk', - route: (version: string) => - fileRoute( - version, - `component: PostsComponent,\n codeSplitGroupings: [['loader'], ['component']],`, - posts(version), - ), - components: ['component'], - }, - { - name: 'a split loader in the component chunk', - route: (version: string) => - fileRoute( - version, - `component: PostsComponent,\n codeSplitGroupings: [['component', 'loader']],`, - posts(version), - ), - components: ['component'], - }, -] - -/** What each component option of `splitRoutes` renders in a version. */ -const rendered = (option: string, version: string) => - option === 'component' - ? `
    posts ${version}
    ` - : `
    Error loading posts ${version}
    ` - /** The value a loader of the live route returns, split or not. */ async function loaderData(update: Awaited>) { return (await update.load('loader')).call(undefined) } -describe('a hot update of a split route', () => { - // Source: add-hmr snapshot fixtures arrow-function, function-declaration, - // multi-component and string-literal-keys (React, Vite and webpack); the - // split loader row replaces react-refresh-route-components "does not keep - // a split loader in the HMR data". - describe.each(['vite', 'webpack'] as const)('%s', (style) => { - it.each(splitRoutes)( - 'keeps the split components, renders their new code in place and applies the new options ($name)', - async ({ route, components }) => { - const update = await hotUpdate(route('old'), { - style, - codeSplitting: true, - updated: route('new'), - }) - expect(update.invalidated).toBe(false) - for (const option of components) { - expect(update.isLazy(option), option).toBe(true) - expect(update.live[option], option).toBe(update.previous[option]) - expect(await update.render(option), option).toBe( - rendered(option, 'new'), - ) - expect(update.remounted(option), option).toBe(false) - } - expect(await loaderData(update)).toBe('new') - }, - ) - }) - - // An edit that adds or removes a split component option. - it.each(['vite', 'webpack'] as const)( - '%s: renders a split component option the update adds, and drops one it removes', - async (style) => { - const one = (version: string) => - fileRoute(version, 'component: PostsComponent,', posts(version)) - const two = (version: string) => +describe('the route HMR code of a Solid or Vue route', () => { + // Source: add-hmr snapshot fixture solid/arrow-function. Solid and Vue + // have no React Refresh to patch a kept component in place: the live route + // gets the new component (webpack / Rspack style, the route module runs + // again). What the page shows after an edit: route-hmr-browser.test.ts. + test.each([ + { framework: 'solid', codeSplitting: true }, + { framework: 'vue', codeSplitting: true }, + { framework: 'solid', codeSplitting: false }, + { framework: 'vue', codeSplitting: false }, + ] as const)( + '$framework (codeSplitting: $codeSplitting): applies the new options and component with the router package of the framework and no React code', + async ({ framework, codeSplitting }) => { + const route = (version: string) => fileRoute( version, - 'component: PostsComponent,\n errorComponent: PostsErrorComponent,', - posts(version), - ) - const added = await hotUpdate(one('old'), { - style, - codeSplitting: true, - updated: two('new'), - }) - expect(added.invalidated).toBe(false) - expect(added.isLazy('errorComponent')).toBe(true) - expect(await added.render('errorComponent')).toBe( - rendered('errorComponent', 'new'), - ) - expect(await added.render('component')).toBe(rendered('component', 'new')) - const removed = await hotUpdate(two('old'), { - style, - codeSplitting: true, - updated: one('new'), - }) - expect(removed.invalidated).toBe(false) - expect(removed.live.errorComponent).toBeUndefined() - expect(await removed.render('component')).toBe( - rendered('component', 'new'), - ) - }, - ) - - // A split component that reads the route, next to a loader that calls an - // import. Source: add-hmr snapshot fixtures (main) whose component reads - // `Route.*`. - describe.each([ - { style: 'vite', codeSplitting: true }, - { style: 'webpack', codeSplitting: true }, - { style: 'vite', codeSplitting: false }, - { style: 'webpack', codeSplitting: false }, - ] as const)('$style (codeSplitting: $codeSplitting)', (options) => { - it('updates a route whose component reads the route and whose loader calls an import', async () => { - const route = ( - version: string, - ) => `import { createFileRoute } from '@tanstack/react-router' -import { fetchData } from './api' -function Page() { - return
    {Route.fullPath} ${version}
    -} -export const Route = createFileRoute('/posts')({ - loader: () => fetchData('${version}'), - component: Page, -})` - const update = await hotUpdate(route('old'), { - ...options, - routeId: '/posts', - updated: route('new'), - stubs: { './api': { fetchData: (data: string) => data } }, - }) - expect(update.invalidated).toBe(false) - expect(update.live.component).toBe(update.previous.component) - expect(await loaderData(update)).toBe('new') - expect(await update.render('component')).toBe('
    /posts new
    ') - expect(update.remounted('component')).toBe(false) - }) - }) - - /** A Solid or Vue split route, compiled for the dev server. */ - const frameworkRoute = (framework: TargetFramework) => (version: string) => - fileRoute( - version, - 'component: PostsComponent,', - `function PostsComponent() { + 'component: PostsComponent,', + `function PostsComponent() { return
    posts ${version}
    }`, - framework, - ) - - // Solid and Vue: webpack / Rspack (whether Vite hot-updates them depends - // on their framework plugin: the pins below and known-bugs-route-hmr.test.ts - // "Vite HMR boundaries"). Source: add-hmr snapshot fixture - // solid/arrow-function. - test.each(['solid', 'vue'] as const)( - '%s: applies the new options and renders the new component with the router package of the framework', - async (framework) => { - const route = frameworkRoute(framework) + framework, + ) const update = await hotUpdate(route('old'), { style: 'webpack', targetFramework: framework, - codeSplitting: true, + codeSplitting, updated: route('new'), }) expect(update.invalidated).toBe(false) expect(await loaderData(update)).toBe('new') - expect(await update.render('component')).toBe('
    posts new
    ') + expect((await update.load('component'))()).toBe('
    posts new
    ') // No React code. expect(Object.keys(update.window)).toEqual(['__TSR_ROUTER__']) for (const style of ['vite', 'webpack'] as const) { @@ -273,305 +104,26 @@ export const Route = createFileRoute('/posts')({ } }, ) - - // Bug: route HMR keeps the previous split components only for React: for - // Solid and Vue, the route module creates a new lazy component on every - // update and `handleRouteUpdate` keeps no component option - // (`getFrameworkHmrCompilerPlugins` returns no plugin and the kept - // component keys are empty for other targets); in Vite, the route module - // is not an HMR boundary either (its accept call is on an alias), so the - // page reloads. - // Impact: every edit of a Solid or Vue route file reloads the page (Vite) - // or remounts the route's component (webpack / Rspack), losing its state, - // instead of patching it in place. - // Goal (any fix): in a Vite dev server with the framework's plugin, the - // edit is a hot update that runs the edited component's module again - // (where the framework's refresh patches the component), and the live - // route keeps the component the page rendered. - // Source: decision D10 (Solid/Vue split components keep their identity - // across hot updates, as React's do); gaps H8/H9. - test.fails.each([ - { framework: 'solid', plugin: () => solid() }, - { framework: 'vue', plugin: () => vueJsx() }, - ] as const)( - '$framework: keeps the split component and updates it in place', - async ({ framework, plugin }) => { - const route = frameworkRoute(framework) - const served = await editRouteFile({ - target: framework, - autoCodeSplitting: true, - plugins: [plugin()], - route, - loaded: ['?tsr-split=component'], - }) - expect(served.messages).not.toContainEqual( - expect.stringMatching(/full-reload/), - ) - expect(served.reimports('/routes/index.tsx?tsr-split=component')).toBe( - true, - ) - const update = await hotUpdate(route('old'), { - style: 'webpack', - targetFramework: framework, - codeSplitting: true, - updated: route('new'), - }) - expect(update.live.component).toBe(update.previous.component) - }, - 30_000, - ) -}) - -// What a Vite dev server running `@vitejs/plugin-react` sends the page when a -// split route file is edited: a hot update, whose boundaries include the -// split component the page loaded (React Refresh patches it in place, as -// `hotUpdate` shows above). -describe('an edit of a React split route in a Vite dev server', () => { - it('is a hot update that imports the edited split component again', async () => { - const served = await editRouteFile({ - target: 'react', - autoCodeSplitting: true, - plugins: [react()], - route: (version) => - fileRoute(version, 'component: PostsComponent,', posts(version)), - loaded: ['?tsr-split=component'], - }) - expect(served.hotUpdate).toBe(true) - expect(served.reimports('/routes/index.tsx?tsr-split=component')).toBe(true) - }, 30_000) - - it('is a hot update when the edit changes a value the loader and the split component share', async () => { - const served = await editRouteFile({ - target: 'react', - autoCodeSplitting: true, - plugins: [react()], - route: (version) => `${head}const label = '${version}' -function PostsComponent() { - return
    {label}
    -} -export const Route = createFileRoute('/')({ - loader: () => label, - component: PostsComponent, -})`, - loaded: ['?tsr-split=component'], - }) - expect(served.hotUpdate).toBe(true) - }, 30_000) -}) - -describe('a hot update of a root route', () => { - const root = ( - version: string, - ) => `import { createRootRoute } from '@tanstack/react-router' -export const Route = createRootRoute({ - loader: () => '${version}', - component: RootComponent, -}) -function RootComponent() { - return
    Hello ${version}
    -}` - - // Source: add-hmr snapshot fixture createRootRoute and the generated root - // route id (`/__root`) test. - it.each(['vite', 'webpack'] as const)( - '%s: reaches the root route, renders its new component in place and applies the new loader', - async (style) => { - const update = await hotUpdate(root('old'), { - style, - codeSplitting: true, - routeId: await generatedRouteId('__root.tsx'), - updated: root('new'), - }) - expect(update.invalidated).toBe(false) - expect(update.live.component).toBe(update.previous.component) - expect(await update.render('component')).toBe('
    Hello new
    ') - expect(update.remounted('component')).toBe(false) - expect(await loaderData(update)).toBe('new') - }, - ) - - // `shellComponent` is a root route option. A shell changes the tree the - // route renders in: adding or removing it renders the new components - // (React may remount them); editing it patches them in place. - // Source: decision on rp:1304 (shellComponent + HMR, 2026-10-18). - const shellRoot = (options: Record) => - `import { createRootRoute } from '@tanstack/react-router' -export const Route = createRootRoute({ -${Object.entries(options) - .map(([option, value]) => ` ${option}: ${value},`) - .join('\n')} -})` - const shell = (version: string) => - `({ children }) => ${version}{children}` - const page = (version: string) => `() =>

    ${version}

    ` - describe.each(['vite', 'webpack'] as const)('%s', (style) => { - it.each<{ - edit: string - before: Record - after: Record - }>([ - { - edit: 'adds', - before: { component: page('old') }, - after: { shellComponent: shell('new'), component: page('new') }, - }, - { - edit: 'edits', - before: { shellComponent: shell('old'), component: page('old') }, - after: { shellComponent: shell('new'), component: page('new') }, - }, - { - edit: 'removes', - before: { shellComponent: shell('old'), component: page('old') }, - after: { component: page('new') }, - }, - ])( - 'an update that $edit the shell component renders the new shell and component', - async ({ edit, before, after }) => { - const update = await hotUpdate(shellRoot(before), { - style, - codeSplitting: true, - routeId: await generatedRouteId('__root.tsx'), - updated: shellRoot(after), - }) - expect(update.invalidated).toBe(false) - expect(await update.render('component')).toBe('

    new

    ') - if ('shellComponent' in after) { - expect( - await update.render('shellComponent', { children: 'page' }), - ).toBe('newpage') - } else { - expect(update.live.shellComponent).toBeUndefined() - } - if (edit === 'edits') { - expect(update.remounted('component')).toBe(false) - expect(update.remounted('shellComponent')).toBe(false) - } - }, - ) - }) }) -describe('a hot update of an unsplit route', () => { - const componentOptions = [ - 'component', - 'pendingComponent', - 'errorComponent', - 'notFoundComponent', - ] - /** - * A route at `/` with a component for each of `options` rendering its - * option and `version`. - */ - const route = (data: string, options: Array, version = data) => - fileRoute( - data, - options - .map((option) => `${option}: () =>

    ${option} ${version}

    ,`) - .join('\n '), - ) - - it.each(['vite', 'webpack'] as const)( - '%s: keeps every component option, renders their new code in place and applies the new loader', - async (style) => { - const update = await hotUpdate(route('old', componentOptions), { - style, - updated: route('new', componentOptions), - }) - expect(update.invalidated).toBe(false) - for (const option of componentOptions) { - expect(update.live[option], option).toBeTypeOf('function') - expect(update.live[option], option).toBe(update.previous[option]) - expect(await update.render(option), option).toBe(`

    ${option} new

    `) - expect(update.remounted(option), option).toBe(false) - } - expect(await loaderData(update)).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, updated: route('new', ['component']) }, - ) - expect(update.live.errorComponent).toBeUndefined() - expect(update.live.component).toBe(update.previous.component) - expect(await update.render('component')).toBe('

    component new

    ') - }, - ) - - it.each(['vite', 'webpack'] as const)( - '%s: renders a component option the update adds', - async (style) => { - const update = await hotUpdate(route('old', ['component']), { - style, - updated: route('new', ['component', 'errorComponent'], 'added'), - }) - expect(await update.render('errorComponent')).toBe( - '

    errorComponent added

    ', - ) - }, - ) - - // Solid and Vue have no React Refresh to patch a kept component in place, - // so the update renders the new one (webpack / Rspack; see the split - // route's Solid and Vue rows for Vite). - 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'), { - style: 'webpack', - targetFramework: framework, - updated: source('new'), - }) - expect(update.invalidated).toBe(false) - expect(await update.render('component')).toBe('

    new

    ') - expect(await loaderData(update)).toBe('new') - expect(Object.keys(update.window)).toEqual(['__TSR_ROUTER__']) - }, - ) - - /** A route whose options a factory call builds. */ - const factoryRoute = ( - data: string, - ) => `import { createFileRoute } from '@tanstack/react-router' +describe('the route HMR code of a route whose options a factory call builds', () => { + // webpack and Rspack run the route module again: its HMR code applies the + // options the new factory call built. (In Vite, the edit may reload the + // page instead; either way the edit shows: route-hmr-browser.test.ts.) + it('webpack: applies the new options', async () => { + const factoryRoute = ( + data: string, + ) => `import { createFileRoute } from '@tanstack/react-router' import { makeOptions } from './options' export const Route = createFileRoute('/')(makeOptions('${data}'))` - const options = { - './options': { - makeOptions: (data: string) => ({ loader: () => data }), - }, - } - - it('webpack: applies route options a factory call builds', async () => { const update = await hotUpdate(factoryRoute('old'), { style: 'webpack', updated: factoryRoute('new'), - stubs: options, - }) - expect(update.invalidated).toBe(false) - expect(await loaderData(update)).toBe('new') - }) - - // Bug: a route module in which React Refresh registers no component (its - // options come from a factory call, so there is no component to hoist and - // the route HMR plugin adds no Fast Refresh anchor) is not a Vite HMR - // boundary with `@vitejs/plugin-react`: the route HMR code calls `accept` - // on an alias (`const hot = import.meta.hot`), and Vite only takes a - // literal `import.meta.hot.accept(` (same root cause as the `.ts` route - // module pin of known-bugs-route-hmr.test.ts "Vite HMR boundaries"). - // Impact: every edit of such a route file reloads the page in Vite dev. - // NEW (wave 2): hotUpdate now decides Vite boundaries like Vite - // (regression-helpers.test.ts "reloads the page exactly when a Vite dev - // server does"). - it.fails('vite: applies route options a factory call builds', async () => { - const update = await hotUpdate(factoryRoute('old'), { - updated: factoryRoute('new'), - stubs: options, + stubs: { + './options': { + makeOptions: (data: string) => ({ loader: () => data }), + }, + }, }) expect(update.invalidated).toBe(false) expect(await loaderData(update)).toBe('new') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b7bdcc562e..2310f44cf09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14803,6 +14803,9 @@ importers: '@rsbuild/core': specifier: 'catalog:' version: 2.2.5 + '@rsbuild/plugin-react': + specifier: ^2.0.0 + version: 2.0.0(@rsbuild/core@2.2.5)(@rspack/core@2.2.7(@swc/helpers@0.5.23)) '@tanstack/compiler-test-utils': specifier: workspace:* version: link:../compiler-test-utils @@ -14857,9 +14860,6 @@ importers: react-dom: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) - react-refresh: - specifier: ^0.18.0 - version: 0.18.0 rimraf: specifier: 'catalog:' version: 6.1.2 From d14697f7b07b8c04a3aae26cbc75915f77dc9f27 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 14:09:35 +0200 Subject: [PATCH 0405/1425] test(start-plugin-core): Start route HMR in a real page; delete the hand-applied update User ruling 2026-10-18: tests must not re-implement other tools. The start-router-dev-server "client route module takes part in HMR" test (spc:6046) applied the hot update "the way Vite's HMR client does": a copied createHotContext, accept callbacks called by hand, a stub `__TSR_ROUTER__`. Deleted. Replacement: a real Vite dev server with Start's whole Vite plugin and @vitejs/plugin-react (`browser: true` of the shared harness) serves an app with the real @tanstack/react-router to headless Chromium (`@tanstack/compiler-test-utils/browser`). Vite's own watcher reports the edits; Vite's HMR client, React Refresh and the route HMR code Start adds apply them. After a loader-only edit and then a component edit of the split page route, the page shows the new loader data and component, the split component keeps its click count (no remount), there is one page load, no full-reload message and no page error. The page is the app's index.html, rendered on the client: Start's SSR document needs the real @tanstack/react-start, which depends on start-plugin-core (a workspace cycle). Start SSR + HMR in a real app stays in e2e/react-start/hmr. Scratch control (reverted): with `addHmr: false` in Start's client code splitter (vite/start-router-plugin/plugin.ts), the test fails: the page reloads (2 loads) and shows "count 0". Harness: tests/dev-server.ts `realRouter` (with `start`) leaves out the stand-in router package. New devDependencies: @tanstack/react-router (workspace), @vitejs/plugin-react, react, react-dom (catalog); lockfile regenerated with pnpm install --no-frozen-lockfile. Co-Authored-By: Claude Opus 5.5 --- packages/start-plugin-core/package.json | 4 + .../start-plugin-core/tests/dev-server.ts | 17 +- .../tests/start-router-dev-server.test.ts | 187 ++++++++++-------- pnpm-lock.yaml | 12 ++ 4 files changed, 133 insertions(+), 87 deletions(-) diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index d21185a76c4..54554dcb55d 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -115,7 +115,11 @@ "@types/picomatch": "^4.0.2", "vite": "*", "@tanstack/compiler-test-utils": "workspace:*", + "@tanstack/react-router": "workspace:*", "@tanstack/vite-config": "catalog:", + "@vitejs/plugin-react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", "vitest": "catalog:", "@arethetypeswrong/cli": "catalog:", "publint": "catalog:", diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts index 892f4769e2b..b90f70dce66 100644 --- a/packages/start-plugin-core/tests/dev-server.ts +++ b/packages/start-plugin-core/tests/dev-server.ts @@ -56,7 +56,7 @@ export interface StartDevServerOptions extends Omit< * (`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 + * stand-in (`routerRuntimeFiles`) unless `realRouter`; 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. */ @@ -67,11 +67,16 @@ export interface StartDevServerOptions extends Omit< * transform). */ compilerTransforms?: Array + /** + * With `start`: the app imports the real `@tanstack/react-router` (a + * devDependency of this package) instead of the build harness's stand-in, + * e.g. to run in a browser page (`browser`). Default `false`. + */ + realRouter?: boolean } /** 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`, @@ -118,6 +123,7 @@ export function startDevServer( providerEnvName = 'ssr', start, compilerTransforms, + realRouter = false, ...devServerOptions } = options /** Server environments Vite does not create by default. */ @@ -129,7 +135,12 @@ export function startDevServer( prefix: '.start-dev-server-', registerCleanup: onTestFinished, ...devServerOptions, - files: { ...startRuntimeFiles, ...(start && startPluginFiles), ...files }, + files: { + ...startRuntimeFiles, + ...(start && !realRouter && routerRuntimeFiles), + ...(start && startPluginFiles), + ...files, + }, plugins: ({ root }) => [ start ? startPlugin(root, start, compilerTransforms) 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 index b739080c542..fabb3abb3a7 100644 --- a/packages/start-plugin-core/tests/start-router-dev-server.test.ts +++ b/packages/start-plugin-core/tests/start-router-dev-server.test.ts @@ -2,13 +2,13 @@ * 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. + * into a server-only route; an edited page route hot-updates in a real + * browser page. */ +import react from '@vitejs/plugin-react' import { describe, expect, onTestFinished, test } from 'vitest' -import { - describeHmr, - evaluateServed, -} from '@tanstack/compiler-test-utils/dev-server' +import { openPage } from '@tanstack/compiler-test-utils/browser' +import { describeHmr } from '@tanstack/compiler-test-utils/dev-server' import { startDevServer } from './dev-server' import type { DevServer } from '@tanstack/compiler-test-utils/dev-server' @@ -146,102 +146,121 @@ export const getRouter = () => ({ routeTree }) ) }) -describe('Vite dev: route HMR', () => { +describe('Vite dev: route HMR in a real page', () => { // Start's client code splitting adds route HMR (Start forces `addHmr` on - // the client): when the browser applies the hot update of an edited page - // route, the live route runs the new options and keeps its split - // component (no remount). The framework's refresh plugin, which makes the - // module an HMR boundary and patches the component body, is not mounted: - // the test applies the update the way Vite's HMR client does (the new - // module evaluates with the hot data of the old one, then the old one's - // accept callback gets it). + // the client). A real Vite dev server with Start's whole Vite plugin and + // `@vitejs/plugin-react` serves an app with the real router to a real page + // (headless Chromium), Vite's own watcher reports the edits, and Vite's + // HMR client, React Refresh and the route HMR code apply them. The edited + // page route shows its new loader data and component without a full + // reload, and its split component keeps its state (no remount). + // The page is the app's `index.html`, rendered on the client: Start's SSR + // document needs the real `@tanstack/react-start`, which depends on this + // package. Start's SSR with HMR in a real app: e2e/react-start/hmr. // Source: mutation round 3 start-router I/J (spc:6046). test( - 'a hot update of an edited page route applies its new options to the live route and keeps its split component', + 'an edited page route shows its new loader and component without a full reload, and its split component keeps its state', async () => { - const page = (marker: string) => - `${head}export const Route = createFileRoute('/page')({ loader: () => '${marker}', component: () => 'MARK_PAGE' })\n` + const page = (loader: string, text: string) => + `import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +function Page() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + return +} +export const Route = createFileRoute('/')({ loader: () => '${loader}', component: Page }) +` const server = await startDevServer({ start: {}, + realRouter: true, + browser: true, + // Pre-bundled up front, as Vite does for an app that installed them + // (the router is a linked workspace package here) + config: { + optimizeDeps: { + include: [ + 'react', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + '@tanstack/react-router', + ], + }, + }, + plugins: [react()], files: { - 'src/routes/page.tsx': page('MARK_LOADER_V1'), - 'src/router.ts': `import { routeTree } from './routeTree.gen' -export const getRouter = () => ({ routeTree }) + 'index.html': ` + + +
    + + + `, + 'src/main.tsx': `import { createRoot } from 'react-dom/client' +import { RouterProvider } from '@tanstack/react-router' +import { getRouter } from './router' +createRoot(document.getElementById('app')!).render() +`, + 'src/router.ts': `import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +export const getRouter = () => createRouter({ routeTree }) +`, + 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute() +`, + 'src/routes/index.tsx': page('loader v1', 'Page v1'), }, }) - /** The hot data of each module, and the accept callbacks. */ - const hotData = new Map>() - const accepted: Array<(module: unknown) => void> = [] - const viteClient = { - createHotContext: (ownerPath: string) => { - const data = hotData.get(ownerPath) ?? {} - hotData.set(ownerPath, data) - return { - data, - accept: (callback?: (module: unknown) => void) => { - if (typeof callback === 'function') { - accepted.push(callback) - } - }, - acceptExports() {}, - dispose() {}, - prune() {}, - invalidate() {}, - on() {}, - off() {}, - send() {}, + const fullReloads = () => + server.hmr.filter( + (message) => + message.environment === 'client' && message.type === 'full-reload', + ) + /** Waits until the dev server sent no HMR message for a while. */ + const quiet = async () => { + let count = server.hmr.length + let since = Date.now() + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 25)) + if (server.hmr.length !== count) { + count = server.hmr.length + since = Date.now() + } else if (Date.now() - since >= 300) { + await server.idle() + return } - }, - } - /** Evaluates the served page route module, as the browser loads it. */ - const loadPage = async () => - ( - await evaluateServed(server, '/src/routes/page.tsx', { - skip: /^\/@vite\b/, - stubs: { '/@vite/client': viteClient }, - }) - ).Route as { - options: { loader: () => unknown; component: unknown } - } - const global = globalThis as Record - const hadWindow = 'window' in global - const browser: Record = {} - global.window = browser - onTestFinished(() => { - if (!hadWindow) { - delete global.window } + } + const browser = await openPage(server.url!, { + registerCleanup: onTestFinished, }) + const shows = (text: string) => + browser.waitForText(text, { selector: '#app' }) + await shows('Page v1 loader v1 count 0') + await quiet() + await browser.page.locator('#app button').click() + await browser.page.locator('#app button').click() + await shows('Page v1 loader v1 count 2') - const live = await loadPage() - const component = live.options.component - // The router the page created (`window.__TSR_ROUTER__`) - browser.__TSR_ROUTER__ = { - routesById: { '/page': live }, - _replaceRouteChunk() {}, - buildRouteTree() {}, - setRoutes() {}, - resolvePathCache: new Map(), - } - expect(live.options.loader()).toBe('MARK_LOADER_V1') + // Route options outside the split component change + await server.write({ + 'src/routes/index.tsx': page('loader v2', 'Page v1'), + }) + await shows('Page v1 loader v2 count 2') + await quiet() - const messages = await server.edit({ - 'src/routes/page.tsx': page('MARK_LOADER_V2'), + // The split component changes + await server.write({ + 'src/routes/index.tsx': page('loader v2', 'Page v2'), }) - // An edit of a client route module: the client is told - expect( - messages.filter((message) => message.environment === 'client'), - ).not.toEqual([]) - const callbacks = [...accepted] - const updated = await loadPage() - for (const callback of callbacks) { - callback({ Route: updated }) - } + await shows('Page v2 loader v2 count 2') + await quiet() - expect(callbacks).not.toEqual([]) - expect(live.options.loader()).toBe('MARK_LOADER_V2') - expect(live.options.component).toBe(component) + expect(browser.loads()).toBe(1) + expect(describeHmr(fullReloads())).toEqual([]) + expect(browser.errors).toEqual([]) }, timeout, ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2310f44cf09..97540558455 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15419,6 +15419,9 @@ importers: '@tanstack/compiler-test-utils': specifier: workspace:* version: link:../compiler-test-utils + '@tanstack/react-router': + specifier: workspace:* + version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) @@ -15434,9 +15437,18 @@ importers: '@types/picomatch': specifier: ^4.0.2 version: 4.0.2 + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) publint: specifier: 'catalog:' version: 0.3.17 + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) rimraf: specifier: 'catalog:' version: 6.1.2 From ac3f8d5ac973095d58c982549ad92fbb1a04af2a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 14:09:35 +0200 Subject: [PATCH 0406/1425] test(router-plugin): React Refresh outcomes in a real page; delete the refresh runtime model User ruling 2026-10-18: no copies of React HMR logic in tests. Deleted (copies of React Refresh / plugin-react behaviour): - regression-helpers `isRefreshable` / `expectRefreshableRouteOption`: modelled how React Refresh's runtime resolves memo / forwardRef to a registered family. - react-refresh-route-components "a React route module compiled for Vite stays a Fast Refresh boundary" (6 tests): evaluated the module twice with one hot context and called plugin-react's validateRefreshBoundaryAndEnqueueUpdate by hand, as its accept glue does. - react-refresh-route-components "React Refresh registers split route components" (memo / forwardRef rows) and "hoists the components of the module's Route when another route is created first". - known-bugs-route-hmr "React Refresh registration": the memo(Page) control, the "registers an inline component written as $name" pins and "registers a method-shorthand component of a split route". Replacements on the real page harness (route-hmr-page.ts, Vite + @vitejs/plugin-react, headless Chromium): - route-hmr-browser: a route without a component and a root route with an inline stateful component apply their edits in place (state kept, no reload), autoCodeSplitting on and off; split components written inline in memo / forwardRef calls, and inline components after another route the module creates (unsplit component, and the route module's pendingComponent when split), show their edits without a reload. `extra` is no longer exported there: an exported route object makes plugin-react reload the page (React Refresh boundary rule), which was not what the source test was about. - known-bugs-route-hmr "route HMR in a real page": control memo(Page) unsplit; pins (each verified red by one run without .fails: the page keeps "inline v1" / "root v1", no reload) for `as any`, `satisfies`, memo(() => ...), forwardRef(function ...), method shorthand (unsplit and split) and React.memo(forwardRef(...)) on a root route. Co-Authored-By: Claude Opus 5.5 --- .../tests/known-bugs-route-hmr.test.ts | 253 ++++++++++-------- .../react-refresh-route-components.test.ts | 175 +----------- .../router-plugin/tests/regression-helpers.ts | 43 --- .../tests/route-hmr-browser.test.ts | 156 +++++++++++ 4 files changed, 310 insertions(+), 317 deletions(-) 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 fba5c9bc305..8a68e702e1c 100644 --- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts @@ -15,13 +15,10 @@ import { describe, expect, test } from 'vitest' import type { TestContext } from 'vitest' import { build, routeApp } from './build-harness' import { - compileRouteModules, evaluateModule, - expectRefreshableRouteOption, expectValidModules, head, hotUpdate, - isRefreshable, loadRoute, routeSpecifier, transformWithRouteHmrPlugin, @@ -31,86 +28,9 @@ import { editRouteFile } from './vite-hot-update' import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' import type { PluginOption } from 'vite' -/** Compiles every module the code splitter emits, with React HMR. */ -function compileWithCodeSplitting(code: string) { - return compileRouteModules(code, { hmr: true }).modules -} - describe('React Refresh registration', () => { - // Control for the inline component pins below (same assertion). - test('a memo of a top-level component is refreshable', async () => { - await expectRefreshableRouteOption( - transformWithRouteHmrPlugin(`${head}import { memo } from 'react' -function Page() { - return

    hi

    -} -export const Route = createFileRoute('/')({ component: memo(Page) })`), - 'component', - ) - }) - - // Bug: inline component options that are not plain functions (`memo(...)`, - // `forwardRef(...)`, method shorthand, a function wrapped in a TypeScript - // `as` / `satisfies`) are neither hoisted nor registered, nor is the - // function they wrap. - // Impact: on unsplit routes (root routes, or file routes without automatic - // code splitting), edits to these components are dropped until a reload. - // Source: ReactFreshBabelPlugin-test "registers likely HOCs with inline - // functions" and "registers top-level variable declarations with arrow - // functions"; #8504 react-refresh-route-components ("written inline and - // wrapped in ..."), where the wrapped rows pass. - test.fails.each([ - ...[ - ['as any', `(() =>

    hi

    ) as any`], - ['satisfies', `(() =>

    hi

    ) satisfies FC`], - ].map(([wrapper, component]) => ({ - name: `a function wrapped in ${wrapper}`, - code: `${head}import type { FC } from 'react' -export const Route = createFileRoute('/')({ component: ${component} })`, - compile: transformWithRouteHmrPlugin, - })), - { - name: 'memo(() => ...)', - code: `${head}import { memo } from 'react' -export const Route = createFileRoute('/')({ component: memo(() =>

    hi

    ) })`, - compile: transformWithRouteHmrPlugin, - }, - { - name: 'forwardRef(function () { ... })', - code: `${head}import { forwardRef } from 'react' -export const Route = createFileRoute('/')({ - component: forwardRef(function (props, ref) { - return

    hi

    - }), -})`, - compile: transformWithRouteHmrPlugin, - }, - { - name: 'method shorthand', - code: `${head}export const Route = createFileRoute('/')({ - component() { - return

    hi

    - }, -})`, - compile: transformWithRouteHmrPlugin, - }, - { - name: 'React.memo(forwardRef(...)) on a root route', - code: `import React, { forwardRef } from 'react' -import { createRootRoute } from '@tanstack/react-router' -export const Route = createRootRoute({ - component: React.memo(forwardRef(function Root(props, ref) { - return

    root

    - })), -})`, - compile: (code: string) => compileWithCodeSplitting(code).reference!, - }, - ])( - 'registers an inline component written as $name', - async ({ code, compile }) => { - await expectRefreshableRouteOption(compile(code), 'component') - }, - ) + // Inline components that are not plain functions (`memo(...)`, ...): + // pinned in a real page below ("route HMR in a real page"). /** * Loads, with React HMR and React Refresh, a route whose `component` option @@ -130,30 +50,6 @@ export const Route = createFileRoute('/')({ component: ${name} })`, return { binding, registered: [...route.registered.keys()] } } - // Goal (split path of the method-shorthand row above): a method-shorthand - // component is refreshable whether the code splitter splits it or keeps it - // in the route module. - // Bug: same root cause as the method-shorthand row above. - // Source: ruling on #8721 "keeps a method-shorthand component option" - // (react-refresh-route-components.test.ts, loosened). - test.fails( - 'registers a method-shorthand component of a split route', - async () => { - const route = await loadRoute( - `${head}export const Route = createFileRoute('/')({ - component() { - return

    hi

    - }, -})`, - { hmr: true, refresh: true }, - ) - await expectValidModules(route.modules) - const component = await route.load('component') - expect(component()).toBe('

    hi

    ') - expect(isRefreshable(component, route.registered)).toBe(true) - }, - ) - // Source: add-hmr snapshot fixture createFileRoute-lowercase-components: // with the default groupings, the lowercase `component` and // `errorComponent` are split and `pendingComponent` stays in the route @@ -641,6 +537,151 @@ export const Route = createFileRoute('/')({ }) ` + /** + * Opens a React app in Vite whose route file `file` is `route('v1')`, + * waits until it shows `shows('v1')`, edits it to `route('v2')` and + * returns whether the page reloaded once it shows `shows('v2')` (waiting + * at most `timeout`). + */ + async function editComponent( + { + file, + autoCodeSplitting, + route, + shows, + }: { + file: string + autoCodeSplitting: boolean + route: (version: string) => string + shows: (version: string) => string + }, + onTestFinished: TestContext['onTestFinished'], + timeout?: number, + ) { + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { [file]: route('v1') }, + ready: shows('v1'), + }) + return app.edit({ [file]: route('v2') }, shows('v2'), { timeout }) + } + + /** A route at `/` with the `component` option `option`, after `imports`. */ + const inlineRoute = + (imports: string, option: (version: string) => string) => + (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +${imports} +export const Route = createFileRoute('/')({ + ${option(version)}, +}) +` + + // Control for the inline component pins below (same harness). + test('vite: an edit of a component passed as memo(Page) shows in place (autoCodeSplitting: false)', async ({ + expect, + onTestFinished, + }) => { + expect( + await editComponent( + { + file: 'routes/index.tsx', + autoCodeSplitting: false, + route: inlineRoute( + `import { memo } from 'react' +function Page() { + return

    page v1

    +}`, + () => 'component: memo(Page)', + ), + shows: () => 'page v1', + }, + onTestFinished, + ), + ).toEqual({ reloaded: false }) + }) + + // Bug: inline component options that are not plain functions (`memo(...)`, + // `forwardRef(...)`, method shorthand, a function wrapped in a TypeScript + // `as` / `satisfies`) are neither hoisted nor registered for React + // Refresh, nor is the function they wrap; route HMR keeps the previous + // component for React Refresh to patch, which it cannot. + // Impact: on unsplit routes (root routes, or file routes without automatic + // code splitting), and for a split method-shorthand component, edits to + // these components do not show until a reload. + // Source: ReactFreshBabelPlugin-test "registers likely HOCs with inline + // functions" and "registers top-level variable declarations with arrow + // functions"; #8504 react-refresh-route-components ("written inline and + // wrapped in ..."), where the wrapped rows pass; ruling on #8721 "keeps a + // method-shorthand component option" (split row). Formerly read React + // Refresh's registrations and modelled its runtime (ruling 2026-10-18). + // Any fix passes: the edit shows without a full reload. + test.fails.for([ + ...[ + ['as any', '', `(() =>

    inline VERSION

    ) as any`], + [ + 'satisfies', + `import type { FC } from 'react'`, + `(() =>

    inline VERSION

    ) satisfies FC`, + ], + [ + 'memo(() => ...)', + `import { memo } from 'react'`, + `memo(() =>

    inline VERSION

    )`, + ], + [ + 'forwardRef(function () { ... })', + `import { forwardRef } from 'react'`, + `forwardRef(function (props, ref) { + return

    inline VERSION

    + })`, + ], + ].map(([name, imports, component]) => ({ + name: name!, + file: 'routes/index.tsx', + autoCodeSplitting: false, + route: inlineRoute( + imports!, + (version) => `component: ${component!.replace('VERSION', version)}`, + ), + shows: (version: string) => `inline ${version}`, + })), + ...[false, true].map((autoCodeSplitting) => ({ + name: 'method shorthand', + file: 'routes/index.tsx', + autoCodeSplitting, + route: inlineRoute( + '', + (version) => `component() { + return

    inline ${version}

    + }`, + ), + shows: (version: string) => `inline ${version}`, + })), + { + name: 'React.memo(forwardRef(...)) on a root route', + file: 'routes/__root.tsx', + autoCodeSplitting: true, + route: (version: string) => `import React, { forwardRef } from 'react' +import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({ + component: React.memo(forwardRef(function Root(props, ref) { + return

    root ${version}

    + })), +}) +`, + shows: (version: string) => `root ${version}`, + }, + ])( + 'vite: an edit of an inline component written as $name shows in place (autoCodeSplitting: $autoCodeSplitting)', + async (row, { expect, onTestFinished }: TestContext) => { + expect(await editComponent(row, onTestFinished, pinTimeout)).toEqual({ + reloaded: false, + }) + }, + ) + // Bug: route HMR keeps the previous split components only for React: for // Solid and Vue, the route module creates a new lazy component on every // update and `handleRouteUpdate` keeps no component option 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 b6476c1ffcf..17d31d10c64 100644 --- a/packages/router-plugin/tests/react-refresh-route-components.test.ts +++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts @@ -6,10 +6,7 @@ * transform (the one `@vitejs/plugin-react` uses) on the output to see which * components are registered and how their hook signatures are computed. */ -import { createRequire } from 'node:module' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import { expectExportedRouteOptions, @@ -20,13 +17,10 @@ import { compileRouteModules, evaluateModule, evaluateRouteModule, - expectRefreshableRouteOption, expectRegisteredRouteOption, - expectValidModules, exportedNames, getRouteOption, head, - isRefreshable, loadRoute, reactRefresh, routeFile, @@ -264,32 +258,8 @@ export const Route = createFileRoute('/')({ }) }) -describe('React Refresh registers split route components', () => { - // Source: ReactFreshBabelPlugin-test "registers likely HOCs with inline - // functions"; #8504 react-refresh-route-components ("written inline in a - // memo / forwardRef call"). The bodies have no JSX: with JSX, the code - // splitter cannot split the call (pinned in known-bugs-code-splitter.test.ts - // "a component written as memo of an inline arrow is split and renders"). - it.each([ - ['memo', `import { memo } from 'react'`, `memo(() => 'hi')`], - [ - 'forwardRef', - `import { forwardRef } from 'react'`, - `forwardRef(function (props, ref) {\n return 'hi'\n})`, - ], - ])('written inline in a %s call', async (_, imports, component) => { - const route = await loadRoute( - `${head}${imports} -export const Route = createFileRoute('/')({ component: ${component} })`, - { hmr: true, refresh: true }, - ) - await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect(isRefreshable(await route.load('component'), route.registered)).toBe( - true, - ) - }) -}) +// Split route components written inline in a memo / forwardRef call: +// route-hmr-browser.test.ts (a real page). describe('React Refresh hoisting of inline route components', () => { it('hoists the root route component but not the components of routes a function creates', async () => { @@ -313,143 +283,12 @@ export const Route = createRootRoute({ component: () =>
    }) await expectRegisteredRouteOption(reference!, 'component') }) - // Source: #8504 react-refresh-route-components "hoists the components of - // the module's Route when another route is created first". - it("hoists the components of the module's Route when another route is created first", async () => { - const code = `import { createFileRoute, createRoute } from '@tanstack/react-router' -export const extra = createRoute({ - getParentRoute: () => Route, - path: 'extra', - component: () =>

    extra

    , + // The components of the module's Route when another route is created + // first: route-hmr-browser.test.ts (a real page). }) -export const Route = createFileRoute('/')({ - component: () =>

    home

    , - pendingComponent: () =>

    pending

    , -})` - await expectRefreshableRouteOption( - transformWithRouteHmrPlugin(code), - 'component', - ) - // The split `component` loads from its chunk; the route module keeps - // `pendingComponent`. - await expectRefreshableRouteOption( - compileWithCodeSplitting(code).reference!, - 'pendingComponent', - ) - }) -}) - -/** - * `@vitejs/plugin-react` ends every module it compiles with React Refresh - * with a hot accept callback that runs its refresh runtime's - * `validateRefreshBoundaryAndEnqueueUpdate(id, previousExports, nextExports)` - * and invalidates the module (a full page reload) when it returns a message. - * A route module's `Route` export is a new object on every update, so the - * route module must have the runtime ignore it - * (`window.__getReactRefreshIgnoredExports`) and export at least one - * component. - */ -describe('a React route module compiled for Vite stays a Fast Refresh boundary', () => { - const refreshRuntimeUrl = pathToFileURL( - path.join( - path.dirname( - createRequire(import.meta.url).resolve('@vitejs/plugin-react'), - ), - 'refresh-runtime.js', - ), - ).href - /** - * Loads the compiled route module twice with one Vite hot context, as a - * hot update does, and returns what plugin-react's refresh runtime decides - * for the update (`undefined`: refreshed in place), the exports the - * runtime does not ignore and whether React Refresh registered each. - */ - async function refreshUpdate(code: string, id: string) { - const window: { - __getReactRefreshIgnoredExports?: (ctx: { id: string }) => Array - } = {} - vi.stubGlobal('window', window) - try { - const runtime = await import(/* @vite-ignore */ refreshRuntimeUrl) - const hot = { - data: {}, - accept() {}, - dispose() {}, - invalidate() {}, - on() {}, - } - const previous = await evaluateRouteModule( - code, - {}, - { importMeta: { hot } }, - ) - const next = await evaluateRouteModule(code, {}, { importMeta: { hot } }) - const invalidation: unknown = - runtime.validateRefreshBoundaryAndEnqueueUpdate( - id, - previous.exports, - next.exports, - ) - const ignored = window.__getReactRefreshIgnoredExports?.({ id }) ?? [] - const components = Object.fromEntries( - Object.keys(next.exports) - .filter((name) => !ignored.includes(name)) - .map((name) => [name, next.registered.has(next.exports[name])]), - ) - return { invalidation, components } - } finally { - vi.unstubAllGlobals() - } - } - - const routes = [ - { - name: 'a route', - route: `${head}function Page() { - return

    page

    -} -export const Route = createFileRoute('/')({ component: Page })`, - }, - { - name: 'a route with an undefined component', - route: `${head}export const Route = createFileRoute('/')({ component: undefined })`, - }, - { - name: 'a root route', - route: `import { createRootRoute } from '@tanstack/react-router' -export const Route = createRootRoute({ component: () =>
    root
    })`, - }, - ] - - describe.each([ - { - compiler: 'automatic code splitting', - compile: (route: string) => { - const { modules, ids } = compileRouteModules(route, { hmr: true }) - return { code: modules.reference!, id: ids.reference! } - }, - }, - { - compiler: 'the route HMR plugin', - compile: (route: string) => ({ - code: transformWithRouteHmrPlugin(route), - id: routeFile('index'), - }), - }, - ])('with $compiler', ({ compile }) => { - it.each(routes)('$name', async ({ route }) => { - const { code, id } = compile(route) - const { invalidation, components } = await refreshUpdate(code, id) - expect(invalidation).toBeUndefined() - // Every export the runtime does not ignore is a registered component. - expect(Object.keys(components).length).toBeGreaterThan(0) - for (const [name, registered] of Object.entries(components)) { - expect(registered, name).toBe(true) - } - }) - }) -}) +// A route module stays a React Refresh boundary in Vite: +// route-hmr-browser.test.ts (a real page). describe('route HMR leaves options React Refresh does not handle as they are', () => { // Loosened: the method keeps working. Being refreshable is the goal pinned diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index 3b325327255..b4b1553c0e5 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -1182,49 +1182,6 @@ export async function expectRegisteredRouteOption( return name! } -const memoType = Symbol.for('react.memo') -const forwardRefType = Symbol.for('react.forward_ref') - -/** - * Asserts that React Refresh can hot-update the component of `option`: the - * option is a component React Refresh registers, or a `memo(...)` / - * `forwardRef(...)` (nested or not) of one, since React Refresh resolves - * those wrappers through the function they wrap. - */ -export async function expectRefreshableRouteOption( - code: string, - option: string, -) { - expect(await getModuleErrors(code)).toEqual([]) - const { Route, registered } = await evaluateRouteModule(code) - expect( - isRefreshable(Route.options[option], registered), - `\`${option}\` is neither a component React Refresh registers nor a memo/forwardRef of one (registered: ${[...registered.values()].join(', ')})`, - ).toBe(true) -} - -/** - * Whether React Refresh can hot-update `component`: it is registered - * (`registered`, as `loadRoute` and `hotUpdate` record it), or it is a - * `memo(...)` / `forwardRef(...)` (nested or not) of a registered component. - */ -export function isRefreshable( - component: unknown, - registered: Map, -) { - let current: any = component - while (!registered.has(current) && current && typeof current === 'object') { - if (current.$$typeof === memoType) { - current = current.type - } else if (current.$$typeof === forwardRefType) { - current = current.render - } else { - return false - } - } - return registered.has(current) -} - const getStoreConfig: GetStoreConfig = () => ({ createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, diff --git a/packages/router-plugin/tests/route-hmr-browser.test.ts b/packages/router-plugin/tests/route-hmr-browser.test.ts index f6cd232c0d4..e43f8189ff1 100644 --- a/packages/router-plugin/tests/route-hmr-browser.test.ts +++ b/packages/router-plugin/tests/route-hmr-browser.test.ts @@ -455,6 +455,162 @@ export const Route = createFileRoute('/')(makeOptions('${version}')) }, ) +// A React route module stays an HMR boundary of React Refresh in Vite +// (`@vitejs/plugin-react` reloads the page when a module it compiled +// exports anything but components, beyond the exports the route HMR code +// has it ignore), also without a component of its own or as the root route. +// Sources: react-refresh-route-components "a React route module compiled +// for Vite stays a Fast Refresh boundary" (it called the refresh runtime's +// boundary check by hand; ruling 2026-10-18). +describe.concurrent( + 'vite: a React route module without a component, and a root route with an inline component', + () => { + it.for([true, false])( + 'apply their edits in place (autoCodeSplitting: %s)', + { timeout }, + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + const root = (version: string) => + `import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router' +import { useState } from 'react' +export const Route = createRootRoute({ + component: () => { + const [count, setCount] = useState(0) + const data = useRouterState({ + select: (state) => state.matches.map((match) => match.loaderData ?? '').join(''), + }) + return ( +
    + + +
    + ) + }, +}) +` + const index = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: () => 'index ${version}', + component: undefined, +}) +` + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { + 'routes/__root.tsx': root('v1'), + 'routes/index.tsx': index('v1'), + }, + ready: 'root v1 index v1 count 0', + }) + await click(app, 'root v1 index v1 count', 1) + expect( + await app.edit( + { 'routes/index.tsx': index('v2') }, + 'root v1 index v2 count 1', + ), + ).toEqual({ reloaded: false }) + expect( + await app.edit( + { 'routes/__root.tsx': root('v2') }, + 'root v2 index v2 count 1', + ), + ).toEqual({ reloaded: false }) + }, + ) + }, +) + +// React Refresh patches route components that are not plain declarations +// in place. Sources: react-refresh-route-components "React Refresh registers +// split route components" (ReactFreshBabelPlugin-test "registers likely +// HOCs with inline functions"; #8504 "written inline in a memo / forwardRef +// call") and "hoists the components of the module's Route when another +// route is created first" (#8504), which read React Refresh's registrations +// and modelled how its runtime resolves memo / forwardRef (ruling +// 2026-10-18). The split bodies have no JSX: with JSX, the code splitter +// cannot split the call (pinned in known-bugs-code-splitter.test.ts "a +// component written as memo of an inline arrow is split and renders"). +// Unsplit inline memo / forwardRef components: pinned in +// known-bugs-route-hmr.test.ts. +describe.concurrent('vite: an edited React route component written', () => { + const rows = [ + { + name: 'inline in a memo call, split', + autoCodeSplitting: true, + route: (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +import { memo } from 'react' +export const Route = createFileRoute('/')({ component: memo(() => 'inline ${version}') }) +`, + shows: (version: string) => `inline ${version}`, + }, + { + name: 'inline in a forwardRef call, split', + autoCodeSplitting: true, + route: (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +import { forwardRef } from 'react' +export const Route = createFileRoute('/')({ + component: forwardRef(function (props, ref) { + return 'inline ${version}' + }), +}) +`, + shows: (version: string) => `inline ${version}`, + }, + // Split: the route module keeps `pendingComponent`. (`extra` is not + // exported: `@vitejs/plugin-react` reloads the page on an edit of a + // module that exports anything but components and the exports route HMR + // has it ignore.) + ...[false, true].map((autoCodeSplitting) => ({ + name: `inline, after another route the module creates (autoCodeSplitting: ${autoCodeSplitting})`, + autoCodeSplitting, + route: (version: string) => + `import { createFileRoute, createRoute } from '@tanstack/react-router' +const extra = createRoute({ + getParentRoute: () => Route, + path: 'extra', + component: () =>

    extra

    , +}) +export const Route = createFileRoute('/')({ + ${ + autoCodeSplitting + ? `loader: () => new Promise(() => {}), + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () =>

    pending ${version}

    ,` + : `component: () =>

    home ${version}

    ,` + } +}) +`, + shows: (version: string) => + autoCodeSplitting ? `pending ${version}` : `home ${version}`, + })), + ] + it.for(rows)( + '$name shows in place', + { timeout }, + async ( + { autoCodeSplitting, route, shows }, + { expect, onTestFinished }: TestContext, + ) => { + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': route('v1') }, + ready: shows('v1'), + }) + expect( + await app.edit({ 'routes/index.tsx': route('v2') }, shows('v2')), + ).toEqual({ reloaded: false }) + }, + ) +}) + // Solid and Vue in Vite with their framework's plugin: an edit of a route // file shows its new code. Whether it does without a full reload, keeping // the split component (decision D10), is pinned in From ed455004fd0b2cea450f28dd0df83f66c255e87d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 14:42:06 +0200 Subject: [PATCH 0407/1425] test(compiler-test-utils): absolute app symlinks, ES module chunks, Temporal Additive harness changes for Start builds that bundle Start's real runtime: - createApp `symlinks` may point to an absolute path (a workspace package linked into the app's node_modules). Relative targets resolve as before. - describeBuild `esModules`: the chunks are ES modules (the bundler's `output.module`), even a chunk without import or export. The dangling-name check of an unminified Rspack/webpack client build minifies such a chunk as a module, like the bundler's own minimizer does. Before, a module chunk without import/export was minified as a script, so its unused top-level code (here seroval's `const RETURN = () => T`) stayed and was reported. - known globals: `Temporal` (browsers and Node.js 26 define it; the `globals` package 16.x does not list it yet). Start's serializer (seroval) revives Temporal values with it. Co-Authored-By: Claude Opus 5.5 --- .../compiler-test-utils/src/build-harness.ts | 27 ++++++++++++++----- .../compiler-test-utils/src/known-globals.ts | 8 ++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/compiler-test-utils/src/build-harness.ts b/packages/compiler-test-utils/src/build-harness.ts index cff89c62bbf..e32d110a650 100644 --- a/packages/compiler-test-utils/src/build-harness.ts +++ b/packages/compiler-test-utils/src/build-harness.ts @@ -89,7 +89,8 @@ export interface AppOptions { sideEffects?: boolean | Array /** * Symbolic links by path relative to the app root, each pointing to a path - * relative to the app root (e.g. a route file linked from elsewhere). + * relative to the app root (e.g. a route file linked from elsewhere) or to + * an absolute path (e.g. a workspace package linked into `node_modules`). */ symlinks?: Record /** Directory name prefix of the temporary app. */ @@ -296,7 +297,7 @@ export async function createApp(options: AppOptions): Promise { } for (const [link, target] of Object.entries(options.symlinks ?? {})) { await mkdir(path.dirname(path.join(root, link)), { recursive: true }) - await symlink(path.join(root, target), path.join(root, link)) + await symlink(path.resolve(root, target), path.join(root, link)) } } catch (error) { await dispose() @@ -865,6 +866,11 @@ export interface DescribeBuildInput { mode: BuildMode /** Whether the build minified its output (default: `mode === 'production'`). */ minify?: boolean + /** + * Whether the chunks are ES modules (the bundler's `output.module`), even + * a chunk that neither imports nor exports. Default: the chunks that do. + */ + esModules?: boolean /** App root on disk. */ root: string /** Absolute output directory the chunk file names are relative to. */ @@ -913,7 +919,12 @@ export async function describeBuild( input.mode === 'production' && bundler !== 'esbuild' ) { - await checkDanglingNames(bundler, chunks, input.minify ?? true) + await checkDanglingNames( + bundler, + chunks, + input.minify ?? true, + input.esModules, + ) } const entries = chunks.filter((chunk) => chunk.isEntry) if (entries.length !== 1) { @@ -1005,6 +1016,7 @@ async function checkDanglingNames( bundler: Exclude, chunks: Array, minified: boolean, + esModules: boolean | undefined, ) { const [{ unboundNames }, { clientGlobals }] = await Promise.all([ import('./validate-module'), @@ -1015,7 +1027,7 @@ async function checkDanglingNames( const code = minified || bundler === 'vite' ? chunk.code - : await minifyAsInProduction(bundler, chunk.code) + : await minifyAsInProduction(bundler, chunk.code, esModules) const names = [...unboundNames(code)].filter( (name) => !clientGlobals.has(name), ) @@ -1037,12 +1049,13 @@ async function checkDanglingNames( async function minifyAsInProduction( bundler: 'rspack' | 'webpack', code: string, + esModules: boolean | undefined, ): Promise { const { parseSync } = await import('vite') const { program } = parseSync('chunk.js', code, { sourceType: 'module' }) - const module = program.body.some((statement) => - /^(Import|Export)/.test(statement.type), - ) + const module = + esModules ?? + program.body.some((statement) => /^(Import|Export)/.test(statement.type)) if (bundler === 'rspack') { const { rspack } = await import('@rsbuild/core') const result = await rspack.experiments.swc.minify(code, { diff --git a/packages/compiler-test-utils/src/known-globals.ts b/packages/compiler-test-utils/src/known-globals.ts index cddd5fcb6c3..a87083dee34 100644 --- a/packages/compiler-test-utils/src/known-globals.ts +++ b/packages/compiler-test-utils/src/known-globals.ts @@ -100,6 +100,13 @@ const browserNames = [ */ const browserFamilies = /^(HTML|SVG|MathML)\w*Element$|^\w+Event$/ +/** + * Language globals the `globals` package (16.x) does not list yet: + * `Temporal`, which browsers and Node.js 26 define (Start's serializer, + * seroval, revives Temporal values with it). + */ +const languageNames = ['Temporal'] + /** Globals a framework's own code defines for itself, which its runtime reads. */ const frameworkNames = [ // the hydration state the script of Solid's `generateHydrationScript()` @@ -120,6 +127,7 @@ const frameworkNames = [ */ export const knownGlobals: ReadonlySet = new Set([ ...Object.keys(globals.builtin), + ...languageNames, ...Object.keys(globals.nodeBuiltin), ...browserNames, ...Object.keys(globals.browser).filter((name) => browserFamilies.test(name)), From 953b24f622464dc7cb09fe98de604198dd04e180 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 14:42:27 +0200 Subject: [PATCH 0408/1425] test(start-plugin-core): build and dev tests run Start's real runtime User ruling 2026-10-18 (test infrastructure must not re-implement other tools or packages). The build harness no longer writes a hand-written `@tanstack/react-start` into the app: `node_modules/@tanstack/react-start` links the built workspace package (`startRuntimeLinks`), and its dependencies (start-client-core, start-server-core, start-storage-context, router-core, ...) resolve from its real path. The `test:unit` target now depends on `@tanstack/react-start:build` (task-level dependsOn, no project dependency, so no cycle). The Vite and Rsbuild dev harnesses link it too. Stand-ins kept, only where the test process cannot run the real piece, each checked against the real runtime: - request context: SSR scripts and in-process server calls run in a `StartStorageContext` (typed against the real one) of a request to `/` (`inRequestContext`, `runInRequestContext`); contract test build-harness.test.ts "SSR scripts run in a request context that Start's runtime reads". - the network: a client call's requests are observed through the call's public `fetch` option (`requestsOf`); contract test "a server fn is Start's RPC on the client ...". - the server-fn HTTP handler: by-id calls resolve, call with `{ data, method }` and unwrap `{ result }`/`{ error }` like `handleServerAction` (`callByIdPrelude`, `callServerFnById`). - Vite dev client evaluation loads start-client-core through Node (`servedStartRuntime`): the root entry imports React (CommonJS), which only Vite's dependency optimizer (off in the harness) serves to a browser. - Rsbuild compiler-only dev server defines `TSS_SERVER_FN_BASE` like Start's plugin; its VM gets the WinterTC web APIs a browser has. - hydrate-build keeps its own `@tanstack/react-start` (Hydrate rendered by a string JSX runtime); new contract test: its names are the real package's. Harness realism: Vite builds run in the app's directory, as `vite build` does (Start's plugin crawls the working directory's package.json for Start libraries to bundle in SSR); the build harness records Rsbuild's `output.module` for the dangling-name check. Status changes, each investigated: - Tests that relied on the fake (`functionId`, client RPCs throwing "client RPC ", `getServerFnById(id)({})` returning the bare result, middleware `.server()` stubs, uncompiled `handler(rpc)` returning `rpc`): rewritten to Start's real API (`serverFnMeta.id`, `url`, requests the call sends, `{ result }` unwrapping). The library-server-fn tests passed vacuously on Vite: the library stayed external and uncompiled in SSR, and the stand-in's uncompiled `handler(fn)` returned `fn`. The apps now depend on their library, as installed ones. - Route-tree type test: the `@tanstack/react-start` d.ts stand-in is replaced by the real package's types. - Pins re-verified red for the same reason (with `.fails` removed, failure messages compared to baseline 5d35383f02): 123 pin rows across the build, dev and import-protection files are unchanged. Import-protection dev pins load only the app's modules (Start's server package imports Start's entries, which only Start's whole plugin provides). Library-function pin (D13): SSR now also shows the uncompiled server fn (`undefined` instead of the stand-in's handler result). - NEW pin (Vite): a build run from another directory leaves a Start library external and uncompiled in SSR (src/vite/plugin.ts:143 crawls `process.cwd()`, not Vite's root); verified red: the SSR call returns undefined. Rsbuild row passes. Cheap stand-ins replaced as well: createMiddleware.test.ts `middlewareFactoryStub`, known-bugs-start-compiler `middlewareStub` and the string stubs of its dev servers, start-compiler-factories' middleware stub now use Start's real builder (keeping a `.server()` trap where the test needs one). Co-Authored-By: Claude Opus 5.5 --- packages/start-plugin-core/package.json | 15 + .../tests/build-harness.test.ts | 78 ++++- .../start-plugin-core/tests/build-harness.ts | 312 +++++++++++------- .../createMiddleware/createMiddleware.test.ts | 15 +- .../start-plugin-core/tests/dev-server.ts | 32 +- .../tests/hydrate-build.test.ts | 47 ++- .../tests/importProtection/dev.test.ts | 6 +- .../tests/importProtection/known-bugs.test.ts | 8 +- .../tests/known-bugs-dev-server.test.ts | 16 +- .../tests/known-bugs-start-compiler.test.ts | 159 +++++---- .../start-build-options.test.ts | 25 +- .../tests/rsbuild-dev-server.ts | 101 +++++- .../tests/rsbuild/dev-server.test.ts | 44 ++- .../tests/start-build-determinism.test.ts | 20 +- .../tests/start-build-env-separation.test.ts | 70 ++-- .../tests/start-build-options.test.ts | 27 +- .../tests/start-compiler-dev-server.test.ts | 72 ++-- .../tests/start-compiler-factories.test.ts | 18 +- 18 files changed, 716 insertions(+), 349 deletions(-) diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index d21185a76c4..6a49bc940a2 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -40,6 +40,21 @@ "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", "build": "vite build" }, + "nx": { + "targets": { + "test:unit": { + "dependsOn": [ + "^build", + { + "projects": [ + "@tanstack/react-start" + ], + "target": "build" + } + ] + } + } + }, "type": "module", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", diff --git a/packages/start-plugin-core/tests/build-harness.test.ts b/packages/start-plugin-core/tests/build-harness.test.ts index 602d0aaf388..13cbc43c76f 100644 --- a/packages/start-plugin-core/tests/build-harness.test.ts +++ b/packages/start-plugin-core/tests/build-harness.test.ts @@ -7,7 +7,13 @@ */ import { describe, expect, test } from 'vitest' import { BuildError } from '@tanstack/compiler-test-utils/build-harness' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' +import { + buildStart, + callByIdPrelude, + requestsOfPrelude, + routerRuntimeFiles, + startBundlers, +} from './build-harness' const timeout = 60_000 @@ -68,7 +74,7 @@ describe.each(startBundlers)('%s', (bundler) => { ) test( - 'a server fn is an RPC by id on the client, and the SSR build resolves the same id through the generated resolver', + "a server fn is Start's RPC on the client: calling it fetches its URL, which carries its id; the SSR build runs the handler, directly and by that id", async () => { const { client, ssr } = await buildStart({ bundler, @@ -83,16 +89,27 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' }, entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, }) + // The client run has no server: `requestsOf` records the requests + // the call sends. const clientRpc = ( - await client.run(`try { - entry.greet() - return 'called' -} catch (error) { - return { id: entry.greet.functionId, message: error.message } + await client.run(`${requestsOfPrelude} +return { + id: entry.greet.serverFnMeta.id, + url: entry.greet.url, + requests: await requestsOf(entry.greet), }`) - ).result as { id: string; message: string } - expect(clientRpc.message).toBe(`client RPC ${clientRpc.id}`) - const { result } = await ssr.run(`const settle = async (run) => { + ).result as { + id: string + url: string + requests: Array<{ url: string; method: string }> + } + expect(clientRpc).toEqual({ + id: expect.any(String), + url: `/_serverFn/${clientRpc.id}`, + requests: [{ url: clientRpc.url, method: 'GET' }], + }) + const { result } = await ssr.run(`${callByIdPrelude} +const settle = async (run) => { try { return await run() } catch (error) { @@ -101,19 +118,52 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' } const id = ${JSON.stringify(clientRpc.id)} return { - sameId: entry.greet.functionId === id, - resolved: await settle(async () => (await entry.getServerFnById(id, { origin: 'server' }))()), - unknown: await settle(() => entry.getServerFnById('unknown-id', { origin: 'server' })), + sameId: entry.greet.serverFnMeta.id === id, + called: await settle(() => entry.greet()), + byId: await settle(() => callById(id)), + unknown: await settle(() => callById('unknown-id')), }`) expect(result).toEqual({ sameId: true, - resolved: 'hello', + called: 'hello', + byId: 'hello', unknown: expect.stringMatching(/not found/), }) }, timeout, ) + // The request context is the one piece of Start the harness stands in for + // (`inRequestContext`): Start's own runtime reads it. + test( + 'SSR scripts run in a request context that Start’s runtime reads', + async () => { + const { ssr } = await buildStart({ + bundler, + files: { + 'src/fns.ts': `import { createMiddleware, createServerFn } from '@tanstack/react-start' +const middleware = createMiddleware({ type: 'function' }).server( + ({ next, context }) => + next({ context: { fromMiddleware: 'MARK_MIDDLEWARE', given: context } }), +) +export const inspect = createServerFn() + .middleware([middleware]) + .handler(async ({ context, method }) => ({ method, context })) +`, + 'src/entry.ts': `export { inspect } from './fns'\n`, + }, + entry: 'src/entry.ts', + }) + // Without a request context, Start's runtime throws ("No Start + // context found"). + expect((await ssr.run(`return await entry.inspect()`)).result).toEqual({ + method: 'GET', + context: { fromMiddleware: 'MARK_MIDDLEWARE', given: {} }, + }) + }, + timeout, + ) + test( 'the route generator and code splitter run: the route tree links a route whose component loads on demand', async () => { diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index 4b1e848b458..ba5bbdb410a 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -11,20 +11,27 @@ * (`@tanstack/compiler-test-utils/build-harness`). The temporary apps live * in this directory. * - * The runtime packages are stand-ins written into the app - * (`startRuntimeFiles`, and `routerRuntimeFiles` for apps with routes): - * server functions run their handler, SSR callers go through the generated - * server fn resolver, client RPCs throw when called, server builds keep - * middleware as written, and a middleware's `.server()` throws on the client - * (the `browser` export condition). The app's entries are the harness's: the client and - * server entries Start builds import `#tanstack-start-entry` (`src/start.ts` - * when the app has one), `#tanstack-router-entry` (`src/router.ts`, a stub - * unless the app has one) and the test's entry module, whose exports `run()` - * gives the script as `entry`. + * `@tanstack/react-start` is the real package (`startRuntimeLinks`): both + * builds bundle and run Start's runtime. What the test process cannot do is + * stood in for, each checked against the real runtime in + * build-harness.test.ts: SSR scripts run in a request context + * (`inRequestContext`), a client script observes the requests a server fn + * call sends (`requestsOfPrelude`), and an SSR script calls a server fn by + * id like the server-fn HTTP handler (`callByIdPrelude`). + * `@tanstack/react-router` is a stand-in for apps with routes + * (`routerRuntimeFiles`). The app's entries are the harness's: the client + * and server entries Start builds import `#tanstack-start-entry` + * (`src/start.ts` when the app has one), `#tanstack-router-entry` + * (`src/router.ts`, a stub unless the app has one) and the test's entry + * module, whose exports `run()` gives the script as `entry`. Vite builds run + * in the app's directory, as `vite build` does. * * Import protection is on, with Start's defaults unless the test configures * it; it checks the modules of the source directory (`src`) only. */ +import { AsyncLocalStorage } from 'node:async_hooks' +import { existsSync } from 'node:fs' +import { mkdir, symlink } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -51,122 +58,173 @@ import type { } from '@tanstack/compiler-test-utils/build-harness' import type { TanStackStartInputConfig } from '../src/schema' import type { StartCompilerImportTransform } from '../src/types' +// The type of the real context (`@tanstack/start-storage-context` is no +// dependency of this package). +import type { StartStorageContext } from '../../start-storage-context/src/async-local-storage' import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core' import type { InlineConfig, Plugin } from 'vite' -const startPackage = 'node_modules/@tanstack/react-start' +/** + * The workspace's `@tanstack/react-start` package. Its build (`dist`) is + * what apps link: the `test:unit` target depends on its `build` target. + */ +export const reactStartPackage = fileURLToPath( + new URL('../../react-start', import.meta.url), +) /** - * Export targets under the `import` condition, the shape of the real - * package's exports. + * Links (`AppOptions.symlinks`) that give an app Start's real runtime: + * `node_modules/@tanstack/react-start` is the workspace package, so the app + * bundles and runs the built package with its own export conditions. Its + * dependencies (`@tanstack/start-client-core`, `@tanstack/start-server-core`, + * ...) resolve from its real path, as in an installed app. An app whose + * `files` bring their own `@tanstack/react-start` (hydrate-build.test.ts: + * `` rendered by a stand-in React) keeps it. */ -function importExports(targets: Record) { - return Object.fromEntries( - Object.entries(targets).map(([subpath, file]) => [ - subpath, - { import: { default: file } }, - ]), - ) +export function startRuntimeLinks( + files: Record = {}, +): Record { + if ('node_modules/@tanstack/react-start/package.json' in files) { + return {} + } + if (!existsSync(path.join(reactStartPackage, 'dist/esm/index.js'))) { + throw new Error( + `@tanstack/react-start is not built (no ${reactStartPackage}/dist): run the tests through Nx, whose test:unit target builds it first`, + ) + } + return { 'node_modules/@tanstack/react-start': reactStartPackage } } -/** The stand-in `@tanstack/react-start` package. */ -export const startRuntimeFiles: Record = { - [`${startPackage}/package.json`]: JSON.stringify({ - name: '@tanstack/react-start', - type: 'module', - sideEffects: false, - exports: { - // The client gets a `createMiddleware` whose `.server()` throws: - // the client build must not call it (Start removes it). - '.': { browser: './index.client.js', import: { default: './index.js' } }, - ...importExports({ - './client-rpc': './client-rpc.js', - './server-rpc': './server-rpc.js', - './ssr-rpc': './ssr-rpc.js', - './server': './server.js', - './server-only': './marker.js', - './client-only': './marker.js', - }), - }, - }), - [`${startPackage}/index.js`]: `const uncompiled = (name) => () => { - throw new Error('uncompiled Start factory: ' + name) +/** Creates the `startRuntimeLinks()` of an app made without `createApp`. */ +export async function linkStartRuntime(root: string) { + for (const [link, target] of Object.entries(startRuntimeLinks())) { + await mkdir(path.dirname(path.join(root, link)), { recursive: true }) + await symlink(target, path.join(root, link)) + } } -export function createServerFn() { - let validator - const builder = { - middleware: () => builder, - validator(value) { - validator = value - return builder - }, - handler(rpc, impl) { - if (!impl) { - return rpc - } - // Like the real runtime, the server runs the validator (a function) - // on the input before the handler. - const run = (opts = {}) => - impl(validator ? { ...opts, data: validator(opts.data) } : opts) - // Like the real runtime, the server fn carries what the extracted fn - // carries (its id). - const serverFn = Object.assign((opts) => run(opts ?? {}), rpc) - serverFn.__executeServer = (opts) => run(opts ?? {}) - return serverFn + +/** + * The request context (`StartStorageContext` of + * `@tanstack/start-storage-context`) the harness runs server code in: a + * request to `/` that ran no request middleware, with default start + * options, like Start's request handler opens for an app without + * `createStart`. It is the one piece of Start the harness stands in for: a + * test calls server code outside any request. Self-contained: SSR scripts + * run its source (`inRequestContext`). Checked against the real runtime in + * build-harness.test.ts. + */ +function testRequestContext(): StartStorageContext { + return { + request: new Request('http://localhost/'), + startOptions: {}, + contextAfterGlobalMiddlewares: {}, + executedRequestMiddlewares: new Set(), + handlerType: 'router', + getRouter: () => { + throw new Error('no router in the test request context') }, } - return builder } -export const createServerOnlyFn = uncompiled('createServerOnlyFn') -export const createClientOnlyFn = uncompiled('createClientOnlyFn') -export const createIsomorphicFn = uncompiled('createIsomorphicFn') -// Server builds keep middleware as written: \`options\` holds what each -// method of the chain received. -export function createMiddleware(options) { - const state = { ...options } - const builder = { options: state } - for (const method of ['middleware', 'validator', 'client', 'server']) { - builder[method] = (value) => { - state[method] = value - return builder - } + +/** Key of the storage of `@tanstack/start-storage-context`. */ +const startStorageKey = `Symbol.for('tanstack-start:start-storage-context')` + +/** + * Wraps a script of the SSR build's `run()` in the test request context + * (`testRequestContext`), opened like Start's request handler does + * (`runWithStartContext`: `run()` of the storage that the app's bundled + * `@tanstack/start-storage-context` shares on `globalThis`). + */ +export const inRequestContext = (script: string) => + `const storage = globalThis[${startStorageKey}] +const script = async () => { +${script} +} +if (!storage) { + return await script() +} +return await storage.run((${testRequestContext.toString()})(), script)` + +/** + * Runs `fn` in the test request context (`testRequestContext`), for server + * code a test imports from a build output in its own process. + */ +export function runInRequestContext(fn: () => T): T { + const global = globalThis as unknown as Record< + symbol, + AsyncLocalStorage | undefined + > + const storage = (global[ + Symbol.for('tanstack-start:start-storage-context') + ] ??= new AsyncLocalStorage()) + return storage.run(testRequestContext(), fn) +} + +/** + * Prelude of an SSR script whose entry exports `getServerFnById` of + * `#tanstack-start-server-fn-resolver`: `callById(id, data)` calls the + * server fn of `id` the way Start's server-fn HTTP handler does + * (`handleServerAction` of `@tanstack/start-server-core`: resolve the id, + * call it with the data the client sent, unwrap `{ result }` and throw + * `{ error }`), without the HTTP round trip and its serialization. + */ +export const callByIdPrelude = `const callById = async (id, data) => { + const fn = await entry.getServerFnById(id, { origin: 'client' }) + const response = await fn({ data, method: fn.method ?? 'GET' }) + if ('error' in response) { + throw response.error } - return builder + return response.result } -export const createStart = (getOptions) => ({ getOptions }) -`, - [`${startPackage}/index.client.js`]: `import { createMiddleware as createServerMiddleware } from './index.js' -export * from './index.js' -export function createMiddleware(options) { - const builder = createServerMiddleware(options) - builder.server = () => { - throw new Error('createMiddleware().server() called on the client') +` + +/** + * Calls the client's server fn `fn` (with `options`, e.g. its `data`) and + * gives the requests it sends. A test's client has no server: the call's + * `fetch` option (Start's public API) records each request and fails it. + * Self-contained: client scripts run its source (`requestsOfPrelude`). + */ +export async function requestsOf( + fn: (options: Record) => Promise, + options: Record = {}, +): Promise> { + const requests: Array<{ url: string; method: string | undefined }> = [] + const fetch = (url: string | URL, init?: RequestInit) => { + requests.push({ url: String(url), method: init?.method }) + return Promise.reject(new Error('no server in the client run')) } - return builder + await fn({ ...options, fetch }).catch(() => {}) + return requests } -`, - [`${startPackage}/client-rpc.js`]: `export const createClientRpc = (id) => - Object.assign( - () => { - throw new Error('client RPC ' + id) - }, - { functionId: id }, - ) -`, - [`${startPackage}/server-rpc.js`]: `export const createServerRpc = (meta, fn) => - Object.assign(fn, { meta, functionId: meta.id }) -`, - [`${startPackage}/server.js`]: `export const getRequest = () => ({ url: '/' }) -`, - [`${startPackage}/marker.js`]: `export {} -`, - [`${startPackage}/ssr-rpc.js`]: `import { getServerFnById } from '#tanstack-start-server-fn-resolver' -export const createSsrRpc = (id) => - Object.assign( - async (opts) => (await getServerFnById(id, { origin: 'server' }))(opts ?? {}), - { functionId: id }, - ) -`, + +/** Prelude of a client script: `requestsOf(fn, options)` (see above). */ +export const requestsOfPrelude = `const requestsOf = ${requestsOf.toString()} +` + +/** + * Calls the server fn of `id` in a test's own process the way Start's + * server-fn HTTP handler does (see `callByIdPrelude`), in the test request + * context (`runInRequestContext`): `getServerFnById` is the resolver of the + * server environment (`#tanstack-start-server-fn-resolver`). + */ +export async function callServerFnById( + getServerFnById: ( + id: string, + options: { origin: 'client' }, + ) => Promise<(options: Record) => Promise>, + id: string, + data?: unknown, +): Promise { + const fn = (await getServerFnById(id, { origin: 'client' })) as (( + options: Record, + ) => Promise) & { method?: string } + const response = (await runInRequestContext(() => + fn({ data, method: fn.method ?? 'GET' }), + )) as { result?: unknown; error?: unknown } + if ('error' in response) { + throw response.error + } + return response.result } const routerPackage = 'node_modules/@tanstack/react-router' @@ -276,6 +334,11 @@ export interface StartBuildOptions { * done, and describes the rebuild they cause (its errors reject). */ watchEdits?: Record + /** + * Vite builds: the working directory of the build (default: the app's, as + * `vite build` run in the app directory). + */ + cwd?: string } export interface StartBuild { @@ -348,11 +411,11 @@ export async function buildStart( packageJson: options.packageJson, sideEffects: options.sideEffects, files: { - ...startRuntimeFiles, ...harnessFiles(entries), ...(bundler === 'rsbuild' && testLoaderFiles), ...files, }, + symlinks: startRuntimeLinks(files), }) const mode = options.mode ?? 'production' const target: BuildTarget = { @@ -365,6 +428,7 @@ export async function buildStart( rsbuildConfig: options.rsbuildConfig, viteConfig: options.viteConfig, watchEdits: options.watchEdits, + cwd: options.cwd, } const warnings = { client: [], ssr: [] } as Record> const consoleMessages: Array = [] @@ -388,6 +452,7 @@ export async function buildStart( env, mode, minify: target.minify, + esModules: outputs[env].esModules, root: app.root, outDir: outputs[env].outDir, chunks: outputs[env].chunks, @@ -398,7 +463,11 @@ export async function buildStart( dispose: app.dispose, entryGlobal, }) - return { client: await describe('client'), ssr: await describe('ssr') } + const ssr = await describe('ssr') + return { + client: await describe('client'), + ssr: { ...ssr, run: (script = '') => ssr.run(inRequestContext(script)) }, + } } type Env = 'client' | 'ssr' @@ -413,9 +482,18 @@ interface BuildTarget { rsbuildConfig: RsbuildConfig | undefined viteConfig?: InlineConfig watchEdits?: Record + cwd?: string } -type Outputs = Record }> +type Outputs = Record< + Env, + { + outDir: string + chunks: Array + /** Whether the chunks are ES modules (Rsbuild's `output.module`). */ + esModules?: boolean + } +> const builders: Record< StartBundler, @@ -465,6 +543,11 @@ async function buildWithVite( warnings.client.push(stripAnsi(message)) warnings.ssr.push(stripAnsi(message)) } + // `vite build` runs in the app's directory: Start's plugin reads the + // dependencies of the working directory's package.json (Start libraries + // it bundles in SSR, `crawlFrameworkPkgs`). + const cwd = process.cwd() + process.chdir(target.cwd ?? target.root) try { const builder = await createBuilder( mergeConfig( @@ -515,6 +598,8 @@ async function buildWithVite( ), warnings.client, ) + } finally { + process.chdir(cwd) } return checkOutputs(outputs) } @@ -552,6 +637,7 @@ async function buildWithRsbuild( outputs[env] = { outDir: environment.distPath, chunks: chunksOfStats(json, { entry: 'index', extension: '.js' }), + esModules: environment.config.output.module, } }) }, diff --git a/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts b/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts index b62d2cd7c23..c125dbf141a 100644 --- a/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts +++ b/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts @@ -8,6 +8,7 @@ import { createStartCompiler, evaluateModule, importSources, + startClientCore, } from '../regression-helpers' import { declaredNames, @@ -31,11 +32,15 @@ const uncompiled = () => { } /** - * Start's own middleware factory module, stubbed so that calling - * `.server()` fails: a module evaluates only without it. + * Start's own middleware factory module: the real `createMiddleware`, whose + * builder fails the test when `.server()` is called, so a module evaluates + * only without it. */ const middlewareFactoryStub = { - createMiddleware: () => ({ server: uncompiled }), + createMiddleware: (...args: Array) => ({ + ...startClientCore.createMiddleware(...args), + server: uncompiled, + }), } describe('createMiddleware compiles correctly', () => { @@ -158,8 +163,8 @@ export const middleware = middlewareModule.createMiddleware().server(() => { expect(result).not.toBeNull() expect(result).not.toContain('server-only-middleware') - // The client runtime's `.server()` throws: the module evaluates only - // without it. + // `.server()` fails the test (`middlewareFactoryStub`): the module + // evaluates only without it. const { middleware } = await evaluateModule(result!, { './createMiddleware': middlewareFactoryStub, }) diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts index 892f4769e2b..41dff6077b4 100644 --- a/packages/start-plugin-core/tests/dev-server.ts +++ b/packages/start-plugin-core/tests/dev-server.ts @@ -3,8 +3,8 @@ * compiler's Vite plugin (client and SSR environments, SSR as the server fn * provider) on a temporary app, through the shared dev-server harness * (`@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. + * real package (`startRuntimeLinks`). 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 @@ -17,7 +17,13 @@ import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' import { onTestFinished } from 'vitest' import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { tanStackStartVite } from '../src/vite/plugin' -import { routerRuntimeFiles, startRuntimeFiles } from './build-harness' +import { + reactStartPackage, + routerRuntimeFiles, + startRuntimeLinks, +} from './build-harness' +import { createClientRpc, startClientCore } from './start-runtime' +import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' import type { StartCompilerPluginOptions } from '../src/vite/start-compiler-plugin/plugin' import type { TanStackStartInputConfig } from '../src/schema' import type { StartCompilerImportTransform } from '../src/types' @@ -29,6 +35,20 @@ import type { const harnessDir = path.dirname(fileURLToPath(import.meta.url)) +/** + * Start's real client runtime for evaluating served client modules + * (`evaluateServed(server, url, { stubs: servedStartRuntime })`): the + * entries of `@tanstack/react-start` a dev server serves to the client + * (`/@fs//dist/esm/...`), loaded by Node (start-runtime.ts) + * instead of from the dev server. The root entry imports React, which is + * CommonJS: a browser gets it from Vite's dependency optimizer, which the + * harness turns off. + */ +export const servedStartRuntime: Record = { + [`/@fs${reactStartPackage}/dist/esm/index.js`]: startClientCore, + [`/@fs${reactStartPackage}/dist/esm/client-rpc.js`]: { createClientRpc }, +} + export interface StartDevServerOptions extends Omit< DevServerOptions, 'dir' | 'plugins' @@ -129,7 +149,8 @@ export function startDevServer( prefix: '.start-dev-server-', registerCleanup: onTestFinished, ...devServerOptions, - files: { ...startRuntimeFiles, ...(start && startPluginFiles), ...files }, + files: { ...(start && startPluginFiles), ...files }, + symlinks: { ...startRuntimeLinks(files), ...devServerOptions.symlinks }, plugins: ({ root }) => [ start ? startPlugin(root, start, compilerTransforms) @@ -141,7 +162,8 @@ export function startDevServer( ...(typeof plugins === 'function' ? plugins({ root }) : plugins), { name: 'start-dev-server-harness', - // The stand-in package imports the resolver virtual module + // Start's SSR RPC (`createSsrRpc`) imports the resolver virtual + // module config: () => ({ ssr: { noExternal: ['@tanstack/react-start'] }, ...(extraEnvironments.length > 0 && { diff --git a/packages/start-plugin-core/tests/hydrate-build.test.ts b/packages/start-plugin-core/tests/hydrate-build.test.ts index 664b497e0ce..05d12cff938 100644 --- a/packages/start-plugin-core/tests/hydrate-build.test.ts +++ b/packages/start-plugin-core/tests/hydrate-build.test.ts @@ -6,7 +6,7 @@ */ import { afterAll, describe, expect, onTestFinished, test } from 'vitest' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { originalFrameOf } from '@tanstack/compiler-test-utils/build-harness' import { createDevServer, @@ -14,9 +14,9 @@ import { } from '@tanstack/compiler-test-utils/dev-server' import { buildStart, + reactStartPackage, routerRuntimeFiles, startBundlers, - startRuntimeFiles, } from './build-harness' import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { startDevServer } from './dev-server' @@ -27,11 +27,6 @@ import type { StartBuild } from './build-harness' const timeout = 60_000 -const startIndex = 'node_modules/@tanstack/react-start/index.js' -const startManifest = JSON.parse( - startRuntimeFiles['node_modules/@tanstack/react-start/package.json']!, -) - /** * Stand-ins of the packages a `` module imports: React's JSX * runtimes render intrinsic elements as tags, `Hydrate` records its boundary @@ -39,18 +34,22 @@ const startManifest = JSON.parse( * and `lazyRouteComponent(load, name)` renders the export `name` * (`exportName`) of the module it loaded (after `preload()`); every lazy * component is listed in `globalThis.__lazyComponents` - * (`renderThroughChunks`). + * (`renderThroughChunks`). `@tanstack/react-start` is a stand-in here too + * (`Hydrate` and `/hydration`, what these modules import): the real + * `Hydrate` renders with React and the real router, not with these JSX + * runtimes. */ const hydrateRuntimeFiles: Record = { 'node_modules/@tanstack/react-start/package.json': JSON.stringify({ - ...startManifest, + name: '@tanstack/react-start', + type: 'module', + sideEffects: false, exports: { - ...startManifest.exports, + '.': { import: { default: './index.js' } }, './hydration': { import: { default: './hydration.js' } }, }, }), - [startIndex]: `${startRuntimeFiles[startIndex]} -export const Hydrate = (props) => { + 'node_modules/@tanstack/react-start/index.js': `export const Hydrate = (props) => { ;(globalThis.__boundaryIds ??= []).push(props.h) return '[' + (props.children ?? '') + ']' } @@ -164,6 +163,30 @@ async function expectHydratesLikeServer( return server } +// Contract of the stand-in `@tanstack/react-start` (`hydrateRuntimeFiles`): +// each entry exports only names the real package's entry exports. +test.each([ + { entry: 'index.js', file: 'dist/esm/index.js' }, + { entry: 'hydration.js', file: 'dist/esm/hydration.js' }, +])( + 'the stand-in $entry exports names of the real package', + async ({ entry, file }) => { + const standIn = [ + ...hydrateRuntimeFiles[ + `node_modules/@tanstack/react-start/${entry}` + ]!.matchAll(/^export const (\w+)/gm), + ].map((match) => match[1]) + const real = Object.keys( + await import( + /* @vite-ignore */ pathToFileURL(path.join(reactStartPackage, file)) + .href + ), + ) + expect(standIn.length).toBeGreaterThan(0) + expect(real).toEqual(expect.arrayContaining(standIn)) + }, +) + describe.each(startBundlers)( '%s build: the code of the children', (bundler) => { diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/importProtection/dev.test.ts index 5ef1da49454..a99bfaf5f83 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/importProtection/dev.test.ts @@ -817,7 +817,11 @@ export const read = () => getRequest() behavior: 'error', onViolation: () => false, }) - const client = await server.load('/src/entry.ts') + // The app's modules: `@tanstack/react-start/server` itself imports + // Start's entries, which only Start's whole plugin provides. + const client = await server.load('/src/entry.ts', { + skip: /^\/@(vite|fs)\//, + }) expect(Object.keys(client)).toContain('/src/a.ts') expect(reportedViolations(server.logs)).toEqual([]) }, 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 363f06454b0..531295be11a 100644 --- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts @@ -306,10 +306,14 @@ describe('Vite dev, behavior "error"', () => { return app } - /** The violations loading the app reports, or `'loaded'`. */ + /** + * The violations loading the app's modules reports, or `'loaded'`. (Not + * the modules of `@tanstack/react-start/server`, which imports Start's + * entries, which only Start's whole plugin provides.) + */ async function loadOutcome(files: Record) { const server = await startProtectedDevServer(files, { behavior: 'error' }) - return server.load('/src/entry.ts').then( + return server.load('/src/entry.ts', { skip: /^\/@(vite|fs)\// }).then( () => 'loaded', (error: Error) => reportedViolations(error.message).map( 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 feb96ef5cb7..367a64cf2d5 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 @@ -8,7 +8,8 @@ import path from 'node:path' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { describe, expect, test } from 'vitest' import { importProtectionPlugin } from '../src/vite/import-protection-plugin/plugin' -import { startDevServer } from './dev-server' +import { callServerFnById, runInRequestContext } from './build-harness' +import { servedStartRuntime, startDevServer } from './dev-server' import { isMock } from './importProtection/mock-helpers' import type { ImportProtectionPluginOptions } from '../src/vite/import-protection-plugin/types' @@ -139,7 +140,7 @@ export const fn = base.handler(async () => db.x()) }, }) const before = (await server.import('/src/a.tsx')).fn - expect(before.functionId).toBeUndefined() + expect(before.serverFnMeta).toBeUndefined() await server.edit({ 'src/b.ts': `import { createServerFn } from '@tanstack/react-start' export const base = createServerFn() @@ -157,10 +158,10 @@ export const base = createServerFn() } } return { - id: typeof fn.functionId, - called: await settle(() => fn()), - byId: await settle(async () => - (await getServerFnById(fn.functionId, { origin: 'client' }))({}), + id: typeof fn.serverFnMeta?.id, + called: await settle(() => runInRequestContext(() => fn())), + byId: await settle(() => + callServerFnById(getServerFnById, fn.serverFnMeta?.id), ), } } @@ -257,10 +258,11 @@ export const fn = createServerFn().handler(async () => request) const modules = await server.load('/src/a.ts', { skip: /^\/@vite\b/ }) const { fn } = await evaluateServed(server, '/src/a.ts', { skip: /^\/@vite\b/, + stubs: servedStartRuntime, }) return { secret: modulesContaining(modules, 'MARK_REQUEST_SECRET'), - functionId: typeof fn.functionId, + functionId: typeof fn.serverFnMeta?.id, } } 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 d4e989ea7eb..6afa2d50713 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 @@ -8,7 +8,14 @@ import { performance } from 'node:perf_hooks' import { createServer, isRunnableDevEnvironment, transformWithOxc } from 'vite' import { beforeEach, describe, expect, test, vi } from 'vitest' import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' -import { buildStart, startBundlers } from './build-harness' +import { + buildStart, + callByIdPrelude, + callServerFnById, + linkStartRuntime, + startBundlers, +} from './build-harness' +import { servedStartRuntime } from './dev-server' import { callProvider, callReportedServerFns, @@ -339,11 +346,11 @@ export * from './d'`, } /** - * A middleware builder of the client runtime: `.server()` throws, as the - * client runtime's does when called uncompiled. + * A middleware builder of Start's real runtime whose `.server()` fails + * the test: the client must not call it (the compiler drops it). */ const middlewareStub = (options: unknown) => ({ - options, + ...startClientCore.createMiddleware(options), server: () => { throw new Error('uncompiled .server()') }, @@ -1701,9 +1708,11 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' entry: { client: 'client-entry.ts', ssr: 'ssr-entry.ts' }, }) expect(client.where("'server-only fn'")).toBe('nowhere') - const clientFnId = (await client.run(`return entry.clientFn.functionId`)) - .result - const { result } = await ssr.run(`const settle = async (run) => { + const clientFnId = ( + await client.run(`return entry.clientFn.serverFnMeta.id`) + ).result + const { result } = + await ssr.run(`${callByIdPrelude}const settle = async (run) => { try { return await run() } catch { @@ -1711,10 +1720,10 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' } } const overHttp = (id) => - settle(async () => (await entry.getServerFnById(id, { origin: 'client' }))({})) + settle(async () => callById(id)) return { clientFn: await overHttp(${JSON.stringify(clientFnId)}), - serverOnlyFnOverHttp: await overHttp(entry.serverOnlyFn.functionId), + serverOnlyFnOverHttp: await overHttp(entry.serverOnlyFn.serverFnMeta.id), serverOnlyFnFromSsr: await settle(() => entry.serverOnlyFn()), }`) return result @@ -1801,6 +1810,13 @@ export const outer = createServerFn().handler(async () => 'outer:' + (await inne async () => { const outcome = await buildStart({ bundler, + // The app depends on the library, as an installed one. + packageJson: { + name: 'app', + private: true, + type: 'module', + dependencies: { 'wrapper-lib': '*' }, + }, files: { 'node_modules/wrapper-lib/package.json': JSON.stringify({ name: 'wrapper-lib', @@ -1843,6 +1859,54 @@ export const fn = createLoggedServerFn(async () => db.secret()) timeout, ) + // Bug (Vite): Start's Vite plugin looks for the Start libraries it bundles + // in SSR (`crawlFrameworkPkgs`: dependencies with a peer dependency on + // Start) in the package.json of the working directory (`root: + // process.cwd()`), not of Vite's root, which the plugin uses for its own + // root a few lines above. + // Source: src/vite/plugin.ts:143 (Vite's root: :96); found when the build + // harness ran Start's real runtime (it ran Vite builds outside the app). + // Impact: `vite build ` run from another directory (a monorepo root, + // a script) leaves a Start library external in the SSR build: Node loads + // it uncompiled, and an SSR call of its server fn returns undefined + // instead of the handler's result. + // Control: start-build-env-separation.test.ts "a library's server fn" + // (the same library, built in the app's directory). + ;(bundler === 'vite' ? test.fails : test)( + "a build run from another directory: a library's server fn runs in the SSR build", + async () => { + const { ssr } = await buildStart({ + bundler, + // The directory of this test file, whose package.json is not the + // app's. + cwd: __dirname, + packageJson: { + name: 'app', + private: true, + type: 'module', + dependencies: { 'server-fn-lib': '*' }, + }, + files: { + 'node_modules/server-fn-lib/package.json': JSON.stringify({ + name: 'server-fn-lib', + type: 'module', + exports: './index.js', + peerDependencies: { '@tanstack/react-start': '*' }, + }), + 'node_modules/server-fn-lib/index.js': `import { createServerFn } from '@tanstack/react-start' +export const libFn = createServerFn().handler(async () => 'MARK_LIB_HANDLER') +`, + 'src/entry.ts': `export { libFn } from 'server-fn-lib'\n`, + }, + entry: 'src/entry.ts', + }) + expect((await ssr.run(`return await entry.libFn()`)).result).toBe( + 'MARK_LIB_HANDLER', + ) + }, + timeout, + ) + // Bug (Rsbuild): in the SSR build, compiling a module with an isomorphic // fn makes the Start compiler resolve the module's `createMiddleware` // import (the server lookup does not know that factory). The Rsbuild @@ -1927,29 +1991,14 @@ describe('Vite dev server', () => { return 'settled' } - /** Start packages of `transformClientModule`, by the names imported. */ - const clientStubs = (specifier: string, names: Set): ModuleStub => { - if (names.has('createClientRpc')) { - return { createClientRpc: (id: string) => ({ functionId: id }) } - } - if (names.has('createServerFn')) { - return { - createServerFn: () => ({ handler: (rpc: unknown) => rpc }), - } - } - if (names.has('z')) { - return { z: { object: () => ({}) } } - } - throw new Error(`no stub for ${specifier}`) - } - /** * Starts a Vite dev server (middleware mode, empty dependency cache) on a - * temporary project with the Start compiler, `src/fn.ts` = `code` and - * `files`, and requests the client transform of `src/fn.ts`. Returns - * `'hang'` when the transform stops making progress before it finishes - * (`settledOrHung`); otherwise its code and the module evaluated with - * stubbed Start packages and `zod`. Start packages are stubbed. + * temporary project with the Start compiler, Start's real runtime + * (`linkStartRuntime`), `src/fn.ts` = `code` and `files`, and requests the + * client transform of `src/fn.ts`. Returns `'hang'` when the transform + * stops making progress before it finishes (`settledOrHung`); otherwise + * its code and the module evaluated with Start's real client runtime + * (`clientRuntime`) and `zod`. */ async function transformClientModule( code: string, @@ -1971,6 +2020,7 @@ describe('Vite dev server', () => { })) { await writeFile(path.join(root, 'src', file), content) } + await linkStartRuntime(root) server = await createServer({ root, configFile: false, @@ -1986,16 +2036,6 @@ describe('Vite dev server', () => { load: active, transform: active, }, - { - name: 'stub-start-packages', - resolveId: (id) => - id.startsWith('@tanstack/react-start') ? `\0${id}` : null, - load: (id) => - id.startsWith('\0@tanstack/react-start') - ? `export const createServerFn = () => ({ handler: (fn) => fn }) -export const createClientRpc = (id) => id` - : null, - }, startCompilerPlugin({ framework: 'react', environments: [ @@ -2014,6 +2054,7 @@ export const createClientRpc = (id) => id` } const served = (await transformed)!.code const client = server.environments.client + const zod = (await import('zod')) as ModuleStub const module = await createModuleGraph({ modules: { '/src/fn.ts': served }, // Project modules as the dev server serves them. @@ -2022,7 +2063,14 @@ export const createClientRpc = (id) => id` ? (await client.transformRequest(id))?.code : null, transform: (source) => source, - fallbackStub: clientStubs, + stubs: servedStartRuntime, + // `zod`, served pre-bundled (`/node_modules/.vite/deps/zod.js`) + fallbackStub: (specifier) => { + if (specifier.includes('/zod')) { + return zod + } + throw new Error(`no stub for ${specifier}`) + }, }).import('/src/fn.ts') return { code: served, module } } finally { @@ -2044,7 +2092,7 @@ export const createClientRpc = (id) => id` expect(result).not.toBe('hang') const { code, module } = result as Exclude expect(code).not.toContain('MARK_HANDLER') - expect(module.fn).toEqual({ functionId: expect.any(String) }) + expect(callerRpcId(module.fn)).toEqual(expect.any(String)) } // Control for the pin below (same harness). @@ -2082,20 +2130,10 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, 30_000, ) - /** Start packages of the server fn dev servers: RPCs expose their id. */ - const serverFnStubs: Record = { - '@tanstack/react-start': `export const createServerFn = () => ({ - handler: (rpc, impl) => - impl ? Object.assign((opts) => impl(opts ?? {}), { __executeServer: (opts) => impl(opts ?? {}) }) : rpc, -})`, - '@tanstack/react-start/server-rpc': `export const createServerRpc = (serverFnMeta, fn) => Object.assign(fn, { serverFnMeta })`, - '@tanstack/react-start/ssr-rpc': `export const createSsrRpc = (id) => ({ functionId: id })`, - '@tanstack/react-start/client-rpc': `export const createClientRpc = (id) => ({ functionId: id })`, - } - /** * Starts a Vite dev server (middleware mode) with the Start compiler on - * `root`, with SSR as the server fn provider. + * `root` (which links Start's real runtime), with SSR as the server fn + * provider. */ function startServerFnDevServer(root: string) { return createServer({ @@ -2106,14 +2144,6 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [ - { - name: 'stub-start-packages', - resolveId: (id) => (id in serverFnStubs ? `\0stub:${id}` : null), - load: (id) => - id.startsWith('\0stub:') - ? serverFnStubs[id.slice('\0stub:'.length)] - : null, - }, startCompilerPlugin({ framework: 'react', environments: [ @@ -2142,6 +2172,7 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, path.join(root, 'src', 'fns.ts'), `${head}export const greet = createServerFn().handler(() => 'hello')\n`, ) + await linkStartRuntime(root) /** Imports a module in the SSR environment of a dev server. */ const importSsr = ( server: Awaited>, @@ -2167,9 +2198,7 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, '#tanstack-start-server-fn-resolver', ) try { - return await ( - await getServerFnById(greet.functionId) - )({}) + return await callServerFnById(getServerFnById, greet.serverFnMeta.id) } catch (error) { return `throws: ${(error as Error).message}` } diff --git a/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts b/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts index ec834bdd77f..a4fb6c81428 100644 --- a/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts +++ b/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts @@ -9,22 +9,11 @@ * Start's Vite and Rsbuild plugins (build-harness.ts). */ import { describe, expect, test } from 'vitest' -import { buildStart, startBundlers, startRuntimeFiles } from '../build-harness' +import { buildStart, requestsOfPrelude, startBundlers } from '../build-harness' const timeout = 60_000 -/** - * The stand-in client RPC, with the URL the real one - * (`@tanstack/start-client-core` `createClientRpc`) calls. - */ const greetFiles = { - ...startRuntimeFiles, - 'node_modules/@tanstack/react-start/client-rpc.js': `export const createClientRpc = (id) => - Object.assign(() => {}, { - functionId: id, - url: process.env.TSS_SERVER_FN_BASE + id, - }) -`, 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start' export const greet = createServerFn().handler(async () => 'MARK_HANDLER') `, @@ -54,11 +43,15 @@ describe.each(startBundlers)('%s build', (bundler) => { start, }) const rpc = ( - await client.run( - `return { id: entry.greet.functionId, url: entry.greet.url }`, - ) - ).result as { id: string; url: string } + await client.run(`${requestsOfPrelude} +return { + id: entry.greet.serverFnMeta.id, + url: entry.greet.url, + requests: await requestsOf(entry.greet), +}`) + ).result as { id: string; url: string; requests: Array } expect(rpc.url).toBe(`${base}${rpc.id}`) + expect(rpc.requests).toEqual([{ url: rpc.url, method: 'GET' }]) expect((await ssr.run(`return entry.serverFnBase()`)).result).toBe(base) }, timeout, diff --git a/packages/start-plugin-core/tests/rsbuild-dev-server.ts b/packages/start-plugin-core/tests/rsbuild-dev-server.ts index 91a1b63a587..ceffc53b4b7 100644 --- a/packages/start-plugin-core/tests/rsbuild-dev-server.ts +++ b/packages/start-plugin-core/tests/rsbuild-dev-server.ts @@ -3,10 +3,11 @@ * with the Start compiler's Rsbuild transforms (`client` environment) on a * temporary app, through the shared Rsbuild dev-server harness * (`@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. `runRsbuildClient()` runs - * the emitted client code. The temporary apps live in this directory. + * `@tanstack/react-start` is the real package (`startRuntimeLinks` of the + * build harness). With `importProtection`, Start's Rsbuild import protection + * also runs on the `client` environment. `runRsbuildClient()` runs the + * emitted client code in a VM context with a browser's web APIs. The + * temporary apps live in this directory. */ import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,7 +17,7 @@ import { onTestFinished } from 'vitest' import { registerImportProtection } from '../src/rsbuild/import-protection' import { registerStartCompilerTransforms } from '../src/rsbuild/start-compiler-host' import { tanStackStartRsbuild } from '../src/rsbuild/plugin' -import { startRuntimeFiles } from './build-harness' +import { startRuntimeLinks } from './build-harness' import { testLoaderFiles, useTestLoaders } from './rsbuild-test-loaders' import type { ImportProtectionOptions } from '../src/schema' import type { GenerateFunctionIdFnOptional } from '../src/start-compiler/types' @@ -27,6 +28,75 @@ import type { } from '@tanstack/compiler-test-utils/rsbuild-dev-server' const harnessDir = path.dirname(fileURLToPath(import.meta.url)) +/** + * Web APIs of a browser that Node.js has too (the WinterTC minimum common + * API): a VM context has only the language's globals, and Start's client + * runtime uses these (e.g. `TextDecoder` when its module loads). + */ +const webApis = [ + 'AbortController', + 'AbortSignal', + 'Blob', + 'BroadcastChannel', + 'ByteLengthQueuingStrategy', + 'CompressionStream', + 'CountQueuingStrategy', + 'Crypto', + 'CryptoKey', + 'CustomEvent', + 'DOMException', + 'DecompressionStream', + 'Event', + 'EventTarget', + 'File', + 'FormData', + 'Headers', + 'MessageChannel', + 'MessageEvent', + 'MessagePort', + 'ReadableByteStreamController', + 'ReadableStream', + 'ReadableStreamBYOBReader', + 'ReadableStreamBYOBRequest', + 'ReadableStreamDefaultController', + 'ReadableStreamDefaultReader', + 'Request', + 'Response', + 'SubtleCrypto', + 'TextDecoder', + 'TextDecoderStream', + 'TextEncoder', + 'TextEncoderStream', + 'TransformStream', + 'TransformStreamDefaultController', + 'URL', + 'URLSearchParams', + 'WritableStream', + 'WritableStreamDefaultController', + 'WritableStreamDefaultWriter', + 'atob', + 'btoa', + 'clearInterval', + 'clearTimeout', + 'crypto', + 'fetch', + 'navigator', + 'performance', + 'queueMicrotask', + 'setInterval', + 'setTimeout', + 'structuredClone', +] + +/** The `webApis` of this process, for a VM context. */ +export function webGlobals(): Record { + return Object.fromEntries( + webApis.map((name) => [ + name, + (globalThis as Record)[name], + ]), + ) +} /** * Starts an Rsbuild dev server with the Start compiler on an app of `files` @@ -47,9 +117,19 @@ export function startRsbuildDevServer( prefix: '.start-rsbuild-dev-server-', registerCleanup: onTestFinished, ...devServerOptions, - config: { environments: { client: {} }, ...devServerOptions.config }, + config: { + environments: { client: {} }, + // The server fn base Start's plugin defines, which Start's client + // runtime reads (the compiler alone defines nothing). + source: { + define: { + 'process.env.TSS_SERVER_FN_BASE': JSON.stringify('/_serverFn/'), + }, + }, + ...devServerOptions.config, + }, + symlinks: { ...startRuntimeLinks(files), ...devServerOptions.symlinks }, files: { - ...startRuntimeFiles, ...testLoaderFiles, ...files, }, @@ -108,8 +188,8 @@ export function startRsbuildStartDevServer( prefix: '.start-rsbuild-start-dev-server-', registerCleanup: onTestFinished, ...devServerOptions, + symlinks: { ...startRuntimeLinks(files), ...devServerOptions.symlinks }, files: { - ...startRuntimeFiles, ...testLoaderFiles, 'harness/client-entry.ts': `import '../src/entry'\n`, 'harness/server-entry.ts': `import '../src/entry' @@ -152,8 +232,9 @@ export async function runInClient( server: Pick, script: string, ): Promise<{ result: unknown } | { error: string }> { - const context = vm.createContext({ console: { ...console } }) + const context = vm.createContext({ ...webGlobals(), console: { ...console } }) context.self = context + context.window = context context.globalThis = context for (const code of Object.values(await server.outputs('client'))) { vm.runInContext(code, context) @@ -202,6 +283,7 @@ export async function runRsbuildClient( ) } const context = vm.createContext({ + ...webGlobals(), console: { log: recorder('log'), info: recorder('info'), @@ -210,6 +292,7 @@ export async function runRsbuildClient( }, }) context.self = context + context.window = context context.globalThis = context for (const code of Object.values(await server.outputs())) { vm.runInContext(code, context) 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 880a2ea2d1a..9815f265860 100644 --- a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts @@ -8,7 +8,9 @@ import { runInClient, startRsbuildDevServer, startRsbuildStartDevServer, + webGlobals, } from '../rsbuild-dev-server' +import { requestsOfPrelude } from '../build-harness' const timeout = 30_000 @@ -42,10 +44,7 @@ export const ${name} = base.handler(async () => db.x()) 'src/a2.ts': importer('fn2'), 'src/entry.ts': `import { fn1 } from './a1' import { fn2 } from './a2' -;(globalThis as Record).run = async () => [ - await fn1(), - await fn2(), -] +;(globalThis as Record).fns = [fn1, fn2] `, }, }) @@ -57,7 +56,21 @@ import { fn2 } from './a2' expect(server.errors).toEqual([]) return { output: await output(), - called: await runInClient(server, 'return await globalThis.run()'), + /** What calling both fns in the client returns. */ + called: await runInClient( + server, + `return await Promise.all(globalThis.fns.map((fn) => fn()))`, + ), + /** The URL of each fn, and the requests calling it sends. */ + requests: await runInClient( + server, + `${requestsOfPrelude} +const requests = [] +for (const fn of globalThis.fns) { + requests.push({ url: fn.url, sent: await requestsOf(fn) }) +} +return requests`, + ), } } @@ -69,9 +82,18 @@ import { fn2 } from './a2' test.each(['./b', './c'])( 'client: every module importing a factory from %s that becomes one loads no server code, and calls the server', async (importFrom) => { - const { output, called } = await editFactory(importFrom, true) + const { output, requests } = await editFactory(importFrom, true) expect(output).not.toContain('MARK_DB_SECRET') - expect(called).toEqual({ error: expect.stringMatching(/^client RPC /) }) + const url = expect.stringMatching(/^\/_serverFn\/./) + const call = { url, sent: [{ url, method: 'GET' }] } + expect(requests).toEqual({ result: [call, call] }) + const { result } = requests as { + result: Array<{ url: string; sent: Array<{ url: string }> }> + } + expect(result.map(({ url, sent }) => sent[0]!.url === url)).toEqual([ + true, + true, + ]) }, timeout, ) @@ -182,12 +204,12 @@ export const getData = createServerFn().handler(async () => 'x') '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] +;(globalThis as Record).ids = [a.serverFnMeta.id, b.serverFnMeta.id] `, }, }) // The client bundle (one chunk) runs in a bare context - const context = vm.createContext({}) + const context = vm.createContext(webGlobals()) context.self = context for (const code of Object.values(await server.outputs())) { vm.runInContext(code, context) @@ -214,11 +236,11 @@ import { getData as b } from './b' export const getData = createServerFn().handler(async () => 'x') `, 'src/entry.ts': `import { getData } from './a' -;(globalThis as Record).ids = [getData.functionId] +;(globalThis as Record).ids = [getData.serverFnMeta.id] `, }, }) - const context = vm.createContext({}) + const context = vm.createContext(webGlobals()) context.self = context for (const code of Object.values(await server.outputs())) { vm.runInContext(code, context) diff --git a/packages/start-plugin-core/tests/start-build-determinism.test.ts b/packages/start-plugin-core/tests/start-build-determinism.test.ts index d1d9df903f5..107173e5125 100644 --- a/packages/start-plugin-core/tests/start-build-determinism.test.ts +++ b/packages/start-plugin-core/tests/start-build-determinism.test.ts @@ -6,7 +6,12 @@ * plugins (build-harness.ts); each build is in a new temporary directory. */ import { describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' +import { + buildStart, + callByIdPrelude, + routerRuntimeFiles, + startBundlers, +} from './build-harness' import type { StartBuild } from './build-harness' const timeout = 60_000 @@ -52,7 +57,7 @@ async function describeOutput(build: StartBuild) { return { ids: ( await build.client.run( - `return [entry.greetA.functionId, entry.greetB.functionId]`, + `return [entry.greetA.serverFnMeta.id, entry.greetB.serverFnMeta.id]`, ) ).result, client: chunks(build.client), @@ -118,16 +123,15 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' const { client, ssr } = outcome as StartBuild const ids = ( await client.run( - `return { a: entry.greetA.functionId, b: entry.greetB.functionId }`, + `return { a: entry.greetA.serverFnMeta.id, b: entry.greetB.serverFnMeta.id }`, ) ).result as { a: string; b: string } expect(ids.a).not.toBe(ids.b) - const { result } = await ssr.run(`const call = async (id) => - (await entry.getServerFnById(id, { origin: 'client' }))({}) + const { result } = await ssr.run(`${callByIdPrelude} return { - a: await call(${JSON.stringify(ids.a)}), - b: await call(${JSON.stringify(ids.b)}), - ssrIds: { a: entry.greetA.functionId, b: entry.greetB.functionId }, + a: await callById(${JSON.stringify(ids.a)}), + b: await callById(${JSON.stringify(ids.b)}), + ssrIds: { a: entry.greetA.serverFnMeta.id, b: entry.greetB.serverFnMeta.id }, }`) expect(result).toEqual({ a: 'MARK_A', b: 'MARK_B', ssrIds: ids }) }, 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 6fd5b73c57f..5159245000d 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 @@ -23,14 +23,30 @@ import { import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { buildStart, + callByIdPrelude, + requestsOfPrelude, routerRuntimeFiles, + runInRequestContext, startBundlers, - startRuntimeFiles, + startRuntimeLinks, } from './build-harness' import type { StartBuild } from './build-harness' const timeout = 60_000 +/** + * The client's `entry.greet()` sends one request to the server, to the + * server fn's URL. + */ +async function expectCallsTheServer(client: StartBuild['client']) { + const called = ( + await client.run(`${requestsOfPrelude} +return { url: entry.greet.url, requests: await requestsOf(entry.greet) }`) + ).result as { url: string; requests: Array } + expect(called.url).toMatch(/^\/_serverFn\/./) + expect(called.requests).toEqual([{ url: called.url, method: 'GET' }]) +} + describe.each(startBundlers)('%s build', (bundler) => { // Whether the SSR build loads the handler eagerly or on demand is left // open. @@ -52,15 +68,7 @@ export const greet = createServerFn().handler(() => 'MARK_HANDLER') expect((await ssr.run(`return await entry.greet()`)).result).toBe( 'MARK_HANDLER', ) - expect( - ( - await client.run(`try { - entry.greet() -} catch (error) { - return error.message.startsWith('client RPC ') -}`) - ).result, - ).toBe(true) + await expectCallsTheServer(client) }, timeout, ) @@ -108,6 +116,13 @@ export const where = createIsomorphicFn() async () => { const { client, ssr } = await buildStart({ bundler, + // The app depends on the library, as an installed one. + packageJson: { + name: 'app', + private: true, + type: 'module', + dependencies: { 'server-fn-lib': '*' }, + }, files: { ...library( 'server-fn-lib', @@ -123,11 +138,11 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, }) expect(client.where('MARK_LIB_HANDLER')).toBe('nowhere') - const id = (await client.run(`return entry.libFn.functionId`)).result + const id = (await client.run(`return entry.libFn.serverFnMeta.id`)).result expect(id).toEqual(expect.any(String)) - const { result } = await ssr.run(`return { + const { result } = await ssr.run(`${callByIdPrelude}return { called: await entry.libFn(), - byId: await (await entry.getServerFnById(${JSON.stringify(id)}, { origin: 'client' }))({}), + byId: await callById(${JSON.stringify(id)}), }`) expect(result).toEqual({ called: 'MARK_LIB_HANDLER', @@ -504,12 +519,13 @@ const next = (options) => options 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]`, + `return [entry.fn.serverFnMeta.id, entry.fromBase.serverFnMeta.id]`, ) ).result - const { result } = await build.ssr.run(`const results = [] + const { result } = await build.ssr + .run(`${callByIdPrelude}const results = [] for (const id of ${JSON.stringify(ids)}) { - results.push(await (await entry.getServerFnById(id, { origin: 'client' }))({})) + results.push(await callById(id)) } return results`) expect(result).toEqual(['MARK_HANDLER_DEP', 'MARK_BASE_HANDLER_DEP']) @@ -548,15 +564,7 @@ export const label = () => 'MARK_CLIENT_LABEL' expect((await ssr.run(`return await entry.greet()`)).result).toBe( 'MARK_HANDLER', ) - expect( - ( - await client.run(`try { - entry.greet() -} catch (error) { - return error.message.startsWith('client RPC ') -}`) - ).result, - ).toBe(true) + await expectCallsTheServer(client) }, timeout, ) @@ -575,8 +583,8 @@ describe('Vite: an environment built twice by one builder', () => { dir: path.dirname(fileURLToPath(import.meta.url)), prefix: '.start-build-twice-', registerCleanup: onTestFinished, + symlinks: startRuntimeLinks(), files: { - ...startRuntimeFiles, 'src/direct.ts': `import { createServerFn } from '@tanstack/react-start' export const direct = createServerFn().handler(async () => 'MARK_DIRECT') `, @@ -638,8 +646,8 @@ export const greet = base.handler(async () => 'MARK_HANDLER') const { greet } = await import( pathToFileURL(path.join(outDir, output!)).href ) - expect(typeof greet.functionId).toBe('string') - expect(await greet()).toBe('MARK_HANDLER') + expect(typeof greet.serverFnMeta.id).toBe('string') + expect(await runInRequestContext(() => greet())).toBe('MARK_HANDLER') }, timeout, ) @@ -677,12 +685,12 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' expect(client.where(/MARK_GREET|MARK_FROM_BASE/)).toBe('nowhere') const ids = ( await client.run( - `return [entry.greet.functionId, entry.fromBase.functionId]`, + `return [entry.greet.serverFnMeta.id, entry.fromBase.serverFnMeta.id]`, ) ).result - const { result } = await ssr.run(`const results = [] + const { result } = await ssr.run(`${callByIdPrelude}const results = [] for (const id of ${JSON.stringify(ids)}) { - results.push(await (await entry.getServerFnById(id, { origin: 'client' }))({})) + results.push(await callById(id)) } return results`) expect(result).toEqual(['MARK_GREET', 'MARK_FROM_BASE']) 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 9743bbd708b..07c1b72bd1a 100644 --- a/packages/start-plugin-core/tests/start-build-options.test.ts +++ b/packages/start-plugin-core/tests/start-build-options.test.ts @@ -9,7 +9,12 @@ import { writeFile } from 'node:fs/promises' import path from 'node:path' import ts from 'typescript' import { describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' +import { + buildStart, + callByIdPrelude, + routerRuntimeFiles, + startBundlers, +} from './build-harness' import type { StartBuild } from './build-harness' const timeout = 60_000 @@ -24,11 +29,11 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' `, } -/** Type declarations of the stand-in packages. */ +/** + * Type declarations of the stand-in router (`@tanstack/react-start` is the + * real package, with its types). + */ 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 @@ -108,12 +113,12 @@ describe.each(startBundlers)('%s build', (bundler) => { entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, start: { serverFns: { generateFunctionId: () => undefined } }, }) - const id = (await client.run(`return entry.greet.functionId`)).result + const id = (await client.run(`return entry.greet.serverFnMeta.id`)).result expect(id).toEqual(expect.stringMatching(/./)) expect( ( await ssr.run( - `return await (await entry.getServerFnById(${JSON.stringify(id)}, { origin: 'client' }))({})`, + `${callByIdPrelude}return await callById(${JSON.stringify(id)})`, ) ).result, ).toBe('MARK_HANDLER') @@ -132,13 +137,13 @@ describe.each(startBundlers)('%s build', (bundler) => { 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 client.run(`return entry.greet.serverFnMeta.id`)).result, + ).toBe('my-custom-id') expect( ( await ssr.run( - `return await (await entry.getServerFnById('my-custom-id', { origin: 'client' }))({})`, + `${callByIdPrelude}return await callById('my-custom-id')`, ) ).result, ).toBe('MARK_HANDLER') 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 1b320eb7845..4665d404a82 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 @@ -8,7 +8,12 @@ import { describeHmr, evaluateServed, } from '@tanstack/compiler-test-utils/dev-server' -import { startDevServer } from './dev-server' +import { + callServerFnById, + requestsOf, + runInRequestContext, +} from './build-harness' +import { servedStartRuntime, startDevServer } from './dev-server' const timeout = 30_000 @@ -35,15 +40,22 @@ export const fn = base.handler(async () => db.x()) }) /** The client's `fn` (dependencies loaded from the dev server too). */ const clientFn = async () => - (await evaluateServed(server, '/src/a.tsx', { skip: /^\/@vite\b/ })) - .fn as () => Promise + ( + await evaluateServed(server, '/src/a.tsx', { + skip: /^\/@vite\b/, + stubs: servedStartRuntime, + }) + ).fn as () => Promise expect( containing(await server.load('/src/a.tsx'), 'MARK_DB_SECRET'), ).toEqual([]) - // A server fn: the client calls the server - await expect(clientFn().then((fn) => fn())).rejects.toThrow( - /^client RPC /, - ) + // A server fn: the client calls the server, at the server fn's URL + const rpc = (await clientFn()) as unknown as { + url: string + serverFnMeta: { id: string } + } & (() => Promise) + expect(rpc.url.endsWith(rpc.serverFnMeta.id)).toBe(true) + expect(await requestsOf(rpc)).toEqual([{ url: rpc.url, method: 'GET' }]) await server.edit({ 'src/b.ts': plain }) @@ -77,7 +89,7 @@ export const later = createServerFn().handler(() => 'MARK_LATER') const { getServerFnById } = await server.import( '#tanstack-start-server-fn-resolver', ) - return (await getServerFnById(fn.functionId, { origin: 'client' }))({}) + return callServerFnById(getServerFnById, fn.serverFnMeta.id) } expect(await callById('/src/first.ts', 'first')).toBe('MARK_FIRST') expect(await callById('/src/later.ts', 'later')).toBe('MARK_LATER') @@ -146,21 +158,21 @@ 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' }))({}) - } + await evaluateServed(server, '/src/fns.ts', { + skip: /^\/@vite\b/, + stubs: servedStartRuntime, + }) + ).greet.serverFnMeta.id + /** The resolver of `environment`. */ + const resolver = async (environment: string) => + (await server.import('#tanstack-start-server-fn-resolver', environment)) + .getServerFnById + // SSR's resolver is the test's module, which answers by itself. + const forwarded = await (await resolver('ssr'))(id, { origin: 'client' }) expect({ - ssr: await callById('ssr'), - rsc: await callById('rsc'), + ssr: forwarded(), + rsc: await callServerFnById(await resolver('rsc'), id), }).toEqual({ ssr: `MARK_FORWARDED:${id}`, rsc: 'MARK_HANDLER' }) }, timeout, @@ -188,10 +200,8 @@ export const greet = createServerFn().handler(() => 'before') const { getServerFnById } = await server.import( '#tanstack-start-server-fn-resolver', ) - const handler = await getServerFnById(greet.functionId, { - origin: 'client', - }) - return { id: greet.functionId, result: await handler({}) } + const id = greet.serverFnMeta.id + return { id, result: await callServerFnById(getServerFnById, id) } } const before = await call() expect(before.result).toBe('before') @@ -230,10 +240,7 @@ export const greet = createServerFn().handler(() => db.x()) const { getServerFnById } = await server.import( '#tanstack-start-server-fn-resolver', ) - const handler = await getServerFnById(greet.functionId, { - origin: 'client', - }) - return handler({}) + return callServerFnById(getServerFnById, greet.serverFnMeta.id) } expect(await call()).toBe('before') @@ -362,7 +369,7 @@ export const label = () => 'MARK_CLIENT_LABEL' const { getServerFnById } = await server.import( '#tanstack-start-server-fn-resolver', ) - return (await getServerFnById(fn.functionId, { origin: 'client' }))({}) + return callServerFnById(getServerFnById, fn.serverFnMeta.id) } test( @@ -421,7 +428,10 @@ export const label = () => 'MARK_CLIENT_LABEL' await server.edit({ 'src/fns.ts': fns('V2') }) const { fn } = await server.import('/src/fns.ts') - expect({ called: await fn(), byId: await callById(server) }).toEqual({ + expect({ + called: await runInRequestContext(() => fn()), + byId: await callById(server), + }).toEqual({ called: 'MARK_HANDLER_V2', byId: 'MARK_HANDLER_V2', }) 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 3add1c22ad8..78c4d238e9c 100644 --- a/packages/start-plugin-core/tests/start-compiler-factories.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-factories.test.ts @@ -569,10 +569,10 @@ export const mw = createMiddleware().server(async ({ next }) => throw new Error('uncompiled Start factory') } const stubs = { - // The package also exports the factories `head` imports, which the - // output may still import without calling them. + // Start's real runtime. The output may still import the factories + // `head` imports, but never calls them uncompiled (they throw here). '@tanstack/react-start': { - createMiddleware: () => ({ server: () => 'middleware' }), + ...startClientCore, createClientOnlyFn: uncompiled, createIsomorphicFn: uncompiled, createServerOnlyFn: uncompiled, @@ -586,11 +586,13 @@ export const mw = createMiddleware().server(async ({ next }) => const ssr = await compileCode('ssr', code) expect(importSources(ssr!)).toEqual(['./db.server']) const ssrModule = await evaluateModule(ssr!, stubs) - expect([ssrModule.iso(), ssrModule.serverOnly(), ssrModule.mw]).toEqual([ - 'x', - 'x', - 'middleware', - ]) + expect([ + ssrModule.iso(), + ssrModule.serverOnly(), + await ssrModule.mw.options.server({ + next: (options: unknown) => options, + }), + ]).toEqual(['x', 'x', { context: { value: 'x' } }]) }) // Source: babel-dead-code-elimination "variable" > "within for...in"; From 60f5126529a9a3e18d1471bfb457e7b2d52a7efe Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 15:19:08 +0200 Subject: [PATCH 0409/1425] test(start-plugin-core): call server fns by id through Start's real server entry User ruling 2026-10-18 (test infrastructure must not re-implement other tools or packages). The by-id helpers mirrored Start's server-fn HTTP handler (`handleServerAction`): resolve the id with `getServerFnById`, call it with `{ data, method }`, unwrap `{ result }` / `{ error }`. They now send the request a page sends to Start's own server entry: - builds: the SSR entry exports Start's default server entry (`@tanstack/react-start/server-entry`) and client RPC (`@tanstack/react-start/client-rpc`) (`callByIdExports`); `callById` calls the client RPC with a `fetch` that hands a real `Request` to the server entry's `fetch` (`callByIdPrelude`). `handleServerAction` is not exported by `@tanstack/start-server-core`; `createStartHandler` (the default server entry) is its public way in. - dev: `callServerFnById(server, id)` (dev-server.ts) does the same with the dev server's SSR environment (the server entry Start's dev middleware imports, `virtual:tanstack-start-server-entry`). The dev servers mount Start's whole Vite plugin (`start`, `realRouter`, `startServerEntryFiles`). The harness cannot go over HTTP: Start's dev middleware is not installed because compiler-test-utils and start-plugin-core resolve two copies of vite 8.3.4 (different sass peers), so `isRunnableDevEnvironment` fails. Glue only: a page's browser adds `Sec-Fetch-Site: same-origin`, which Start's CSRF middleware requires (without it every call is "Forbidden"), and the caller throws `error` / returns `result` like the client's server fn. The old copy (`callServerFnById(getServerFnById, id)` in build-harness.ts) is deleted. Contract test build-harness.test.ts "a server fn is Start's RPC on the client ..." now checks the real round trip (by id, and an unknown id rejects with "not found"). Changes needed by the real server entry: - It renders with the real `@tanstack/react-router`, so an app with the router stand-in cannot load it (Vite SSR: "does not provide an export named 'RouterProvider'"). start-build-env-separation "each server fn of the client runs by its id in the SSR build" gets its own build of the server fn files (no routes) instead of the shared routed build. - bundledDev tests: Start's plugin needs React Refresh (`@vitejs/plugin-react`), and its client entry replaces the harness's bundle entry, so `harness/client-entry.ts` re-exports `src/entry.ts`. - known-bugs-start-compiler "calls a server fn id on a fresh dev server": the raw dev servers use Start's whole plugin (`startPlugin`, `startPluginFiles` now exported). Start logs the failed call's Vite plugin error with `console.error`; Vitest's console forwarding then ends the run without a report, so the test silences `console.error` during the call. - Not converted: start-compiler-dev-server "a server fn provider environment other than SSR" (compiler plugin alone with an `rsc` provider; the server entry for such a setup is the framework's). It calls the provider's resolved server fn directly and checks `{ result }`, without claiming to be the HTTP handler. Pins re-verified red for the same reason (run once without `.fails`): - D6 "SSR: a server fn only server code references is not callable over HTTP" (vite, rsbuild): serverOnlyFnOverHttp is 'server-only fn' (now a real HTTP request), as before. - known-bugs-dev-server "SSR: a module importing an edited factory module through a re-export" (2 rows): same diff as before (id 'undefined', byId "Invalid server function ID: undefined"). - known-bugs-start-compiler "calls a server fn id on a fresh dev server that has not compiled its module": same message as before ("Invalid server function ID: eyJ..."). Also drops a stale reason from a start-router-dev-server comment (the real `@tanstack/react-start` is linked now). Co-Authored-By: Claude Opus 5.5 --- .../tests/build-harness.test.ts | 6 +- .../start-plugin-core/tests/build-harness.ts | 71 +++++++--------- .../start-plugin-core/tests/dev-server.ts | 48 ++++++++++- .../tests/known-bugs-dev-server.test.ts | 24 +++--- .../tests/known-bugs-start-compiler.test.ts | 78 ++++++++++-------- .../tests/start-build-determinism.test.ts | 4 +- .../tests/start-build-env-separation.test.ts | 57 +++++++++---- .../tests/start-build-options.test.ts | 4 +- .../tests/start-compiler-dev-server.test.ts | 80 ++++++++++++------- .../tests/start-router-dev-server.test.ts | 5 +- 10 files changed, 238 insertions(+), 139 deletions(-) diff --git a/packages/start-plugin-core/tests/build-harness.test.ts b/packages/start-plugin-core/tests/build-harness.test.ts index 13cbc43c76f..3de6485399e 100644 --- a/packages/start-plugin-core/tests/build-harness.test.ts +++ b/packages/start-plugin-core/tests/build-harness.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from 'vitest' import { BuildError } from '@tanstack/compiler-test-utils/build-harness' import { buildStart, + callByIdExports, callByIdPrelude, requestsOfPrelude, routerRuntimeFiles, @@ -74,7 +75,7 @@ describe.each(startBundlers)('%s', (bundler) => { ) test( - "a server fn is Start's RPC on the client: calling it fetches its URL, which carries its id; the SSR build runs the handler, directly and by that id", + "a server fn is Start's RPC on the client: calling it fetches its URL, which carries its id; the SSR build runs the handler, directly and for a request of that id", async () => { const { client, ssr } = await buildStart({ bundler, @@ -84,8 +85,7 @@ export const greet = createServerFn().handler(async () => 'hello') `, 'src/client-entry.ts': `export { greet } from './fns'\n`, 'src/ssr-entry.ts': `export { greet } from './fns' -export { getServerFnById } from '#tanstack-start-server-fn-resolver' -`, +${callByIdExports}`, }, entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, }) diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index ba5bbdb410a..df2f9afd6b0 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -15,9 +15,9 @@ * builds bundle and run Start's runtime. What the test process cannot do is * stood in for, each checked against the real runtime in * build-harness.test.ts: SSR scripts run in a request context - * (`inRequestContext`), a client script observes the requests a server fn - * call sends (`requestsOfPrelude`), and an SSR script calls a server fn by - * id like the server-fn HTTP handler (`callByIdPrelude`). + * (`inRequestContext`), and a client script observes the requests a server + * fn call sends (`requestsOfPrelude`). An SSR script calls a server fn by id + * through Start's own server entry and client RPC (`callByIdPrelude`). * `@tanstack/react-router` is a stand-in for apps with routes * (`routerRuntimeFiles`). The app's entries are the harness's: the client * and server entries Start builds import `#tanstack-start-entry` @@ -161,20 +161,37 @@ export function runInRequestContext(fn: () => T): T { } /** - * Prelude of an SSR script whose entry exports `getServerFnById` of - * `#tanstack-start-server-fn-resolver`: `callById(id, data)` calls the - * server fn of `id` the way Start's server-fn HTTP handler does - * (`handleServerAction` of `@tanstack/start-server-core`: resolve the id, - * call it with the data the client sent, unwrap `{ result }` and throw - * `{ error }`), without the HTTP round trip and its serialization. + * Exports of an SSR entry whose script calls server fns by id + * (`callByIdPrelude`): Start's default server entry (the HTTP handler of a + * Start app) and Start's client RPC. The server entry renders with the real + * `@tanstack/react-router`: an app with the stand-in (`routerRuntimeFiles`) + * cannot load it. + */ +export const callByIdExports = `export { default as startServerEntry } from '@tanstack/react-start/server-entry' +export { createClientRpc } from '@tanstack/react-start/client-rpc' +` + +/** + * Prelude of an SSR script whose entry has `callByIdExports`: + * `callById(id, data)` sends the GET request of a client call of the server + * fn `id` (Start's client RPC) to Start's server entry, as a page of the + * same origin does (the browser adds `Sec-Fetch-Site`, which Start's CSRF + * middleware checks), and gives the decoded result or throws the decoded + * error, like the client's server fn. */ export const callByIdPrelude = `const callById = async (id, data) => { - const fn = await entry.getServerFnById(id, { origin: 'client' }) - const response = await fn({ data, method: fn.method ?? 'GET' }) - if ('error' in response) { - throw response.error + const fetch = (url, init) => { + const headers = new Headers(init.headers) + headers.set('sec-fetch-site', 'same-origin') + return entry.startServerEntry.fetch( + new Request(new URL(url, 'http://localhost'), { ...init, headers }), + ) + } + const reply = await entry.createClientRpc(id)({ data, method: 'GET', fetch }) + if ('error' in reply) { + throw reply.error } - return response.result + return reply.result } ` @@ -201,32 +218,6 @@ export async function requestsOf( export const requestsOfPrelude = `const requestsOf = ${requestsOf.toString()} ` -/** - * Calls the server fn of `id` in a test's own process the way Start's - * server-fn HTTP handler does (see `callByIdPrelude`), in the test request - * context (`runInRequestContext`): `getServerFnById` is the resolver of the - * server environment (`#tanstack-start-server-fn-resolver`). - */ -export async function callServerFnById( - getServerFnById: ( - id: string, - options: { origin: 'client' }, - ) => Promise<(options: Record) => Promise>, - id: string, - data?: unknown, -): Promise { - const fn = (await getServerFnById(id, { origin: 'client' })) as (( - options: Record, - ) => Promise) & { method?: string } - const response = (await runInRequestContext(() => - fn({ data, method: fn.method ?? 'GET' }), - )) as { result?: unknown; error?: unknown } - if ('error' in response) { - throw response.error - } - return response.result -} - const routerPackage = 'node_modules/@tanstack/react-router' /** diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts index c844fe0256d..2c726bee11b 100644 --- a/packages/start-plugin-core/tests/dev-server.ts +++ b/packages/start-plugin-core/tests/dev-server.ts @@ -17,6 +17,7 @@ import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' import { onTestFinished } from 'vitest' import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { tanStackStartVite } from '../src/vite/plugin' +import { ENTRY_POINTS } from '../src/constants' import { reactStartPackage, routerRuntimeFiles, @@ -95,8 +96,51 @@ export interface StartDevServerOptions extends Omit< realRouter?: boolean } +/** + * The server entry of an app with `start` whose server handles HTTP + * requests: Start's default one (`@tanstack/react-start/server-entry`), + * which renders with the real `@tanstack/react-router` (`realRouter`). + */ +export const startServerEntryFiles = { + 'harness/server-entry.ts': `export { default } from '@tanstack/react-start/server-entry'\n`, +} + +/** + * Calls the server fn `id` of a dev server with `start` and + * `startServerEntryFiles` the way a page of the same origin does: Start's + * client RPC sends its GET request to the app's server entry (its `fetch`, + * which Start's dev server middleware calls in the SSR environment; the + * browser adds `Sec-Fetch-Site`, which Start's CSRF middleware checks). + * Gives the decoded result or throws the decoded error, like the client's + * server fn. + */ +export async function callServerFnById( + server: Pick, + id: string, + data?: unknown, +): Promise { + const { createClientRpc } = await server.import( + '@tanstack/react-start/client-rpc', + ) + const serverEntry = (await server.import(ENTRY_POINTS.server)).default + const fetch = (url: string, init: RequestInit) => { + const headers = new Headers(init.headers) + headers.set('sec-fetch-site', 'same-origin') + return serverEntry.fetch( + new Request(new URL(url, 'http://localhost'), { ...init, headers }), + ) + } + const reply = (await createClientRpc(id)({ data, method: 'GET', fetch })) as + | { result: unknown } + | { error: unknown } + if ('error' in reply) { + throw reply.error + } + return reply.result +} + /** The files the harness adds to an app with Start's plugin, unless it has them. */ -const startPluginFiles = { +export const startPluginFiles = { 'harness/client-entry.ts': `export {}\n`, 'harness/server-entry.ts': `export {}\n`, 'harness/start.ts': `export const startInstance = undefined\n`, @@ -107,7 +151,7 @@ export const Route = createRootRoute() } /** Start's Vite plugin with the core options `@tanstack/react-start` passes. */ -function startPlugin( +export function startPlugin( root: string, start: TanStackStartInputConfig, compilerTransforms: Array | undefined, 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 367a64cf2d5..3fec7ccf3e7 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 @@ -8,8 +8,13 @@ import path from 'node:path' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { describe, expect, test } from 'vitest' import { importProtectionPlugin } from '../src/vite/import-protection-plugin/plugin' -import { callServerFnById, runInRequestContext } from './build-harness' -import { servedStartRuntime, startDevServer } from './dev-server' +import { runInRequestContext } from './build-harness' +import { + callServerFnById, + servedStartRuntime, + startDevServer, + startServerEntryFiles, +} from './dev-server' import { isMock } from './importProtection/mock-helpers' import type { ImportProtectionPluginOptions } from '../src/vite/import-protection-plugin/types' @@ -122,13 +127,15 @@ describe('editing a server fn factory (SSR)', () => { * Like `editFactory` above, in the SSR environment: `src/a.tsx` runs in SSR * while `fn` is a plain function, then `src/b.ts` becomes a * `createServerFn()` builder. Returns what `fn` is in SSR afterwards: its - * server fn id, what calling it returns, and what the server fn resolver - * (the server-fn router's lookup, which HTTP calls go through) runs for - * that id. + * server fn id, what calling it returns, and what a page's call of that + * id returns (`callServerFnById`: Start's server entry handles it). */ async function editFactorySsr(importFrom: string) { const server = await startDevServer({ + start: {}, + realRouter: true, files: { + ...startServerEntryFiles, 'src/b.ts': `export const base = { handler: (fn: () => unknown) => fn }\n`, 'src/c.ts': `export * from './b'\n`, 'src/d.ts': `import { base } from './b'\nexport { base }\n`, @@ -147,9 +154,6 @@ export const base = createServerFn() `, }) const { fn } = await server.import('/src/a.tsx') - const { getServerFnById } = await server.import( - '#tanstack-start-server-fn-resolver', - ) const settle = async (run: () => unknown) => { try { return await run() @@ -160,9 +164,7 @@ export const base = createServerFn() return { id: typeof fn.serverFnMeta?.id, called: await settle(() => runInRequestContext(() => fn())), - byId: await settle(() => - callServerFnById(getServerFnById, fn.serverFnMeta?.id), - ), + byId: await settle(() => callServerFnById(server, fn.serverFnMeta?.id)), } } 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 6afa2d50713..1a3794f856d 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,12 +10,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { buildStart, + callByIdExports, callByIdPrelude, - callServerFnById, linkStartRuntime, startBundlers, } from './build-harness' -import { servedStartRuntime } from './dev-server' +import { + callServerFnById, + servedStartRuntime, + startPlugin, + startPluginFiles, + startServerEntryFiles, +} from './dev-server' import { callProvider, callReportedServerFns, @@ -1693,13 +1699,13 @@ export { getServerFnById } from '#tanstack-start-server-fn-resolver' 'client-entry.ts': `export { clientFn } from './client-fns' `, 'ssr-entry.ts': `export { serverOnlyFn, getServerFnById } from './server-only-fns' -`, +${callByIdExports}`, } /** - * Builds `clientAccessFiles` and, in the SSR build, resolves each server fn - * the way the server-fn HTTP handler resolves a request - * (`origin: 'client'`), and calls the server-only one from SSR code. + * Builds `clientAccessFiles` and, in the SSR build, sends a request for + * each server fn id to Start's server entry, as a page does + * (`callByIdPrelude`), and calls the server-only one from SSR code. */ async function callClientAccess() { const { client, ssr } = await buildStart({ @@ -2131,9 +2137,9 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, ) /** - * Starts a Vite dev server (middleware mode) with the Start compiler on - * `root` (which links Start's real runtime), with SSR as the server fn - * provider. + * Starts a Vite dev server (middleware mode) with Start's Vite plugin on + * `root` (which links Start's real runtime and has `startAppFiles`), with + * SSR as the server fn provider. */ function startServerFnDevServer(root: string) { return createServer({ @@ -2143,35 +2149,34 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, cacheDir: path.join(root, '.vite'), appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false, watch: null }, - plugins: [ - startCompilerPlugin({ - framework: 'react', - environments: [ - { name: 'client', type: 'client' }, - { name: 'ssr', type: 'server' }, - ], - providerEnvName: 'ssr', - }), - ], + plugins: [startPlugin(root, {}, undefined)], }) } + /** + * The files of Start's plugin (`startPluginFiles`), with Start's default + * server entry (`startServerEntryFiles`), which handles server fn calls. + */ + const startAppFiles = { ...startPluginFiles, ...startServerEntryFiles } + /** * In a temporary project with `src/fns.ts` = a `greet` server fn, reads the - * id of `greet` through a first dev server, then calls the id through the - * server fn resolver (`getServerFnById`, like the server fn router) of a - * dev server started with `fresh` (a new server, which has compiled - * nothing) or of the same server. Returns the result or the error message. + * id of `greet` through a first dev server, then calls the id the way a + * page does (`callServerFnById`: Start's server entry handles it) on a dev + * server started with `fresh` (a new server, which has compiled nothing) + * or on the same server. Returns the result or the error message. */ async function callServerFnId(fresh: boolean) { const root = await mkdtemp(path.join(__dirname, '.dev-server-fn-')) const servers: Array>> = [] try { - await mkdir(path.join(root, 'src')) - await writeFile( - path.join(root, 'src', 'fns.ts'), - `${head}export const greet = createServerFn().handler(() => 'hello')\n`, - ) + for (const [file, code] of Object.entries({ + ...startAppFiles, + 'src/fns.ts': `${head}export const greet = createServerFn().handler(() => 'hello')\n`, + })) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }) + await writeFile(path.join(root, file), code) + } await linkStartRuntime(root) /** Imports a module in the SSR environment of a dev server. */ const importSsr = ( @@ -2189,18 +2194,23 @@ export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, const { greet } = await importSsr(first, '/src/fns.ts') let server = first if (fresh) { - await first.close() + await servers.pop()!.close() server = await startServerFnDevServer(root) servers.push(server) } - const { getServerFnById } = await importSsr( - server, - '#tanstack-start-server-fn-resolver', - ) + const ssr = server + // Start's server logs the error of a failed call (a Vite plugin error + // here), which Vitest's console forwarding does not survive. + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - return await callServerFnById(getServerFnById, greet.serverFnMeta.id) + return await callServerFnById( + { import: (id) => importSsr(ssr, id) }, + greet.serverFnMeta.id, + ) } catch (error) { return `throws: ${(error as Error).message}` + } finally { + logged.mockRestore() } } finally { for (const server of servers) { diff --git a/packages/start-plugin-core/tests/start-build-determinism.test.ts b/packages/start-plugin-core/tests/start-build-determinism.test.ts index 107173e5125..dfe8c9e1d6c 100644 --- a/packages/start-plugin-core/tests/start-build-determinism.test.ts +++ b/packages/start-plugin-core/tests/start-build-determinism.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test } from 'vitest' import { buildStart, + callByIdExports, callByIdPrelude, routerRuntimeFiles, startBundlers, @@ -110,8 +111,7 @@ export { greet as greetB } from './b' `, 'src/ssr-entry.ts': `export { greet as greetB } from './b' export { greet as greetA } from './a' -export { getServerFnById } from '#tanstack-start-server-fn-resolver' -`, +${callByIdExports}`, }, entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, start: { serverFns: { generateFunctionId: () => 'same-id' } }, 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 5159245000d..a13d3768633 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 @@ -23,6 +23,7 @@ import { import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { buildStart, + callByIdExports, callByIdPrelude, requestsOfPrelude, routerRuntimeFiles, @@ -132,8 +133,7 @@ export const libFn = createServerFn().handler(async () => 'MARK_LIB_HANDLER') ), 'src/client-entry.ts': `export { libFn } from 'server-fn-lib'\n`, 'src/ssr-entry.ts': `export { libFn } from 'server-fn-lib' -export { getServerFnById } from '#tanstack-start-server-fn-resolver' -`, +${callByIdExports}`, }, entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, }) @@ -513,23 +513,51 @@ const next = (options) => options }, ) - // An HTTP call of a client RPC runs the server fn of its id. + // An HTTP call of a client RPC runs the server fn of its id. Its own + // build of the server fns: Start's server entry, which handles the + // call, renders with the real `@tanstack/react-router`, not the shared + // build's stand-in. // 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.serverFnMeta.id, entry.fromBase.serverFnMeta.id]`, - ) - ).result - const { result } = await build.ssr - .run(`${callByIdPrelude}const results = [] + test( + 'each server fn of the client runs by its id in the SSR build', + async () => { + const fnFiles = [ + 'src/deps/handlerDep.ts', + 'src/deps/baseHandlerDep.ts', + 'src/deps/middlewareServer.ts', + 'src/deps/middlewareValidator.ts', + 'src/deps/isomorphicServer.ts', + 'src/deps/serverOnlyDep.ts', + 'src/deps/startMiddleware.ts', + 'src/middleware.ts', + 'src/base.ts', + 'src/fns.ts', + 'src/start.ts', + ] + const { client, ssr } = await buildStart({ + bundler, + files: { + ...Object.fromEntries(fnFiles.map((file) => [file, files[file]!])), + 'src/ssr-entry.ts': `export * from './fns' +${callByIdExports}`, + }, + entry: { client: 'src/fns.ts', ssr: 'src/ssr-entry.ts' }, + }) + const ids = ( + await client.run( + `return [entry.fn.serverFnMeta.id, entry.fromBase.serverFnMeta.id]`, + ) + ).result + const { result } = await ssr.run(`${callByIdPrelude}const results = [] for (const id of ${JSON.stringify(ids)}) { results.push(await callById(id)) } return results`) - expect(result).toEqual(['MARK_HANDLER_DEP', 'MARK_BASE_HANDLER_DEP']) - }) + expect(result).toEqual(['MARK_HANDLER_DEP', 'MARK_BASE_HANDLER_DEP']) + }, + timeout, + ) }) }) @@ -677,8 +705,7 @@ 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' -`, +${callByIdExports}`, }, entry: { client: 'src/client-entry.ts', ssr: 'src/ssr-entry.ts' }, }) 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 07c1b72bd1a..cec2b90121b 100644 --- a/packages/start-plugin-core/tests/start-build-options.test.ts +++ b/packages/start-plugin-core/tests/start-build-options.test.ts @@ -11,6 +11,7 @@ import ts from 'typescript' import { describe, expect, test } from 'vitest' import { buildStart, + callByIdExports, callByIdPrelude, routerRuntimeFiles, startBundlers, @@ -25,8 +26,7 @@ export const greet = createServerFn().handler(async () => 'MARK_HANDLER') `, 'src/client-entry.ts': `export { greet } from './fns'\n`, 'src/ssr-entry.ts': `export { greet } from './fns' -export { getServerFnById } from '#tanstack-start-server-fn-resolver' -`, +${callByIdExports}`, } /** 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 4665d404a82..c8082af7ee1 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 @@ -3,17 +3,19 @@ * environments, SSR as the server fn provider): edits of server fn factories * and handlers reach every environment without a restart. */ +import react from '@vitejs/plugin-react' import { describe, expect, test } from 'vitest' import { describeHmr, evaluateServed, } from '@tanstack/compiler-test-utils/dev-server' +import { requestsOf, runInRequestContext } from './build-harness' import { callServerFnById, - requestsOf, - runInRequestContext, -} from './build-harness' -import { servedStartRuntime, startDevServer } from './dev-server' + servedStartRuntime, + startDevServer, + startServerEntryFiles, +} from './dev-server' const timeout = 30_000 @@ -74,7 +76,10 @@ describe('Vite dev: server fns by id', () => { 'SSR: a server fn of a module compiled after the resolver first loaded runs by id', async () => { const server = await startDevServer({ + start: {}, + realRouter: true, files: { + ...startServerEntryFiles, 'src/first.ts': `import { createServerFn } from '@tanstack/react-start' export const first = createServerFn().handler(() => 'MARK_FIRST') `, @@ -83,13 +88,10 @@ export const later = createServerFn().handler(() => 'MARK_LATER') `, }, }) - /** Calls the server fn `name` of `url` the way an HTTP call does: by id. */ + /** Calls the server fn `name` of `url` the way a page 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 callServerFnById(getServerFnById, fn.serverFnMeta.id) + return callServerFnById(server, fn.serverFnMeta.id) } expect(await callById('/src/first.ts', 'first')).toBe('MARK_FIRST') expect(await callById('/src/later.ts', 'later')).toBe('MARK_LATER') @@ -170,10 +172,17 @@ export const greet = createServerFn().handler(() => 'MARK_HANDLER') .getServerFnById // SSR's resolver is the test's module, which answers by itself. const forwarded = await (await resolver('ssr'))(id, { origin: 'client' }) + // The provider's resolver gives the server fn of the client id. (No + // HTTP call: the server entry that handles one in such a setup is the + // framework's, which this compiler-only setup has none of.) + const provided = await (await resolver('rsc'))(id, { origin: 'client' }) expect({ ssr: forwarded(), - rsc: await callServerFnById(await resolver('rsc'), id), - }).toEqual({ ssr: `MARK_FORWARDED:${id}`, rsc: 'MARK_HANDLER' }) + rsc: await runInRequestContext(() => provided({})), + }).toEqual({ + ssr: `MARK_FORWARDED:${id}`, + rsc: expect.objectContaining({ result: 'MARK_HANDLER' }), + }) }, timeout, ) @@ -188,20 +197,20 @@ describe('Vite dev: editing a server fn handler', () => { 'SSR: the server fn resolver runs the edited handler under the same id, without a restart', async () => { const server = await startDevServer({ + start: {}, + realRouter: true, files: { + ...startServerEntryFiles, 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start' export const greet = createServerFn().handler(() => 'before') `, }, }) - /** Calls `greet` the way the server-fn router does: by id. */ + /** Calls `greet` the way a page does: by id. */ const call = async () => { const { greet } = await server.import('/src/fns.ts') - const { getServerFnById } = await server.import( - '#tanstack-start-server-fn-resolver', - ) const id = greet.serverFnMeta.id - return { id, result: await callServerFnById(getServerFnById, id) } + return { id, result: await callServerFnById(server, id) } } const before = await call() expect(before.result).toBe('before') @@ -227,7 +236,10 @@ export const greet = createServerFn().handler(() => 'after') 'SSR: an edit of a module only the handler reads does not fully reload, and the handler runs the new code', async () => { const server = await startDevServer({ + start: {}, + realRouter: true, files: { + ...startServerEntryFiles, 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start' import { db } from './db.server' export const greet = createServerFn().handler(() => db.x()) @@ -237,10 +249,7 @@ export const greet = createServerFn().handler(() => db.x()) }) const call = async () => { const { greet } = await server.import('/src/fns.ts') - const { getServerFnById } = await server.import( - '#tanstack-start-server-fn-resolver', - ) - return callServerFnById(getServerFnById, greet.serverFnMeta.id) + return callServerFnById(server, greet.serverFnMeta.id) } expect(await call()).toBe('before') @@ -362,22 +371,31 @@ export const fn = createServerFn().handler(async () => 'MARK_HANDLER_${version}' export const label = () => 'MARK_CLIENT_LABEL' ` const entry = `export { fn, label } from './fns'\n` + /** Start's client entry, which the client bundle starts from, loads `src/entry.ts`. */ + const bundledClientEntry = { + 'harness/client-entry.ts': `export * from '../src/entry'\n`, + } - /** Calls `fn` in SSR the way an HTTP call does: through the resolver, by its id. */ + /** Calls `fn` the way a page does: by its id. */ async function callById(server: Awaited>) { const { fn } = await server.import('/src/fns.ts') - const { getServerFnById } = await server.import( - '#tanstack-start-server-fn-resolver', - ) - return callServerFnById(getServerFnById, fn.serverFnMeta.id) + return callServerFnById(server, fn.serverFnMeta.id) } test( 'the client bundle has the client code and not the handler, and SSR runs the handler by id', async () => { const server = await startDevServer({ + start: {}, + realRouter: true, + plugins: [react()], bundledDev: { entry: 'src/entry.ts' }, - files: { 'src/fns.ts': fns('V1'), 'src/entry.ts': entry }, + files: { + ...startServerEntryFiles, + ...bundledClientEntry, + 'src/fns.ts': fns('V1'), + 'src/entry.ts': entry, + }, }) const bundle = await server.clientBundle() expect({ @@ -420,8 +438,16 @@ export const label = () => 'MARK_CLIENT_LABEL' 'SSR runs an edited handler, called directly and by id', async () => { const server = await startDevServer({ + start: {}, + realRouter: true, + plugins: [react()], bundledDev: { entry: 'src/entry.ts' }, - files: { 'src/fns.ts': fns('V1'), 'src/entry.ts': entry }, + files: { + ...startServerEntryFiles, + ...bundledClientEntry, + 'src/fns.ts': fns('V1'), + 'src/entry.ts': entry, + }, }) expect(await callById(server)).toBe('MARK_HANDLER_V1') 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 index fabb3abb3a7..979c7e17604 100644 --- a/packages/start-plugin-core/tests/start-router-dev-server.test.ts +++ b/packages/start-plugin-core/tests/start-router-dev-server.test.ts @@ -154,9 +154,8 @@ describe('Vite dev: route HMR in a real page', () => { // HMR client, React Refresh and the route HMR code apply them. The edited // page route shows its new loader data and component without a full // reload, and its split component keeps its state (no remount). - // The page is the app's `index.html`, rendered on the client: Start's SSR - // document needs the real `@tanstack/react-start`, which depends on this - // package. Start's SSR with HMR in a real app: e2e/react-start/hmr. + // The page is the app's `index.html`, rendered on the client. Start's SSR + // with HMR in a real app: e2e/react-start/hmr. // Source: mutation round 3 start-router I/J (spc:6046). test( 'an edited page route shows its new loader and component without a full reload, and its split component keeps its state', From fddb59070c04da7c19ca3b8964fd2bd3d12ea2c5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 15:40:34 +0200 Subject: [PATCH 0410/1425] test(compiler-test-utils): Rsbuild dev harness reads each compile before Rspack drops it With Start's real runtime (real-start-build) the Rsbuild dev tests of start-plugin-core became flaky (baseline ac3f8d5ac9: 8 of 8 runs of tests/rsbuild/dev-server.test.ts green; real-start-build 953b24f622: 1 failure in 6; the merge: 1 in 11). Two causes, both in the shared harness: - "Rsbuild failed to compile the app: Unable to access compilation with id = CompilationId(5) now": `onDevCompileDone` runs after the compile ended; when a later compile already started (Start rewrites its server fn resolver module, which the watcher reports), Rspack has dropped the compilation and reading `builtModules` throws. The harness now skips such a report: the running compile's end reports instead. - "the client's hot update has the edited client code ...": `label` never showed up within 10 s. `hotUpdates()` read the hot-update chunks from disk, skipping those a later compile removed. It now keeps their code, read in each environment compiler's `done` hook while the compilation is alive. `hotUpdates()` keeps its contract (the hot-update chunks of the compiles since the last `edit()` began). 8 of 8 runs green afterwards. Co-Authored-By: Claude Opus 5.5 --- .../src/rsbuild-dev-server.ts | 105 +++++++++++------- 1 file changed, 65 insertions(+), 40 deletions(-) diff --git a/packages/compiler-test-utils/src/rsbuild-dev-server.ts b/packages/compiler-test-utils/src/rsbuild-dev-server.ts index 6fc6c8ea448..c29c5afcec5 100644 --- a/packages/compiler-test-utils/src/rsbuild-dev-server.ts +++ b/packages/compiler-test-utils/src/rsbuild-dev-server.ts @@ -138,48 +138,77 @@ export async function createRsbuildDevServer( const compiles = trackCompiles() /** Output directory of each environment. */ const distPaths: Record = {} - /** Hot-update chunks of the compiles since the last edit, by environment. */ - const hotUpdateFiles: Record> = {} + /** + * Hot-update chunks (code by file) of the compiles since the last edit, + * by environment compiler. Read when each compile ends: a later compile + * may remove them from the output directory. + */ + const hotUpdateChunks = new Map>() + /** The environment compiler of each environment name. */ + const compilerOf: Record = {} const harnessPlugin: RsbuildPlugin = { name: 'rsbuild-dev-server-harness', setup(api) { api.onAfterCreateCompiler(({ compiler }) => { compiles.track(compiler) + for (const environmentCompiler of 'compilers' in compiler + ? compiler.compilers + : [compiler]) { + const chunks: Record = {} + hotUpdateChunks.set(environmentCompiler, chunks) + // Before Rsbuild's own `done` taps, which report the end of the + // compile (`onDevCompileDone`) + environmentCompiler.hooks.done.tap( + { name: 'rsbuild-dev-server-harness', stage: -100 }, + ({ compilation }) => { + for (const asset of compilation.getAssets()) { + if (hotUpdateChunk.test(asset.name)) { + chunks[asset.name] = asset.source.source().toString() + } + } + }, + ) + } }) api.onDevCompileDone(({ stats, environments }) => { for (const [name, environment] of Object.entries(environments)) { distPaths[name] = environment.distPath } const all = 'stats' in stats ? stats.stats : [stats] - rebuilt.length = 0 - errors.length = 0 - warnings.length = 0 const names = Object.keys(environments) - all.forEach((environmentStats, index) => { - const { compilation } = environmentStats - const name = compilation.name ?? names[index]! - const files = (hotUpdateFiles[name] ??= new Set()) - for (const asset of compilation.getAssets()) { - if (hotUpdateChunk.test(asset.name)) { - files.add(asset.name) + const compilers = [...hotUpdateChunks.keys()] + const built: Array = [] + const compileErrors: Array = [] + const compileWarnings: Array = [] + try { + all.forEach((environmentStats, index) => { + const { compilation } = environmentStats + compilerOf[compilation.name ?? names[index]!] = compilers[index]! + for (const module of compilation.builtModules) { + const resource = (module as { resource?: string }).resource + if (resource) { + built.push(path.relative(root, resource)) + } } - } - }) - for (const environmentStats of all) { - const { compilation } = environmentStats - for (const module of compilation.builtModules) { - const resource = (module as { resource?: string }).resource - if (resource) { - rebuilt.push(path.relative(root, resource)) + for (const error of compilation.errors) { + compileErrors.push(error.message) } + for (const warning of compilation.warnings) { + compileWarnings.push(warning.message) + } + }) + } catch (error) { + // Rspack dropped the compilation: the next compile, which an edit + // made during this one started (e.g. a plugin wrote a file the + // watcher reports), is running, and its end reports instead. + if (/Unable to access compilation/.test(String(error))) { + return } - for (const error of compilation.errors) { - errors.push(error.message) - } - for (const warning of compilation.warnings) { - warnings.push(warning.message) - } + throw error } + rebuilt.splice(0, rebuilt.length, ...built) + errors.splice(0, errors.length, ...compileErrors) + warnings.splice(0, warnings.length, ...compileWarnings) compiles.done() }) }, @@ -266,8 +295,10 @@ export async function createRsbuildDevServer( rsbuild, url, edit: (files) => { - for (const name of Object.keys(hotUpdateFiles)) { - hotUpdateFiles[name]!.clear() + for (const chunks of hotUpdateChunks.values()) { + for (const file of Object.keys(chunks)) { + delete chunks[file] + } } return compiles.edit(root, files, compileTimeout) }, @@ -277,18 +308,12 @@ export async function createRsbuildDevServer( Object.entries(files).filter(([file]) => !hotUpdate.test(file)), ) }, - hotUpdates: async (environment) => { - const distPath = distPathOf(environment) - const name = environment ?? Object.keys(distPaths)[0]! - const files: Record = {} - for (const file of hotUpdateFiles[name] ?? []) { - try { - files[file] = await readFile(path.join(distPath, file), 'utf8') - } catch { - // Removed by a later compile - } - } - return files + hotUpdates: (environment) => { + distPathOf(environment) + const compiler = compilerOf[environment ?? Object.keys(distPaths)[0]!] + return Promise.resolve({ + ...(compiler && hotUpdateChunks.get(compiler)), + }) }, rebuilt, errors, From c81aadf7971fc3bdad637656d0e98c656f2172ae Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 15:55:49 +0200 Subject: [PATCH 0411/1425] test(start-plugin-core): type the server-fn file pick of the by-id build `files` of the shared construct build has no index signature (TS7053 under TypeScript 5.7 to 5.9). Co-Authored-By: Claude Opus 5.5 --- .../tests/start-build-env-separation.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 a13d3768633..919362246a7 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 @@ -538,7 +538,12 @@ const next = (options) => options const { client, ssr } = await buildStart({ bundler, files: { - ...Object.fromEntries(fnFiles.map((file) => [file, files[file]!])), + ...Object.fromEntries( + fnFiles.map((file) => [ + file, + (files as Record)[file]!, + ]), + ), 'src/ssr-entry.ts': `export * from './fns' ${callByIdExports}`, }, From 0517895892bc941ced97ca563b9d87312160fcce Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 18:00:16 +0200 Subject: [PATCH 0412/1425] build: one sass in the workspace so every package resolves the same vite vite's optional peers `sass` and `sass-embedded` are resolved from whatever versions exist anywhere in the lockfile graph. Two sass versions were in the graph: 1.97.2 (the `^1.97.2` devDependency of the three Start css-modules e2e apps) and 1.105.1 (pulled by `sass-embedded`, which `@rsbuild/plugin-sass` brings into e2e/react-start/css-modules). Importers resolved at different times got different vite peer variants: compiler-test- utils, react-start-rsc and eslint-plugin-start got vite(sass@1.105.1), the rest vite(sass@1.97.2). Two copies of vite break Start's `isRunnableDevEnvironment` (instanceof) check, so Start's dev server middleware never ran in the compiler test dev servers. The css-modules e2e apps now ask for sass ^1.105.1, the version sass-embedded already uses; the graph has one sass and one vite@8.3.4 (esbuild 0.28.2) variant. With Start's dev middleware working, the client-rendered route HMR test turns it off explicitly (it serves index.html, not Start's SSR); the Start dev-server harness accepts the Vite plugin options for that. Co-Authored-By: Claude Opus 5.5 --- e2e/react-start/css-modules/package.json | 2 +- e2e/solid-start/css-modules/package.json | 2 +- e2e/vue-start/css-modules/package.json | 2 +- .../start-plugin-core/tests/dev-server.ts | 6 +- .../tests/start-router-dev-server.test.ts | 8 +- pnpm-lock.yaml | 2218 ++++++++--------- 6 files changed, 1029 insertions(+), 1209 deletions(-) diff --git a/e2e/react-start/css-modules/package.json b/e2e/react-start/css-modules/package.json index 3501ba52e01..93f9dadd74e 100644 --- a/e2e/react-start/css-modules/package.json +++ b/e2e/react-start/css-modules/package.json @@ -47,7 +47,7 @@ "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react": "^6.0.1", "nitro": "^3.0.260903-beta", - "sass": "^1.97.2", + "sass": "^1.105.1", "srvx": "^1.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", "vite": "^8.0.14", diff --git a/e2e/solid-start/css-modules/package.json b/e2e/solid-start/css-modules/package.json index 107659f43c2..e3812ad8f6b 100644 --- a/e2e/solid-start/css-modules/package.json +++ b/e2e/solid-start/css-modules/package.json @@ -22,7 +22,7 @@ "@playwright/test": "^1.61.0", "@tanstack/router-e2e-utils": "workspace:*", "@types/node": "^22.10.2", - "sass": "^1.97.2", + "sass": "^1.105.1", "srvx": "^1.0.0", "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/e2e/vue-start/css-modules/package.json b/e2e/vue-start/css-modules/package.json index 36717746443..03303f69214 100644 --- a/e2e/vue-start/css-modules/package.json +++ b/e2e/vue-start/css-modules/package.json @@ -23,7 +23,7 @@ "@tanstack/router-e2e-utils": "workspace:*", "@types/node": "^22.10.2", "@vitejs/plugin-vue-jsx": "^5.1.5", - "sass": "^1.97.2", + "sass": "^1.105.1", "srvx": "^1.0.0", "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts index 2c726bee11b..c604a727ddc 100644 --- a/packages/start-plugin-core/tests/dev-server.ts +++ b/packages/start-plugin-core/tests/dev-server.ts @@ -26,7 +26,7 @@ import { import { createClientRpc, startClientCore } from './start-runtime' import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' import type { StartCompilerPluginOptions } from '../src/vite/start-compiler-plugin/plugin' -import type { TanStackStartInputConfig } from '../src/schema' +import type { TanStackStartViteInputConfig } from '../src/vite/schema' import type { StartCompilerImportTransform } from '../src/types' import type { PluginOption } from 'vite' import type { @@ -81,7 +81,7 @@ export interface StartDevServerOptions extends Omit< * empty, and `src/router.ts` and `src/routes/__root.tsx` are stubs unless * the app has them. */ - start?: TanStackStartInputConfig + start?: TanStackStartViteInputConfig /** * With `start`: external import transforms of the Start compiler, which a * framework package passes to Start's plugin (e.g. React's RSC CSS @@ -153,7 +153,7 @@ export const Route = createRootRoute() /** Start's Vite plugin with the core options `@tanstack/react-start` passes. */ export function startPlugin( root: string, - start: TanStackStartInputConfig, + start: TanStackStartViteInputConfig, compilerTransforms: Array | undefined, ) { return tanStackStartVite( 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 index 979c7e17604..38291060e01 100644 --- a/packages/start-plugin-core/tests/start-router-dev-server.test.ts +++ b/packages/start-plugin-core/tests/start-router-dev-server.test.ts @@ -154,8 +154,10 @@ describe('Vite dev: route HMR in a real page', () => { // HMR client, React Refresh and the route HMR code apply them. The edited // page route shows its new loader data and component without a full // reload, and its split component keeps its state (no remount). - // The page is the app's `index.html`, rendered on the client. Start's SSR - // with HMR in a real app: e2e/react-start/hmr. + // The page is the app's `index.html`, rendered on the client: Start's dev + // server middleware, which would answer the page request with the app's + // server entry, is off. Start's SSR with HMR in a real app: + // e2e/react-start/hmr. // Source: mutation round 3 start-router I/J (spc:6046). test( 'an edited page route shows its new loader and component without a full reload, and its split component keeps its state', @@ -171,7 +173,7 @@ function Page() { export const Route = createFileRoute('/')({ loader: () => '${loader}', component: Page }) ` const server = await startDevServer({ - start: {}, + start: { vite: { installDevServerMiddleware: false } }, realRouter: true, browser: true, // Pre-bundled up front, as Vite does for an app that installed them diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97540558455..1e3b610140c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,7 +200,7 @@ importers: version: typescript@5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) benchmarks/bundle-size: dependencies: @@ -261,22 +261,22 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) benchmarks/client-nav: dependencies: @@ -310,7 +310,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^6.0.1 - version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) + version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) '@platformatic/flame': specifier: ^1.6.0 version: 1.6.0 @@ -334,13 +334,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -349,13 +349,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) benchmarks/memory/client: dependencies: @@ -386,7 +386,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^6.0.1 - version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) + version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) '@datadog/pprof': specifier: ^5.13.2 version: 5.13.2 @@ -410,13 +410,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: 29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -425,13 +425,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) benchmarks/memory/server: dependencies: @@ -468,7 +468,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^6.0.1 - version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) + version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) '@datadog/pprof': specifier: ^5.13.2 version: 5.13.2 @@ -483,10 +483,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: 29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -495,13 +495,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) benchmarks/ssr: dependencies: @@ -538,7 +538,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^6.0.1 - version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) + version: 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4) '@types/react': specifier: ^19.2.8 version: 19.2.9 @@ -547,10 +547,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: 29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -562,13 +562,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/e2e-utils: devDependencies: @@ -583,13 +583,13 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/eslint-plugin-start: devDependencies: @@ -656,16 +656,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -699,10 +699,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-esbuild-file-based: dependencies: @@ -748,7 +748,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -788,19 +788,19 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: ^1.1.11 version: 1.1.11 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-file-based-code-splitting: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -840,16 +840,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-react-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -889,16 +889,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-react-query-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -944,16 +944,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-scroll-restoration: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -990,19 +990,19 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: ~5.9.0 version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-virtual-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1045,16 +1045,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/basic-virtual-named-export-config-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1097,10 +1097,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/escaped-special-strings: dependencies: @@ -1131,16 +1131,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/generator-cli-only: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1177,16 +1177,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/i18n-paraglide: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1226,13 +1226,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/issue-4759: dependencies: @@ -1260,10 +1260,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/issue-7120: dependencies: @@ -1294,10 +1294,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/issue-7457: dependencies: @@ -1331,16 +1331,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/js-only-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1377,10 +1377,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/match-params: dependencies: @@ -1408,7 +1408,7 @@ importers: version: 19.2.3(@types/react@19.2.9) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/react-compiler: dependencies: @@ -1430,7 +1430,7 @@ importers: version: 1.63.0 '@rolldown/plugin-babel': specifier: ^0.2.0 - version: 0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -1442,13 +1442,13 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/rspack-basic-file-based: dependencies: @@ -1567,7 +1567,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1607,10 +1607,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/sentry-integration: dependencies: @@ -1625,7 +1625,7 @@ importers: version: 4.6.1(supports-color@10.2.2) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1659,16 +1659,16 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-router/view-transitions: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1711,13 +1711,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/basic: dependencies: @@ -1769,7 +1769,7 @@ importers: version: 4.2.2 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -1790,7 +1790,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -1805,7 +1805,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) zod: specifier: ^4.4.3 version: 4.4.3 @@ -1851,7 +1851,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -1869,7 +1869,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -1884,7 +1884,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/basic-cloudflare: dependencies: @@ -1906,13 +1906,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.0 - version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) + version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) '@playwright/test': specifier: ^1.63.0 version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -1930,7 +1930,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -1939,7 +1939,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -1985,7 +1985,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2003,7 +2003,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -2015,7 +2015,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/basic-tsr-config: dependencies: @@ -2058,7 +2058,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/clerk-basic: dependencies: @@ -2092,7 +2092,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2110,7 +2110,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -2119,7 +2119,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/csp: dependencies: @@ -2162,7 +2162,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/css-inline: dependencies: @@ -2211,13 +2211,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/css-modules: dependencies: @@ -2236,7 +2236,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.0 - version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) + version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) '@playwright/test': specifier: ^1.63.0 version: 1.63.0 @@ -2266,13 +2266,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260903-beta version: 3.0.260903-beta sass: - specifier: ^1.97.2 - version: 1.97.2 + specifier: ^1.105.1 + version: 1.105.1 srvx: specifier: ^1.0.0 version: 1.0.5 @@ -2281,7 +2281,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -2315,7 +2315,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2336,7 +2336,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) cross-env: specifier: ^10.0.0 version: 10.0.0 @@ -2354,7 +2354,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/custom-server-rsbuild: dependencies: @@ -2458,7 +2458,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -2467,7 +2467,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/dev-ssr-styles: dependencies: @@ -2507,7 +2507,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260903-beta version: 3.0.260903-beta @@ -2519,7 +2519,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) zod: specifier: ^4.4.3 version: 4.4.3 @@ -2562,7 +2562,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) node-forge: specifier: ^1.3.1 version: 1.3.1 @@ -2577,7 +2577,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/flamegraph-bench: dependencies: @@ -2605,7 +2605,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) autocannon: specifier: ^8.0.0 version: 8.0.0 @@ -2620,7 +2620,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/hmr: dependencies: @@ -2651,7 +2651,7 @@ importers: version: 4.2.2 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2669,7 +2669,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -2678,7 +2678,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/i18n-paraglide: dependencies: @@ -2706,7 +2706,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2724,7 +2724,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -2733,7 +2733,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/import-protection: dependencies: @@ -2779,7 +2779,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -2788,7 +2788,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/import-protection-custom-config: dependencies: @@ -2825,7 +2825,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -2834,7 +2834,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/issue-8049: dependencies: @@ -2865,10 +2865,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/query-integration: dependencies: @@ -2908,7 +2908,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -2926,7 +2926,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -2935,7 +2935,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/response-reconciliation: dependencies: @@ -2975,7 +2975,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -2984,7 +2984,7 @@ importers: version: 6.0.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/root-document-hydration: dependencies: @@ -3021,7 +3021,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3030,7 +3030,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/rsbuild: dependencies: @@ -3122,10 +3122,10 @@ importers: version: 8.44.1(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.2) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: ^0.5.37 - version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint: specifier: ^9.22.0 version: 9.22.0(jiti@2.7.0)(supports-color@10.2.2) @@ -3137,7 +3137,7 @@ importers: version: 5.9.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/rsc-deferred-hydration: dependencies: @@ -3174,10 +3174,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: ^0.5.37 - version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3186,7 +3186,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/rsc-query: dependencies: @@ -3232,10 +3232,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: ^0.5.37 - version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3244,7 +3244,7 @@ importers: version: 5.9.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/rsc-rsbuild: dependencies: @@ -3321,7 +3321,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3339,7 +3339,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -3354,7 +3354,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/selective-ssr: dependencies: @@ -3379,7 +3379,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3397,7 +3397,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3409,7 +3409,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/serialization-adapters: dependencies: @@ -3434,7 +3434,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3452,7 +3452,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3464,7 +3464,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/server-functions: dependencies: @@ -3516,7 +3516,7 @@ importers: version: 4.2.2 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3537,7 +3537,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -3552,7 +3552,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/server-functions-global-middleware: dependencies: @@ -3574,7 +3574,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3592,7 +3592,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3604,7 +3604,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/server-routes: dependencies: @@ -3647,7 +3647,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3668,7 +3668,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -3683,7 +3683,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/server-routes-global-middleware: dependencies: @@ -3705,7 +3705,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3723,7 +3723,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3735,7 +3735,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/session-handling: dependencies: @@ -3787,7 +3787,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3796,7 +3796,7 @@ importers: version: 6.0.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/spa-mode: dependencies: @@ -3818,7 +3818,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -3842,7 +3842,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/split-base-and-basepath: dependencies: @@ -3882,7 +3882,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -3891,7 +3891,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/start-manifest: dependencies: @@ -3928,7 +3928,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260903-beta version: 3.0.260903-beta @@ -3937,7 +3937,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/static-server-functions: dependencies: @@ -3980,13 +3980,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/streaming-ssr: dependencies: @@ -4029,7 +4029,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -4038,7 +4038,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/transform-asset-urls: dependencies: @@ -4078,7 +4078,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -4087,7 +4087,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/virtual-routes: dependencies: @@ -4124,7 +4124,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -4142,7 +4142,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -4157,7 +4157,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/react-start/website: dependencies: @@ -4191,7 +4191,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -4209,7 +4209,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -4221,7 +4221,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/solid-router/basepath-file-based: dependencies: @@ -4246,16 +4246,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: workspace:^ version: link:../../../packages/solid-router @@ -4280,10 +4280,10 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-esbuild-file-based: dependencies: @@ -4323,7 +4323,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4357,16 +4357,16 @@ importers: version: 1.1.11 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-file-based-code-splitting: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4397,16 +4397,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-scroll-restoration: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: workspace:^ version: link:../../../packages/solid-router @@ -4437,16 +4437,16 @@ importers: version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-solid-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -4477,16 +4477,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-solid-query-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4523,16 +4523,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-virtual-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4566,16 +4566,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-virtual-named-export-config-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4609,16 +4609,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/generator-cli-only: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-cli': specifier: workspace:* version: link:../../../packages/router-cli @@ -4646,16 +4646,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/js-only-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: workspace:^ version: link:../../../packages/solid-router @@ -4683,10 +4683,10 @@ importers: version: link:../../../packages/router-plugin vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/rspack-basic-file-based: dependencies: @@ -4793,7 +4793,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4824,10 +4824,10 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/sentry-integration: dependencies: @@ -4842,7 +4842,7 @@ importers: version: 4.6.1(supports-color@7.2.0) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: workspace:^ version: link:../../../packages/solid-router @@ -4867,16 +4867,16 @@ importers: version: link:../../e2e-utils vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/view-transitions: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -4913,10 +4913,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic: dependencies: @@ -4968,7 +4968,7 @@ importers: version: 4.2.2 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -4995,10 +4995,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic-auth: dependencies: @@ -5038,7 +5038,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5065,10 +5065,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic-cloudflare: dependencies: @@ -5087,13 +5087,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.0 - version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) + version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) '@playwright/test': specifier: ^1.63.0 version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5111,10 +5111,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -5157,7 +5157,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5178,10 +5178,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic-tsr-config: dependencies: @@ -5218,10 +5218,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/csp: dependencies: @@ -5255,10 +5255,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/css-modules: dependencies: @@ -5285,8 +5285,8 @@ importers: specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 sass: - specifier: ^1.97.2 - version: 1.97.2 + specifier: ^1.105.1 + version: 1.105.1 srvx: specifier: ^1.0.0 version: 1.0.5 @@ -5295,10 +5295,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/custom-basepath: dependencies: @@ -5326,7 +5326,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5356,10 +5356,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/deferred-hydration: dependencies: @@ -5399,10 +5399,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/dev-ssr-styles: dependencies: @@ -5439,10 +5439,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/query-integration: dependencies: @@ -5479,7 +5479,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5497,10 +5497,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/scroll-restoration: dependencies: @@ -5531,7 +5531,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5555,10 +5555,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/selective-ssr: dependencies: @@ -5577,7 +5577,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5595,10 +5595,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/serialization-adapters: dependencies: @@ -5620,7 +5620,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5641,10 +5641,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/server-functions: dependencies: @@ -5684,7 +5684,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5711,10 +5711,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/server-routes: dependencies: @@ -5754,7 +5754,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5781,10 +5781,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/solid-query-layout-suspense: dependencies: @@ -5818,10 +5818,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/spa-mode: dependencies: @@ -5843,7 +5843,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5858,10 +5858,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/start-manifest: dependencies: @@ -5895,10 +5895,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/streaming-ssr: dependencies: @@ -5938,10 +5938,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/virtual-routes: dependencies: @@ -5975,7 +5975,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -5999,10 +5999,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/website: dependencies: @@ -6033,7 +6033,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -6054,16 +6054,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/vue-router/basepath-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6097,16 +6097,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6115,7 +6115,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-router': specifier: workspace:* version: link:../../../packages/vue-router @@ -6140,13 +6140,13 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6198,7 +6198,7 @@ importers: version: 0.27.4 esbuild-plugin-vue3: specifier: ^0.5.1 - version: 0.5.1(cheerio@1.0.0)(sass@1.97.2)(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 0.5.1(cheerio@1.0.0)(sass@1.105.1)(vue@3.5.25(@typescript/typescript6@6.0.2)) eslint-plugin-vue: specifier: ^9.33.0 version: 9.33.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -6216,7 +6216,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6256,10 +6256,10 @@ importers: version: 8.44.1(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.8.3) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) eslint-plugin-vue: specifier: ^9.33.0 version: 9.33.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -6268,7 +6268,7 @@ importers: version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-eslint-parser: specifier: ^9.4.3 version: 9.4.3(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -6280,7 +6280,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6311,16 +6311,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6329,7 +6329,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-router': specifier: workspace:* version: link:../../../packages/vue-router @@ -6357,16 +6357,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) typescript: specifier: ~5.9.0 version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.9.3) @@ -6375,7 +6375,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6412,16 +6412,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6430,7 +6430,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6467,16 +6467,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6485,7 +6485,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-query': specifier: ^5.102.0 version: 5.102.0(vue@3.5.25(typescript@5.8.3)) @@ -6516,16 +6516,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6534,7 +6534,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6571,16 +6571,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6589,7 +6589,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-cli': specifier: workspace:* version: link:../../../packages/router-cli @@ -6617,16 +6617,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6635,7 +6635,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6669,13 +6669,13 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-router/rspack-basic-file-based: dependencies: @@ -6797,7 +6797,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6828,16 +6828,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6855,7 +6855,7 @@ importers: version: 10.32.0(@tanstack/vue-router@packages+vue-router)(vue@3.5.25(typescript@5.8.3)) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-router': specifier: workspace:* version: link:../../../packages/vue-router @@ -6880,13 +6880,13 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6895,7 +6895,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -6929,16 +6929,16 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -6996,7 +6996,7 @@ importers: version: 4.2.2 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7011,10 +7011,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7029,7 +7029,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/basic-auth: dependencies: @@ -7069,7 +7069,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7081,10 +7081,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -7102,7 +7102,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/basic-cloudflare: dependencies: @@ -7121,13 +7121,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.0 - version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) + version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) '@playwright/test': specifier: ^1.63.0 version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7139,10 +7139,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -7151,7 +7151,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -7182,10 +7182,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) rimraf: specifier: 'catalog:' version: 6.1.2 @@ -7197,7 +7197,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/basic-vue-query: dependencies: @@ -7237,7 +7237,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7249,10 +7249,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7264,7 +7264,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/css-modules: dependencies: @@ -7292,10 +7292,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) sass: - specifier: ^1.97.2 - version: 1.97.2 + specifier: ^1.105.1 + version: 1.105.1 srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7304,7 +7304,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/custom-basepath: dependencies: @@ -7332,7 +7332,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7347,10 +7347,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) cross-env: specifier: ^10.0.0 version: 10.0.0 @@ -7368,7 +7368,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/dev-ssr-styles: dependencies: @@ -7399,10 +7399,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7411,7 +7411,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/query-integration: dependencies: @@ -7448,7 +7448,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7460,10 +7460,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -7472,7 +7472,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/scroll-restoration: dependencies: @@ -7503,7 +7503,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7515,10 +7515,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7533,7 +7533,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/selective-ssr: dependencies: @@ -7552,7 +7552,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7561,10 +7561,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7576,7 +7576,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(@typescript/typescript6@6.0.2) @@ -7601,7 +7601,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7610,10 +7610,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7625,7 +7625,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(@typescript/typescript6@6.0.2) @@ -7668,7 +7668,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7683,10 +7683,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7701,7 +7701,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/server-routes: dependencies: @@ -7741,7 +7741,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7756,10 +7756,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7774,7 +7774,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/spa-mode: dependencies: @@ -7796,7 +7796,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7805,10 +7805,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -7817,7 +7817,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/start-manifest: dependencies: @@ -7845,7 +7845,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7854,7 +7854,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/streaming-ssr: dependencies: @@ -7888,7 +7888,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7897,7 +7897,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/virtual-routes: dependencies: @@ -7931,7 +7931,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -7943,10 +7943,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7961,7 +7961,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) e2e/vue-start/website: dependencies: @@ -7992,7 +7992,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-e2e-utils': specifier: workspace:^ version: link:../../e2e-utils @@ -8004,10 +8004,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -8019,13 +8019,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/authenticated-routes: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8062,19 +8062,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/authenticated-routes-firebase: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8117,19 +8117,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8160,19 +8160,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-default-search-params: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -8209,19 +8209,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-devtools-panel: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -8258,19 +8258,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8307,19 +8307,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-non-nested-devtools: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8350,19 +8350,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-react-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -8399,19 +8399,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-react-query-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -8454,13 +8454,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-ssr-file-based: dependencies: @@ -8506,13 +8506,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-ssr-streaming-file-based: dependencies: @@ -8558,19 +8558,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-virtual-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8610,19 +8610,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/basic-virtual-inside-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8662,19 +8662,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/deferred-data: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8708,19 +8708,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/i18n-paraglide: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8754,19 +8754,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/kitchen-sink: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8803,19 +8803,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/kitchen-sink-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -8855,19 +8855,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/kitchen-sink-react-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -8910,19 +8910,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/kitchen-sink-react-query-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -8968,19 +8968,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/large-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -9020,13 +9020,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/location-masking: dependencies: @@ -9035,7 +9035,7 @@ importers: version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -9069,19 +9069,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/navigation-blocking: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -9115,19 +9115,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/quickstart: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -9155,13 +9155,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/quickstart-esbuild-file-based: dependencies: @@ -9201,7 +9201,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -9238,13 +9238,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/quickstart-rspack-file-based: dependencies: @@ -9385,16 +9385,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-react-query/packages/app: dependencies: @@ -9416,7 +9416,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router-devtools': specifier: workspace:^ version: link:../../../../../packages/react-router-devtools @@ -9431,7 +9431,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -9440,10 +9440,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-react-query/packages/post-feature: dependencies: @@ -9474,16 +9474,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-react-query/packages/post-query: dependencies: @@ -9502,13 +9502,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/router-monorepo-react-query/packages/router: dependencies: @@ -9551,16 +9551,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple: dependencies: @@ -9597,16 +9597,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple-lazy: dependencies: @@ -9643,16 +9643,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple-lazy/packages/app: dependencies: @@ -9671,7 +9671,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router-devtools': specifier: workspace:^ version: link:../../../../../packages/react-router-devtools @@ -9686,7 +9686,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -9695,10 +9695,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple-lazy/packages/post-feature: dependencies: @@ -9726,16 +9726,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple-lazy/packages/router: dependencies: @@ -9772,16 +9772,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple/packages/app: dependencies: @@ -9800,7 +9800,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router-devtools': specifier: workspace:^ version: link:../../../../../packages/react-router-devtools @@ -9815,7 +9815,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -9824,10 +9824,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple/packages/post-feature: dependencies: @@ -9852,16 +9852,16 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/router-monorepo-simple/packages/router: dependencies: @@ -9898,22 +9898,22 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/scroll-restoration: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -9944,19 +9944,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/search-validator-adapters: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/arktype-adapter': specifier: workspace:* version: link:../../../packages/arktype-adapter @@ -10014,7 +10014,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) jsdom: specifier: ^27.0.0 version: 27.0.0(supports-color@10.2.2) @@ -10023,10 +10023,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: ^5.0.4 - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/react/start-bare: dependencies: @@ -10063,13 +10063,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-basic: dependencies: @@ -10097,7 +10097,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10112,7 +10112,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260903-beta version: 3.0.260903-beta @@ -10124,7 +10124,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-basic-auth: dependencies: @@ -10164,7 +10164,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10179,7 +10179,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -10194,7 +10194,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-basic-authjs: dependencies: @@ -10225,7 +10225,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10240,7 +10240,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -10249,7 +10249,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-basic-cloudflare: dependencies: @@ -10271,10 +10271,10 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.0 - version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) + version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10289,7 +10289,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -10298,7 +10298,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -10341,7 +10341,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10356,7 +10356,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -10365,7 +10365,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-basic-rsbuild: dependencies: @@ -10438,7 +10438,7 @@ importers: version: link:../../../packages/start-static-server-functions '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) react: specifier: ^19.2.3 version: 19.2.3 @@ -10454,7 +10454,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10475,13 +10475,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-bun: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-devtools': specifier: ^0.7.0 version: 0.7.0(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12) @@ -10536,7 +10536,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) jsdom: specifier: ^27.0.0 version: 27.0.0(supports-color@10.2.2) @@ -10548,10 +10548,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: ^5.0.4 - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -10572,7 +10572,7 @@ importers: version: link:../../../packages/react-start '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) react: specifier: ^19.2.3 version: 19.2.3 @@ -10591,7 +10591,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10612,7 +10612,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-convex-trellaux: dependencies: @@ -10670,7 +10670,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10685,7 +10685,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -10694,7 +10694,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-counter: dependencies: @@ -10728,13 +10728,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-i18n-paraglide: dependencies: @@ -10762,7 +10762,7 @@ importers: version: 2.4.0(babel-plugin-macros@3.1.0) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10777,7 +10777,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -10786,7 +10786,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-large: dependencies: @@ -10820,7 +10820,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10835,7 +10835,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -10844,7 +10844,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-material-ui: dependencies: @@ -10896,13 +10896,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-next-migration: dependencies: @@ -10917,7 +10917,7 @@ importers: version: 9.0.1 next: specifier: 16.3.4 - version: 16.3.4(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.2) + version: 16.3.4(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.105.1) react: specifier: ^19.2.3 version: 19.2.3 @@ -10939,7 +10939,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260903-beta version: 3.0.260903-beta @@ -10948,7 +10948,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-rscs: dependencies: @@ -10982,7 +10982,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -10994,10 +10994,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: ^0.5.37 - version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260903-beta version: 3.0.260903-beta @@ -11009,7 +11009,7 @@ importers: version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-streaming-data-from-server-functions: dependencies: @@ -11046,13 +11046,13 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-supabase-basic: dependencies: @@ -11083,7 +11083,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/react': specifier: ^19.2.8 version: 19.2.9 @@ -11095,7 +11095,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -11104,7 +11104,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-tailwind-v4: dependencies: @@ -11132,7 +11132,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -11147,7 +11147,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -11156,7 +11156,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-trellaux: dependencies: @@ -11205,7 +11205,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -11220,7 +11220,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -11229,7 +11229,7 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/start-workos: dependencies: @@ -11266,19 +11266,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/view-transitions: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -11315,19 +11315,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/with-framer-motion: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -11364,19 +11364,19 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/with-trpc: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -11422,19 +11422,19 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tsx: specifier: ^4.20.3 version: 4.20.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/react/with-trpc-react-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.102.0 version: 5.102.0(react@19.2.3) @@ -11489,19 +11489,19 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) tsx: specifier: ^4.20.3 version: 4.20.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) examples/solid/authenticated-routes: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -11532,16 +11532,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/authenticated-routes-firebase: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -11578,16 +11578,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -11618,16 +11618,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-default-search-params: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -11658,16 +11658,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-devtools-panel: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -11692,16 +11692,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -11732,16 +11732,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-non-nested-devtools: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -11772,16 +11772,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-solid-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -11815,16 +11815,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-solid-query-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -11861,10 +11861,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-ssr-file-based: dependencies: @@ -11904,16 +11904,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-ssr-streaming-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -11959,16 +11959,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-virtual-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -12002,16 +12002,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-virtual-inside-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -12045,16 +12045,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/deferred-data: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -12082,16 +12082,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/i18n-paraglide: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -12119,16 +12119,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -12159,16 +12159,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -12202,16 +12202,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink-solid-query: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -12248,16 +12248,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink-solid-query-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -12297,16 +12297,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/large-file-based: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -12340,16 +12340,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/location-masking: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -12377,16 +12377,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/navigation-blocking: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-query': specifier: ^5.102.0 version: 5.102.0(solid-js@1.9.12) @@ -12414,16 +12414,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/quickstart: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -12445,10 +12445,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/quickstart-esbuild-file-based: dependencies: @@ -12485,7 +12485,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -12516,10 +12516,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/quickstart-rspack-file-based: dependencies: @@ -12651,13 +12651,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/router-monorepo-simple-lazy: dependencies: @@ -12688,13 +12688,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/router-monorepo-solid-query: dependencies: @@ -12731,19 +12731,19 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/scroll-restoration: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -12768,16 +12768,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/search-validator-adapters: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/arktype-adapter': specifier: workspace:* version: link:../../../packages/arktype-adapter @@ -12832,13 +12832,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: ^5.0.4 - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic: dependencies: @@ -12863,7 +12863,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -12881,10 +12881,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-auth: dependencies: @@ -12921,7 +12921,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -12942,10 +12942,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-authjs: dependencies: @@ -12973,7 +12973,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -12988,10 +12988,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-cloudflare: dependencies: @@ -13010,10 +13010,10 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.29.0 - version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) + version: 1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13028,10 +13028,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -13053,10 +13053,10 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.3.19 - version: 1.3.19(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 1.3.19(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13071,10 +13071,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-nitro: dependencies: @@ -13093,7 +13093,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13111,10 +13111,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-solid-query: dependencies: @@ -13148,7 +13148,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13163,10 +13163,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-static: dependencies: @@ -13194,7 +13194,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13209,16 +13209,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-bun: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -13273,13 +13273,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: ^5.0.4 - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -13291,7 +13291,7 @@ importers: version: 0.9.7(@standard-schema/spec@1.1.0)(@typescript/typescript6@6.0.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(@prisma/client@7.0.0(@typescript/typescript6@6.0.2)(prisma@7.0.0(@types/react@19.2.9)(@typescript/typescript6@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(@tanstack/solid-start@packages+solid-start)(mysql2@3.15.3)(next@16.3.4(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.105.1))(prisma@7.0.0(@types/react@19.2.9)(@typescript/typescript6@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@4.1.4)(vue@3.5.25(@typescript/typescript6@6.0.2)))(convex@1.28.2(@clerk/clerk-react@5.59.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(hono@4.13.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -13343,10 +13343,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-counter: dependencies: @@ -13386,10 +13386,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-i18n-paraglide: dependencies: @@ -13414,7 +13414,7 @@ importers: version: 2.4.0(babel-plugin-macros@3.1.0) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13429,10 +13429,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-large: dependencies: @@ -13463,7 +13463,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13478,10 +13478,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-streaming-data-from-server-functions: dependencies: @@ -13512,10 +13512,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-supabase-basic: dependencies: @@ -13543,7 +13543,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13558,10 +13558,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-tailwind-v4: dependencies: @@ -13586,7 +13586,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -13601,16 +13601,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/view-transitions: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -13641,16 +13641,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/with-framer-motion: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/solid-router': specifier: ^1.170.42 version: link:../../../packages/solid-router @@ -13681,16 +13681,16 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/with-trpc: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -13730,10 +13730,10 @@ importers: version: 4.20.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/vue/basic: dependencies: @@ -13755,22 +13755,22 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(@typescript/typescript6@6.0.2) @@ -13779,7 +13779,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -13813,10 +13813,10 @@ importers: version: 8.44.1(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.8.3) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) eslint-plugin-vue: specifier: ^9.33.0 version: 9.33.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -13825,7 +13825,7 @@ importers: version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-eslint-parser: specifier: ^9.4.3 version: 9.4.3(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -13837,7 +13837,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: workspace:* version: link:../../../packages/router-plugin @@ -13862,16 +13862,16 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue-tsc: specifier: ^3.3.8 version: 3.3.8(typescript@5.8.3) @@ -13886,7 +13886,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -13919,10 +13919,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/compiler-test-utils: dependencies: @@ -13966,7 +13966,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@typescript-eslint/rule-tester': specifier: ^8.23.0 version: 8.23.0(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -13984,10 +13984,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/eslint-plugin-start: dependencies: @@ -14030,7 +14030,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -14042,10 +14042,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/react-router: dependencies: @@ -14073,7 +14073,7 @@ importers: version: 5.102.0(react@19.2.3) '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -14091,7 +14091,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) combinate: specifier: 'catalog:' version: 1.1.11 @@ -14115,10 +14115,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) zod: specifier: 'catalog:' version: 4.5.4 @@ -14143,7 +14143,7 @@ importers: version: 1.26.2(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(ts-api-utils@2.4.0(@typescript/typescript6@6.0.2)) '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14155,7 +14155,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint-plugin-react-hooks: specifier: 'catalog:' version: 5.1.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2)) @@ -14176,10 +14176,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/react-router-ssr-query: dependencies: @@ -14204,7 +14204,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/react': specifier: ^19.2.8 version: 19.2.9 @@ -14213,7 +14213,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint-plugin-react-hooks: specifier: 'catalog:' version: 5.1.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2)) @@ -14234,10 +14234,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/react-start: dependencies: @@ -14270,7 +14270,7 @@ importers: version: 2.0.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -14283,7 +14283,7 @@ importers: version: 2.2.5 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14313,7 +14313,7 @@ importers: version: 6.1.2 vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/react-start-client: dependencies: @@ -14335,7 +14335,7 @@ importers: version: 1.26.2(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(ts-api-utils@2.4.0(@typescript/typescript6@6.0.2)) '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/react': specifier: 'catalog:' version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -14350,7 +14350,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint-plugin-react-hooks: specifier: 'catalog:' version: 5.1.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2)) @@ -14371,10 +14371,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/react-start-rsc: dependencies: @@ -14478,7 +14478,7 @@ importers: version: 1.26.2(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(ts-api-utils@2.4.0(@typescript/typescript6@6.0.2)) '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/react': specifier: ^19.2.8 version: 19.2.9 @@ -14490,7 +14490,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint-plugin-react-hooks: specifier: 'catalog:' version: 5.1.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2)) @@ -14514,10 +14514,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-cli: dependencies: @@ -14536,7 +14536,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14548,10 +14548,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-core: dependencies: @@ -14576,7 +14576,7 @@ importers: version: 0.11.2 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14594,10 +14594,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-devtools: dependencies: @@ -14619,7 +14619,7 @@ importers: version: 1.26.2(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(ts-api-utils@2.4.0(@typescript/typescript6@6.0.2)) '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/react': specifier: ^19.2.8 version: 19.2.9 @@ -14628,7 +14628,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint-plugin-react-hooks: specifier: 'catalog:' version: 5.1.0(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2)) @@ -14646,10 +14646,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-devtools-core: dependencies: @@ -14671,7 +14671,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -14689,13 +14689,13 @@ importers: version: 1.9.12 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-generator: dependencies: @@ -14732,7 +14732,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14750,10 +14750,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-plugin: dependencies: @@ -14783,10 +14783,10 @@ importers: version: 5.0.0 unplugin: specifier: ^3.3.0 - version: 3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3) + version: 3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) zod: specifier: 'catalog:' version: 4.5.4 @@ -14799,7 +14799,7 @@ importers: version: 7.28.5(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/plugin-babel': specifier: ^0.2.0 - version: 0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@rsbuild/core': specifier: 'catalog:' version: 2.2.5 @@ -14814,7 +14814,7 @@ importers: version: link:../solid-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-router': specifier: workspace:* version: link:../vue-router @@ -14835,10 +14835,10 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) babel-loader: specifier: ^10.0.0 version: 10.0.0(@babel/core@7.29.7(supports-color@10.2.2))(webpack@5.110.3) @@ -14868,10 +14868,10 @@ importers: version: 1.9.12 vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -14892,7 +14892,7 @@ importers: version: link:../router-core '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -14904,10 +14904,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/router-utils: dependencies: @@ -14941,7 +14941,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/babel__generator': specifier: ^7.27.0 version: 7.27.0 @@ -14956,7 +14956,7 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) packages/router-vite-plugin: dependencies: @@ -14969,7 +14969,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) publint: specifier: 'catalog:' version: 0.3.17 @@ -14978,10 +14978,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/solid-router: dependencies: @@ -15009,7 +15009,7 @@ importers: version: 0.8.10(solid-js@1.9.12) '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -15033,13 +15033,13 @@ importers: version: 1.9.12 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) zod: specifier: 'catalog:' version: 4.5.4 @@ -15061,7 +15061,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15076,13 +15076,13 @@ importers: version: 1.9.12 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/solid-router-ssr-query: dependencies: @@ -15104,7 +15104,7 @@ importers: version: link:../solid-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) eslint-plugin-solid: specifier: ^0.14.5 version: 0.14.5(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -15122,13 +15122,13 @@ importers: version: 1.9.12 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/solid-start: dependencies: @@ -15171,7 +15171,7 @@ importers: version: link:../router-utils '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15189,10 +15189,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/solid-start-client: dependencies: @@ -15214,7 +15214,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -15229,13 +15229,13 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/solid-start-server: dependencies: @@ -15254,7 +15254,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -15275,13 +15275,13 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/start-client-core: dependencies: @@ -15303,7 +15303,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15318,10 +15318,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/start-fn-stubs: devDependencies: @@ -15330,7 +15330,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15339,10 +15339,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/start-plugin-core: dependencies: @@ -15402,7 +15402,7 @@ importers: version: 1.6.4 vitefu: specifier: ^1.1.3 - version: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) xmlbuilder2: specifier: ^4.0.3 version: 4.0.3 @@ -15424,7 +15424,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/babel__code-frame': specifier: ^7.0.6 version: 7.0.6 @@ -15439,7 +15439,7 @@ importers: version: 4.0.2 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15454,10 +15454,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/start-server-core: dependencies: @@ -15491,7 +15491,7 @@ importers: version: 1.0.0 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15509,10 +15509,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/start-static-server-functions: dependencies: @@ -15534,7 +15534,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15549,10 +15549,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/start-storage-context: dependencies: @@ -15565,7 +15565,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15580,10 +15580,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/valibot-adapter: devDependencies: @@ -15595,7 +15595,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -15628,10 +15628,10 @@ importers: version: 1.0.0-beta.15(typescript@7.0.2) vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/virtual-file-routes: devDependencies: @@ -15640,7 +15640,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -15652,10 +15652,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) packages/vue-router: dependencies: @@ -15680,7 +15680,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -15692,7 +15692,7 @@ importers: version: 25.0.9 '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitest/ui': specifier: 'catalog:' version: 5.0.4(vitest@5.0.4) @@ -15710,10 +15710,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -15734,20 +15734,20 @@ importers: version: link:../vue-router vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15756,7 +15756,7 @@ importers: version: 6.1.2 vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -15778,13 +15778,13 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-router': specifier: workspace:* version: link:../vue-router '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15793,10 +15793,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -15836,13 +15836,13 @@ importers: version: link:../router-utils '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -15854,10 +15854,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -15879,10 +15879,10 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -15894,10 +15894,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -15919,13 +15919,13 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -15940,10 +15940,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: specifier: 'catalog:' version: 3.5.25(@typescript/typescript6@6.0.2) @@ -15958,7 +15958,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) @@ -15988,10 +15988,10 @@ importers: version: 6.1.2 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) zod: specifier: ^3.24.2 version: 3.25.57 @@ -25404,9 +25404,6 @@ packages: immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} - immutable@5.1.4: - resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} - immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -27795,11 +27792,6 @@ packages: engines: {node: '>=20.19.0'} hasBin: true - sass@1.97.2: - resolution: {integrity: sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==} - engines: {node: '>=14.0.0'} - hasBin: true - sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -30602,12 +30594,12 @@ snapshots: optionalDependencies: workerd: 1.20260317.1 - '@cloudflare/vite-plugin@1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0)': + '@cloudflare/vite-plugin@1.29.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(workerd@1.20260317.1)(wrangler@4.75.0)': dependencies: '@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1) miniflare: 4.20260317.0 unenv: 2.0.0-rc.24 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) wrangler: 4.75.0 ws: 8.18.0 transitivePeerDependencies: @@ -30640,12 +30632,12 @@ snapshots: - debug - supports-color - '@codspeed/vitest-plugin@6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4)': + '@codspeed/vitest-plugin@6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2)(tinybench@6.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vitest@5.0.4)': dependencies: '@codspeed/core': 6.0.1(debug@4.4.3(supports-color@7.2.0))(supports-color@10.2.2) tinybench: 6.2.2 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) transitivePeerDependencies: - debug - supports-color @@ -33045,10 +33037,10 @@ snapshots: '@netlify/types@3.0.0': {} - '@netlify/vite-plugin-tanstack-start@1.3.19(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@netlify/vite-plugin-tanstack-start@1.3.19(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: - '@netlify/vite-plugin': 3.0.1(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + '@netlify/vite-plugin': 3.0.1(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start transitivePeerDependencies: @@ -33079,12 +33071,12 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@3.0.1(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@netlify/vite-plugin@3.0.1(babel-plugin-macros@3.1.0)(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@netlify/dev': 5.1.0(db0@0.4.1)(ioredis@5.11.1(supports-color@10.2.2))(rollup@4.63.1)(srvx@1.0.5)(supports-color@10.2.2) '@netlify/dev-utils': 6.0.1 dedent: 1.7.2(babel-plugin-macros@3.1.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -34461,13 +34453,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.13': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0(supports-color@10.2.2) picomatch: 4.0.7 rolldown: 1.2.13 optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: @@ -34476,15 +34468,6 @@ snapshots: rolldown: 1.2.13 optionalDependencies: vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - optional: true - - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - picomatch: 4.0.7 - rolldown: 1.2.13 - optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) '@rolldown/pluginutils@1.0.0-rc.2': {} @@ -35375,7 +35358,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.2.2 '@tailwindcss/node@4.3.1': @@ -35385,7 +35368,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.1 '@tailwindcss/oxide-android-arm64@4.2.2': @@ -35498,12 +35481,12 @@ snapshots: postcss: 8.5.8 tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@tailwindcss/vite@4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) '@tailwindcss/webpack@4.3.1(@rspack/core@2.2.7(@swc/helpers@0.5.23))(webpack@5.110.3)': dependencies: @@ -35700,24 +35683,24 @@ snapshots: '@tanstack/virtual-core@3.13.13': {} - '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vite-plugin-dts: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite-plugin-externalize-deps: 0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite-tsconfig-paths: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-dts: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite-plugin-externalize-deps: 0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite-tsconfig-paths: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - rollup - supports-color - typescript - '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vite-plugin-dts: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite-plugin-externalize-deps: 0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite-tsconfig-paths: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-dts: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite-plugin-externalize-deps: 0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite-tsconfig-paths: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - rollup @@ -35736,18 +35719,6 @@ snapshots: - supports-color - typescript - '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': - dependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vite-plugin-dts: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite-plugin-externalize-deps: 0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - vite-tsconfig-paths: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - transitivePeerDependencies: - - '@types/node' - - rollup - - supports-color - - typescript - '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(supports-color@10.2.2)(typescript@5.9.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) @@ -35862,7 +35833,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@testing-library/react@16.2.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -36793,12 +36764,12 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) babel-plugin-react-compiler: 1.0.0 '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': @@ -36809,14 +36780,6 @@ snapshots: '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) babel-plugin-react-compiler: 1.0.0 - '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - babel-plugin-react-compiler: 1.0.0 - '@vitejs/plugin-rsc@0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -36832,126 +36795,111 @@ snapshots: vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - '@vitejs/plugin-rsc@0.5.37(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - es-module-lexer: 3.0.3 - estree-walker: 3.0.3 - magic-string: 1.4.3 - obug: 3.0.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - srvx: 1.0.5 - strip-literal: 4.0.0 - turbo-stream: 3.2.1 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - - '@vitejs/plugin-vue-jsx@4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': + '@vitejs/plugin-vue-jsx@4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@5.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@5.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@7.0.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': + '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(@typescript/typescript6@6.0.2) - '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': + '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@5.8.3) - '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2))': + '@vitejs/plugin-vue@6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@7.0.2) '@vitest/expect@4.1.4': @@ -36964,17 +36912,17 @@ snapshots: tinyrainbow: 3.2.0 optional: true - '@vitest/mocker@4.1.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) optional: true - '@vitest/mocker@5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + '@vitest/mocker@5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.4 @@ -36982,7 +36930,7 @@ snapshots: magic-string: 1.4.3 optionalDependencies: msw: 2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) '@vitest/mocker@5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: @@ -36994,16 +36942,6 @@ snapshots: msw: 2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2) vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - '@vitest/mocker@5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.4 - estree-walker: 3.0.3 - magic-string: 1.4.3 - optionalDependencies: - msw: 2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - '@vitest/mocker@5.0.4(msw@2.15.0(@types/node@25.0.9)(typescript@5.9.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -37061,7 +36999,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) optional: true '@vitest/ui@5.0.4(vitest@5.0.4)': @@ -37072,7 +37010,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/utils@4.1.4': dependencies: @@ -37961,7 +37899,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) solid-js: 1.9.12 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vue: 3.5.25(@typescript/typescript6@6.0.2) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -38591,7 +38529,7 @@ snapshots: css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@6.1.0: {} @@ -39066,13 +39004,13 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild-plugin-vue3@0.5.1(cheerio@1.0.0)(sass@1.97.2)(vue@3.5.25(@typescript/typescript6@6.0.2)): + esbuild-plugin-vue3@0.5.1(cheerio@1.0.0)(sass@1.105.1)(vue@3.5.25(@typescript/typescript6@6.0.2)): dependencies: typescript: 4.9.5 vue: 3.5.25(@typescript/typescript6@6.0.2) optionalDependencies: cheerio: 1.0.0 - sass: 1.97.2 + sass: 1.105.1 esbuild@0.23.0: optionalDependencies: @@ -40492,8 +40430,6 @@ snapshots: immer@10.1.1: {} - immutable@5.1.4: {} - immutable@5.1.9: {} import-fresh@3.3.0: @@ -41644,36 +41580,6 @@ snapshots: - '@babel/core' - '@types/node' - babel-plugin-macros - optional: true - - next@16.3.4(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.2): - dependencies: - '@next/env': 16.3.4 - '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.23 - caniuse-lite: 1.0.30001760 - postcss: 8.5.23 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(babel-plugin-macros@3.1.0)(react@19.2.3) - optionalDependencies: - '@next/swc-darwin-arm64': 16.3.4 - '@next/swc-darwin-x64': 16.3.4 - '@next/swc-linux-arm64-gnu': 16.3.4 - '@next/swc-linux-arm64-musl': 16.3.4 - '@next/swc-linux-x64-gnu': 16.3.4 - '@next/swc-linux-x64-musl': 16.3.4 - '@next/swc-win32-arm64-msvc': 16.3.4 - '@next/swc-win32-x64-msvc': 16.3.4 - '@opentelemetry/api': 1.9.1 - '@playwright/test': 1.63.0 - babel-plugin-react-compiler: 1.0.0 - sass: 1.97.2 - sharp: 0.35.4(@types/node@25.0.9) - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - babel-plugin-macros nf3@0.3.24: {} @@ -42334,7 +42240,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postcss@8.5.29: dependencies: @@ -42358,7 +42264,7 @@ snapshots: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres@3.4.7: {} @@ -43125,15 +43031,6 @@ snapshots: source-map-js: 1.2.2 optionalDependencies: '@parcel/watcher': 2.5.1 - optional: true - - sass@1.97.2: - dependencies: - chokidar: 4.0.3 - immutable: 5.1.4 - source-map-js: 1.2.2 - optionalDependencies: - '@parcel/watcher': 2.5.1 sax@1.4.1: {} @@ -44120,7 +44017,7 @@ snapshots: picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3): + unplugin@3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 @@ -44130,7 +44027,7 @@ snapshots: esbuild: 0.28.2 rolldown: 1.2.13 rollup: 4.63.1 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) webpack: 5.110.3(@swc/core@1.15.33(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.29)(sharp@0.35.4(@types/node@25.0.9))(svgo@4.0.0)(webpack-cli@5.1.4) unrs-resolver@1.11.1: @@ -44259,7 +44156,7 @@ snapshots: vary@1.1.2: {} - vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) '@rollup/pluginutils': 5.1.4(rollup@4.63.1) @@ -44272,13 +44169,13 @@ snapshots: magic-string: 0.30.21 typescript: '@typescript/typescript6@6.0.2' optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) '@rollup/pluginutils': 5.1.4(rollup@4.63.1) @@ -44291,7 +44188,7 @@ snapshots: magic-string: 0.30.21 typescript: '@typescript/typescript6@6.0.2' optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - rollup @@ -44316,25 +44213,6 @@ snapshots: - rollup - supports-color - vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) - '@rollup/pluginutils': 5.1.4(rollup@4.63.1) - '@volar/typescript': 2.4.26(@typescript/typescript6@6.0.2) - '@vue/language-core': 2.1.6(@typescript/typescript6@6.0.2) - compare-versions: 6.1.1 - debug: 4.4.3(supports-color@10.2.2) - kolorist: 1.8.0 - local-pkg: 0.5.1 - magic-string: 0.30.21 - typescript: '@typescript/typescript6@6.0.2' - optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - transitivePeerDependencies: - - '@types/node' - - rollup - - supports-color - vite-plugin-dts@4.2.3(@types/node@25.0.9)(supports-color@10.2.2)(typescript@5.9.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) @@ -44354,19 +44232,15 @@ snapshots: - rollup - supports-color - vite-plugin-externalize-deps@0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-externalize-deps@0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-externalize-deps@0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vite-plugin-externalize-deps@0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - - vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@types/babel__core': 7.20.5 @@ -44374,14 +44248,14 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 6.6.3 transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-solid@2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@types/babel__core': 7.20.5 @@ -44389,14 +44263,14 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@types/babel__core': 7.20.5 @@ -44404,14 +44278,14 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 6.6.3 transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@types/babel__core': 7.20.5 @@ -44419,14 +44293,14 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 6.6.3 transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.11(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-plugin-solid@2.11.11(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@types/babel__core': 7.20.5 @@ -44434,19 +44308,19 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@10.2.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4) transitivePeerDependencies: - supports-color - vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 tsconfck: 3.1.4(@typescript/typescript6@6.0.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript @@ -44461,16 +44335,6 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - globrex: 0.1.2 - tsconfck: 3.1.4(@typescript/typescript6@6.0.2) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - transitivePeerDependencies: - - supports-color - - typescript - vite-tsconfig-paths@6.1.1(supports-color@10.2.2)(typescript@5.9.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -44481,7 +44345,7 @@ snapshots: - supports-color - typescript - vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0): + vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -44493,7 +44357,7 @@ snapshots: esbuild: 0.27.4 fsevents: 2.3.3 jiti: 2.7.0 - sass: 1.97.2 + sass: 1.105.1 sass-embedded: 1.105.1 terser: 5.51.2 tsx: 4.20.3 @@ -44517,40 +44381,18 @@ snapshots: tsx: 4.20.3 yaml: 2.9.0 - vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.7 - postcss: 8.5.29 - rolldown: 1.2.13 - tinyglobby: 0.2.17 + vitefu@1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): optionalDependencies: - '@types/node': 25.0.9 - esbuild: 0.28.2 - fsevents: 2.3.3 - jiti: 2.7.0 - sass: 1.97.2 - sass-embedded: 1.105.1 - terser: 5.51.2 - tsx: 4.20.3 - yaml: 2.9.0 - - vitefu@1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vitefu@1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): optionalDependencies: vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu@1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - optionalDependencies: - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -44567,7 +44409,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -44578,10 +44420,10 @@ snapshots: - msw optional: true - vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.2 - '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -44592,7 +44434,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -44626,30 +44468,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - '@types/chai': 5.2.2 - '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - chai: 6.3.0 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 1.4.3 - obug: 3.0.0 - picomatch: 4.0.7 - std-env: 4.3.0 - tinybench: 6.2.2 - tinyexec: 1.3.1 - tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - why-is-node-running: 3.2.1 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 25.0.9 - '@vitest/ui': 5.0.4(vitest@5.0.4) - jsdom: 25.0.1(supports-color@10.2.2) - transitivePeerDependencies: - - msw - vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(typescript@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.2 @@ -44674,10 +44492,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@27.0.0(supports-color@10.2.2))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.2 - '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -44688,7 +44506,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -44698,10 +44516,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + vitest@5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.2 - '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/mocker': 5.0.4(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -44712,7 +44530,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 From 3cb0682ab1e568ea3ba42884c151506a2a4d1c9d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 19:01:04 +0200 Subject: [PATCH 0413/1425] test(compiler): fast and slow test tiers Each compiler package with real-tool tests (router-plugin, start-plugin-core, compiler-test-utils) gets two Vitest projects: `unit` (`test:unit`, in process; a Vite dev server in middleware mode is allowed) and `integration` (`test:integration`, files under tests/integration/ that start real builds, Rsbuild dev servers, child Node runs or browsers). Files that only start real tools move to tests/integration/ unchanged (apart from relative paths). Mixed files are split by test: the in-process tests stay in place, the real-tool tests move to a file of the same name under tests/integration/ (known-bugs-code-splitter into three integration files). No assertion changed; every test keeps its name and outcome (vitest outcome per test compared with the baseline: 0 differences). start-plugin-core: the Vite dev-server tests that link the built @tanstack/react-start package move to the slow tier with the builds, so test:unit depends on ^build only and the react-start build dependency moves to test:integration. Nx: test:integration (and test:integration:smoke) in targetDefaults like test:unit; test:unit inputs exclude tests/integration; test:integration joins test:pr, test:ci and vite-ecosystem-ci:test. Co-Authored-By: Claude Opus 5.5 --- nx.json | 22 + package.json | 7 +- packages/compiler-test-utils/package.json | 3 +- .../tests/{ => integration}/browser.test.ts | 6 +- .../{ => integration}/build-harness.test.ts | 4 +- .../rsbuild-dev-server.test.ts | 2 +- .../tests/{ => integration}/run-node.test.ts | 7 +- packages/compiler-test-utils/vite.config.ts | 24 +- packages/router-plugin/package.json | 8 +- .../tests/code-splitter-dead-code.test.ts | 28 - .../tests/code-splitter-determinism.test.ts | 56 - .../code-splitter-imports-exports.test.ts | 62 - .../tests/code-splitter-module-state.test.ts | 64 - .../code-splitter-plugin-pipeline.test.ts | 41 - .../build-frameworks.test.ts | 4 +- .../{ => integration}/build-harness.test.ts | 4 +- .../build-production.test.ts | 8 +- .../build-react-compiler.test.ts | 4 +- .../build-source-maps.test.ts | 2 +- .../code-splitter-dead-code.test.ts | 47 + .../code-splitter-determinism.test.ts | 88 + .../code-splitter-imports-exports.test.ts | 66 + .../code-splitter-module-state.test.ts | 67 + .../code-splitter-plugin-pipeline.test.ts | 42 + .../code-splitter-robustness.test.ts | 4 +- .../code-splitter-router.test.ts | 4 +- .../code-splitter-runtime.test.ts | 6 +- ...n-bugs-code-splitter-client-builds.test.ts | 455 ++++++ ...known-bugs-code-splitter-page-code.test.ts | 570 +++++++ ...nown-bugs-code-splitter-ssr-builds.test.ts | 440 +++++ .../integration/known-bugs-route-hmr.test.ts | 454 ++++++ .../tests/integration/plugin-options.test.ts | 267 ++++ .../integration/regression-helpers.test.ts | 106 ++ .../route-hmr-browser.test.ts | 4 +- .../rsbuild-dev-server-generator.test.ts | 9 +- .../tests/known-bugs-code-splitter.test.ts | 1418 +---------------- .../tests/known-bugs-route-hmr.test.ts | 441 ----- .../tests/plugin-options.test.ts | 256 +-- .../tests/regression-helpers.test.ts | 99 -- packages/router-plugin/vite.config.ts | 26 +- packages/start-plugin-core/package.json | 19 +- .../tests/importProtection/known-bugs.test.ts | 690 +------- .../{ => integration}/build-harness.test.ts | 2 +- .../{ => integration}/hydrate-build.test.ts | 12 +- .../importProtection/build-options.test.ts | 8 +- .../importProtection/build.test.ts | 10 +- .../importProtection/dev.test.ts | 12 +- .../importProtection/exemptions.test.ts | 11 +- .../importProtection/known-bugs.test.ts | 695 ++++++++ .../reported-location.test.ts | 15 +- .../known-bugs-dev-server.test.ts | 10 +- .../known-bugs-route-options.test.ts | 4 +- .../known-bugs-start-compiler.test.ts | 724 +++++++++ .../import-protection-perf.test.ts | 2 +- .../start-build-manifest.test.ts | 8 +- .../start-build-options.test.ts | 6 +- .../rsbuild/dev-server.test.ts | 4 +- .../rsbuild/import-protection.test.ts | 10 +- .../rsbuild/normalized-client-build.test.ts | 573 +++++++ .../start-build-client-only-imports.test.ts | 6 +- .../start-build-determinism.test.ts | 4 +- .../start-build-env-separation.test.ts | 6 +- .../start-build-manifest.test.ts | 4 +- .../start-build-options.test.ts | 4 +- .../start-build-route-hmr.test.ts | 6 +- .../start-build-source-maps.test.ts | 4 +- .../start-build-warnings.test.ts | 8 +- .../start-compiler-dev-server.test.ts | 4 +- .../start-router-dev-server.test.ts | 2 +- .../tests/known-bugs-start-compiler.test.ts | 716 +-------- .../rsbuild/normalized-client-build.test.ts | 573 +------ packages/start-plugin-core/vite.config.ts | 26 +- 72 files changed, 4851 insertions(+), 4552 deletions(-) rename packages/compiler-test-utils/tests/{ => integration}/browser.test.ts (94%) rename packages/compiler-test-utils/tests/{ => integration}/build-harness.test.ts (99%) rename packages/compiler-test-utils/tests/{ => integration}/rsbuild-dev-server.test.ts (98%) rename packages/compiler-test-utils/tests/{ => integration}/run-node.test.ts (95%) rename packages/router-plugin/tests/{ => integration}/build-frameworks.test.ts (97%) rename packages/router-plugin/tests/{ => integration}/build-harness.test.ts (97%) rename packages/router-plugin/tests/{ => integration}/build-production.test.ts (98%) rename packages/router-plugin/tests/{ => integration}/build-react-compiler.test.ts (95%) rename packages/router-plugin/tests/{ => integration}/build-source-maps.test.ts (99%) create mode 100644 packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts create mode 100644 packages/router-plugin/tests/integration/code-splitter-determinism.test.ts create mode 100644 packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts create mode 100644 packages/router-plugin/tests/integration/code-splitter-module-state.test.ts create mode 100644 packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts rename packages/router-plugin/tests/{ => integration}/code-splitter-robustness.test.ts (96%) rename packages/router-plugin/tests/{ => integration}/code-splitter-router.test.ts (98%) rename packages/router-plugin/tests/{ => integration}/code-splitter-runtime.test.ts (96%) create mode 100644 packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts create mode 100644 packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts create mode 100644 packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts create mode 100644 packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts create mode 100644 packages/router-plugin/tests/integration/plugin-options.test.ts create mode 100644 packages/router-plugin/tests/integration/regression-helpers.test.ts rename packages/router-plugin/tests/{ => integration}/route-hmr-browser.test.ts (99%) rename packages/router-plugin/tests/{ => integration}/rsbuild-dev-server-generator.test.ts (98%) rename packages/start-plugin-core/tests/{ => integration}/build-harness.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/hydrate-build.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/importProtection/build-options.test.ts (98%) rename packages/start-plugin-core/tests/{ => integration}/importProtection/build.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/importProtection/dev.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/importProtection/exemptions.test.ts (98%) create mode 100644 packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts rename packages/start-plugin-core/tests/{ => integration}/importProtection/reported-location.test.ts (98%) rename packages/start-plugin-core/tests/{ => integration}/known-bugs-dev-server.test.ts (98%) rename packages/start-plugin-core/tests/{ => integration}/known-bugs-route-options.test.ts (99%) create mode 100644 packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts rename packages/start-plugin-core/tests/{ => integration}/outside-compiler-corpus/import-protection-perf.test.ts (95%) rename packages/start-plugin-core/tests/{ => integration}/outside-compiler-corpus/start-build-manifest.test.ts (96%) rename packages/start-plugin-core/tests/{ => integration}/outside-compiler-corpus/start-build-options.test.ts (97%) rename packages/start-plugin-core/tests/{ => integration}/rsbuild/dev-server.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/rsbuild/import-protection.test.ts (98%) create mode 100644 packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts rename packages/start-plugin-core/tests/{ => integration}/start-build-client-only-imports.test.ts (93%) rename packages/start-plugin-core/tests/{ => integration}/start-build-determinism.test.ts (98%) rename packages/start-plugin-core/tests/{ => integration}/start-build-env-separation.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/start-build-manifest.test.ts (98%) rename packages/start-plugin-core/tests/{ => integration}/start-build-options.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/start-build-route-hmr.test.ts (96%) rename packages/start-plugin-core/tests/{ => integration}/start-build-source-maps.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/start-build-warnings.test.ts (95%) rename packages/start-plugin-core/tests/{ => integration}/start-compiler-dev-server.test.ts (99%) rename packages/start-plugin-core/tests/{ => integration}/start-router-dev-server.test.ts (99%) diff --git a/nx.json b/nx.json index 9f9d782278b..20699a728e3 100644 --- a/nx.json +++ b/nx.json @@ -58,12 +58,34 @@ "dependsOn": ["^build"], "inputs": [ "default", + "!{projectRoot}/tests/integration/**/*", "^production", "{workspaceRoot}/eslint.config.js", "dependentTaskOutputs" ], "outputs": ["{projectRoot}/coverage"] }, + "test:integration": { + "cache": true, + "dependsOn": ["^build"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/eslint.config.js", + "dependentTaskOutputs" + ], + "outputs": ["{projectRoot}/coverage"] + }, + "test:integration:smoke": { + "cache": true, + "dependsOn": ["^build"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/eslint.config.js", + "dependentTaskOutputs" + ] + }, "test:e2e": { "cache": true, "dependsOn": ["^build", "test:e2e--*"], diff --git a/package.json b/package.json index 5876d0893d9..8e1c753ed38 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "cleanNodeModules": "pnpm -r exec rm -rf node_modules", "clean": "pnpm --filter \"./packages/**\" run clean", "test": "pnpm run test:ci", - "test:pr": "nx affected --targets=test:eslint,test:unit,test:e2e,test:types,test:build,build", - "test:ci": "nx run-many --targets=test:eslint,test:unit,test:e2e,test:types,test:build,build", + "test:pr": "nx affected --targets=test:eslint,test:unit,test:integration,test:e2e,test:types,test:build,build", + "test:ci": "nx run-many --targets=test:eslint,test:unit,test:integration,test:e2e,test:types,test:build,build", "test:eslint": "nx affected --target=test:eslint --exclude=examples/**,e2e/**", "test:unit": "nx affected --target=test:unit --exclude=examples/**,e2e/**", + "test:integration": "nx affected --target=test:integration --exclude=examples/**,e2e/**", "test:unit:dev": "pnpm run test:unit && nx watch --all -- pnpm run test:unit", "test:build": "nx affected --target=test:build --exclude=examples/**", "test:types": "nx affected --target=test:types --exclude=examples/**", @@ -55,7 +56,7 @@ "ts:symbol-references": "node scripts/ts-symbol-references.mjs", "vite-ecosystem-ci:build": "nx run-many --targets=build --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start,@tanstack/react-start-client,@tanstack/react-start-server --skipRemoteCache", "vite-ecosystem-ci:before-test": "pnpm exec playwright install chromium", - "vite-ecosystem-ci:test": "nx run-many --targets=test:unit --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start-client --skipRemoteCache && nx run-many --target=test:e2e --projects=tanstack-router-e2e-react-basic-file-based,tanstack-router-e2e-react-basic-file-based-code-splitting,tanstack-react-start-e2e-basic,tanstack-vue-start-e2e-basic,tanstack-solid-start-e2e-basic --skipRemoteCache" + "vite-ecosystem-ci:test": "nx run-many --targets=test:unit,test:integration --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start-client --skipRemoteCache && nx run-many --target=test:e2e --projects=tanstack-router-e2e-react-basic-file-based,tanstack-router-e2e-react-basic-file-based-code-splitting,tanstack-react-start-e2e-basic,tanstack-vue-start-e2e-basic,tanstack-solid-start-e2e-basic --skipRemoteCache" }, "nx": { "includedScripts": [ diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index fb87205448e..427b094859c 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -15,7 +15,8 @@ "./package.json": "./package.json" }, "scripts": { - "test:unit": "vitest", + "test:unit": "vitest --project unit", + "test:integration": "vitest --project integration", "test:types": "tsc", "test:eslint": "eslint ./src ./tests" }, diff --git a/packages/compiler-test-utils/tests/browser.test.ts b/packages/compiler-test-utils/tests/integration/browser.test.ts similarity index 94% rename from packages/compiler-test-utils/tests/browser.test.ts rename to packages/compiler-test-utils/tests/integration/browser.test.ts index 47aaf521a3c..d96fc3e971e 100644 --- a/packages/compiler-test-utils/tests/browser.test.ts +++ b/packages/compiler-test-utils/tests/integration/browser.test.ts @@ -8,9 +8,9 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { expect, onTestFinished, test } from 'vitest' -import { openPage } from '../src/browser' -import { createDevServer } from '../src/dev-server' -import { createRsbuildDevServer } from '../src/rsbuild-dev-server' +import { openPage } from '../../src/browser' +import { createDevServer } from '../../src/dev-server' +import { createRsbuildDevServer } from '../../src/rsbuild-dev-server' const timeout = 60_000 diff --git a/packages/compiler-test-utils/tests/build-harness.test.ts b/packages/compiler-test-utils/tests/integration/build-harness.test.ts similarity index 99% rename from packages/compiler-test-utils/tests/build-harness.test.ts rename to packages/compiler-test-utils/tests/integration/build-harness.test.ts index 17aa8749fdc..4010956791e 100644 --- a/packages/compiler-test-utils/tests/build-harness.test.ts +++ b/packages/compiler-test-utils/tests/integration/build-harness.test.ts @@ -13,12 +13,12 @@ import { createApp, namedSegments, originalFrameOf, -} from '../src/build-harness' +} from '../../src/build-harness' import type { BundleOptions, DanglingNameError, Placement, -} from '../src/build-harness' +} from '../../src/build-harness' const timeout = 60_000 diff --git a/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts b/packages/compiler-test-utils/tests/integration/rsbuild-dev-server.test.ts similarity index 98% rename from packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts rename to packages/compiler-test-utils/tests/integration/rsbuild-dev-server.test.ts index 04fb13931b6..7f495da23c4 100644 --- a/packages/compiler-test-utils/tests/rsbuild-dev-server.test.ts +++ b/packages/compiler-test-utils/tests/integration/rsbuild-dev-server.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { expect, onTestFinished, test } from 'vitest' -import { createRsbuildDevServer } from '../src/rsbuild-dev-server' +import { createRsbuildDevServer } from '../../src/rsbuild-dev-server' const testsDir = path.dirname(fileURLToPath(import.meta.url)) diff --git a/packages/compiler-test-utils/tests/run-node.test.ts b/packages/compiler-test-utils/tests/integration/run-node.test.ts similarity index 95% rename from packages/compiler-test-utils/tests/run-node.test.ts rename to packages/compiler-test-utils/tests/integration/run-node.test.ts index 514fecfd1fd..d9ab5834f43 100644 --- a/packages/compiler-test-utils/tests/run-node.test.ts +++ b/packages/compiler-test-utils/tests/integration/run-node.test.ts @@ -10,10 +10,13 @@ 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' +import { runNodeProgram } from '../../src/run-node' const runNodeModule = pathToFileURL( - path.join(path.dirname(fileURLToPath(import.meta.url)), '../src/run-node.ts'), + path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/run-node.ts', + ), ).href /** Whether a process is running. */ diff --git a/packages/compiler-test-utils/vite.config.ts b/packages/compiler-test-utils/vite.config.ts index 21b74220374..4dc3b70f449 100644 --- a/packages/compiler-test-utils/vite.config.ts +++ b/packages/compiler-test-utils/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config' +import { configDefaults, defineConfig } from 'vitest/config' import packageJson from './package.json' export default defineConfig({ @@ -6,5 +6,27 @@ export default defineConfig({ name: packageJson.name, dir: './tests', watch: false, + projects: [ + { + // Fast tier (`test:unit`): in process; a Vite dev server in + // middleware mode is allowed. + extends: true, + test: { + name: 'unit', + exclude: [...configDefaults.exclude, 'integration/**'], + }, + }, + { + // Slow tier (`test:integration`): the harness self-tests that run + // real builds, Rsbuild dev servers, child Node runs and browsers. + extends: true, + test: { + name: 'integration', + include: ['integration/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + }, + }, + ], }, }) diff --git a/packages/router-plugin/package.json b/packages/router-plugin/package.json index bc0801e6adb..521a162dfea 100644 --- a/packages/router-plugin/package.json +++ b/packages/router-plugin/package.json @@ -26,9 +26,11 @@ "scripts": { "clean": "rimraf ./dist && rimraf ./coverage", "clean:snapshots": "rimraf **/*snapshot* --glob", - "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit", - "test:unit": "vitest", - "test:unit:dev": "vitest --watch", + "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit && pnpm test:integration", + "test:unit": "vitest --project unit", + "test:unit:dev": "vitest --project unit --watch", + "test:integration": "vitest --project integration", + "test:integration:smoke": "vitest --project integration integration/build-production.test.ts -t \"vite a production react client bundle has no dev-only code|autoCodeSplitting: true. has no dev-only code\"", "test:eslint": "eslint ./src", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", diff --git a/packages/router-plugin/tests/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/code-splitter-dead-code.test.ts index 85c24ba6845..7401f98ef8d 100644 --- a/packages/router-plugin/tests/code-splitter-dead-code.test.ts +++ b/packages/router-plugin/tests/code-splitter-dead-code.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { build, routeApp } from './build-harness' import { compileRouteModules, declaredNames, @@ -191,33 +190,6 @@ export const Route = createFileRoute('/')({ component: () =>

    {helper()}

    } expect(await renderSplitComponent(unusedFunctions)).toBe('

    helper

    ') }) - // The other half of the ruling: the unused functions (and any binding they - // name that their module no longer declares) do not reach a production - // client bundle. - it.each(['vite', 'rspack', 'webpack'] as const)( - 'drops unused functions, one calling a helper of the split component, from a production client build (%s)', - async (bundler) => { - const app = await build({ - bundler, - env: 'client', - files: routeApp(unusedFunctions, { env: 'client' }), - }) - const run = await app.run( - `await entry.mount(entry.Route.options.component)`, - ) - expect({ - unused: app.where('MARK_UNUSED_END'), - ping: app.where('MARK_PING_END'), - html: run.html, - }).toEqual({ - unused: 'nowhere', - ping: 'nowhere', - html: '

    helper

    ', - }) - }, - 60_000, - ) - // Source: babel-dead-code-elimination dead-code-elimination.test.ts "SCC dead // code elimination" > "arrow functions in mutual recursion -> removed" it.each(hmrModes)( diff --git a/packages/router-plugin/tests/code-splitter-determinism.test.ts b/packages/router-plugin/tests/code-splitter-determinism.test.ts index b561809cb2a..6f7502d2a04 100644 --- a/packages/router-plugin/tests/code-splitter-determinism.test.ts +++ b/packages/router-plugin/tests/code-splitter-determinism.test.ts @@ -5,10 +5,8 @@ * twice, in two directories, emits the same files. */ import { describe, expect, test, vi } from 'vitest' -import { build, routeApp } from './build-harness' import { head, loadRoute } from './regression-helpers' import type * as NodeUrl from 'node:url' -import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' const route = `${head}import { format } from './format' const prefix = 'MARK' @@ -104,57 +102,3 @@ describe('a route compiled under', () => { }, ) }) - -const appFiles = routeApp( - `${head}import { format } from '../format' -function Page() { - return

    {format('MARK_PAGE')}

    -} -function Failure() { - return

    MARK_FAILURE

    -} -export const Route = createFileRoute('/')({ - loader: () => 'MARK_DATA', - errorComponent: Failure, - component: Page, -}) -`, - { - env: 'client', - files: { - 'format.ts': 'export const format = (text: string) => `[${text}]`\n', - }, - }, -) - -/** Builds the app in a directory named after `prefix`; returns its files. */ -async function buildIn(bundler: Bundler, prefix: string, reverse: boolean) { - const files = Object.entries(appFiles) - const app = await build({ - bundler, - env: 'client', - prefix, - // Write the files in another order - files: Object.fromEntries(reverse ? files.reverse() : files), - }) - return Object.fromEntries( - app.chunks - .map((chunk) => [chunk.fileName, chunk.code] as const) - .sort(([a], [b]) => a.localeCompare(b)), - ) -} - -test.each(['vite', 'rspack', 'webpack'] as const)( - 'building the same app twice in two directories emits the same files (%s)', - async (bundler) => { - const first = await buildIn(bundler, '.determinism-a-', false) - const second = await buildIn( - bundler, - '.determinism-another-longer-directory-', - true, - ) - expect(Object.keys(first).length).toBeGreaterThan(1) - expect(second).toEqual(first) - }, - 120_000, -) 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 2c6f6dd07ba..4f50b105f4b 100644 --- a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts +++ b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts @@ -2,7 +2,6 @@ import { parseSync as parseWithBabel } from '@babel/core' import { parseSync, transformWithOxc } from 'vite' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' -import { build, clientMountEntry, routeApp } from './build-harness' import { compileRouteModules, componentChunk, @@ -934,67 +933,6 @@ export const Route = createFileRoute('/')({ expect((await route.load('component'))()).toBe('

    server

    ') }) - // With `verbatimModuleSyntax`, TypeScript keeps `import { type Db } from - // './db'` as `import './db'`. A module a route file imports only for types - // need not load in the client (ruling L3, 2026-10-17; same as the Start - // client in start-plugin-core known-bugs-start-compiler.test.ts "a - // server-only module imported only for a type specifier"). Only Vite reads - // the app's tsconfig in this harness. - describe('a module imported only for a type specifier, with verbatimModuleSyntax (vite)', () => { - const files = { - 'tsconfig.json': JSON.stringify({ - compilerOptions: { verbatimModuleSyntax: true, jsx: 'react-jsx' }, - }), - 'db.ts': `;(globalThis as any).dbLoaded = 'MARK_DB_LOADED_END' -export type Db = { name: string } -`, - } - const route = `${head}import { type Db } from '../db' -function Page(props: { db?: Db }) { - return

    {props.db ? props.db.name : 'page'}

    -} -export const Route = createFileRoute('/')({ component: Page }) -` - - // Control: the tsconfig is in effect (the same import, in a module the - // code splitter does not compile, loads `db.ts`). - it('loads with the module that imports it outside route files', async () => { - const app = await build({ - bundler: 'vite', - env: 'client', - files: routeApp(route, { - env: 'client', - files: { - ...files, - 'entry.ts': `import { type Db } from './db'\n${clientMountEntry}`, - }, - }), - }) - expect(app.where('MARK_DB_LOADED_END')).toBe('eager') - }, 60_000) - - it('does not load in the client of a split route file, which renders', async () => { - const app = await build({ - bundler: 'vite', - env: 'client', - files: routeApp(route, { env: 'client', files }), - }) - const run = await app.run( - `await entry.mount(entry.Route.options.component) -return globalThis.dbLoaded ?? null`, - ) - expect({ - where: app.where('MARK_DB_LOADED_END'), - loaded: run.result, - html: run.html, - }).toEqual({ - where: 'nowhere', - loaded: null, - html: '

    page

    ', - }) - }, 60_000) - }) - it('keeps decorators before `export` for legacy decorator transforms', () => { // TypeScript `experimentalDecorators` and Babel `decorators-legacy` only // accept decorators before the `export` keyword: every emitted module 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 41cdd7036c9..dbcf7004b66 100644 --- a/packages/router-plugin/tests/code-splitter-module-state.test.ts +++ b/packages/router-plugin/tests/code-splitter-module-state.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - buildAndRun, declaredNames, expectValidModules, head, @@ -10,38 +9,6 @@ import { import type { CodeSplitGroupings } from '../src/core/constants' describe('split chunks keep the module state the route relies on', () => { - // Route files commonly export a store or helpers over private state next to - // the route and use them from the route component as well. - it('renders a split component using exported bindings over private state like the unsplit route', async () => { - const result = await buildAndRun( - `${head}const state = { count: 0 } -export function increment() { - return ++state.count -} -let renders = 0 -export function getRenders() { - return renders -} -const initial = 0 -export const store = { count: initial } -function Page() { - increment() - renders++ - store.count++ - return

    {[state.count, getRenders(), store.count - initial].join('/')}

    -} -export const Route = createFileRoute('/')({ component: Page }) -`, - `const component = entry.Route.options.component -return [await entry.render(component), await entry.render(component), entry.store.count]`, - ) - // Known limitation on main: the chunk gets its own copy of `state` and - // `renders`, so callers of the exported functions are not checked here. - // Pinned in known-bugs-code-splitter.test.ts ("a binding read by an exported function"). - // `store.count` is 2: the chunk mutated the store the route module exports - expect(result).toEqual(['

    1/1/1

    ', '

    2/2/2

    ', 2]) - }, 30_000) - // Source: Qwik optimizer test.rs // should_keep_non_migrated_binding_from_shared_destructuring_declarator_with_default (+ _with_rest) it('evaluates a destructuring split between the loader and the component once', async () => { @@ -170,37 +137,6 @@ export const Route = createFileRoute('/')({ 1, ]) }) - - // Source: #8504 (code-splitter-exported-bindings-identity.test.ts). - it('gives the split component the state the route file configures before it creates it', async () => { - const html = await buildAndRun( - `${head}import { configure, createStore } from '../store' -configure(5) -const store = createStore() -export function useCount() { - return store.count -} -function Page() { - return

    {useCount()}

    -} -export const Route = createFileRoute('/')({ component: Page }) -`, - `return await entry.render(entry.Route.options.component)`, - { - files: { - 'store.ts': `let initial = 0 -export function configure(count: number) { - initial = count -} -export function createStore() { - return { count: initial } -} -`, - }, - }, - ) - expect(html).toBe('

    5

    ') - }, 30_000) }) // Exported destructurings whose bindings split options read: each 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 bdcae03f31d..84d649aea82 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -1,6 +1,4 @@ -import { BuildError } from '@tanstack/compiler-test-utils/build-harness' import { describe, expect, it, vi } from 'vitest' -import { build, routeApp } from './build-harness' import { compileRouteModules, createCodeSplitterTransforms, @@ -482,45 +480,6 @@ export const Route = createFileRoute('/')({}) }, ) -// A route file that both imports and declares `Route` is invalid TypeScript -// (TS2440). A real build either fails with an error naming `Route`, or -// builds a page that renders; it never builds a page that breaks. Source: -// the #8504 confidence campaign (DX-07). -it.each(['vite', 'rspack'] as const)( - 'a route file that imports and declares Route fails its build naming Route, or renders (%s)', - async (bundler) => { - let outcome: string - try { - const app = await build({ - bundler, - env: 'client', - files: routeApp( - `import { createFileRoute } from '@tanstack/react-router' -import { Route } from '../other' -export const Route = createFileRoute('/')({ component: () =>

    page

    }) -`, - { env: 'client', files: { 'other.ts': 'export const Route = 1\n' } }, - ), - }) - outcome = String( - (await app.run(`await entry.mount(entry.Route.options.component)`)) - .html, - ) - } catch (error) { - if (error instanceof BuildError && /\bRoute\b/.test(error.message)) { - outcome = 'refused naming Route' - } else { - throw error - } - } - expect([ - 'refused naming Route', - '

    page

    ', - ]).toContain(outcome) - }, - 60_000, -) - describe('route HMR plugin without code splitting', () => { function transformWith( options: Partial & { target: 'solid' | 'vue' }, diff --git a/packages/router-plugin/tests/build-frameworks.test.ts b/packages/router-plugin/tests/integration/build-frameworks.test.ts similarity index 97% rename from packages/router-plugin/tests/build-frameworks.test.ts rename to packages/router-plugin/tests/integration/build-frameworks.test.ts index 181070ba6f7..c3bcc96b25e 100644 --- a/packages/router-plugin/tests/build-frameworks.test.ts +++ b/packages/router-plugin/tests/integration/build-frameworks.test.ts @@ -5,12 +5,12 @@ * target framework. */ import { describe, expect, test } from 'vitest' -import { build, frameworks, routeApp, routeHead } from './build-harness' +import { build, frameworks, routeApp, routeHead } from '../build-harness' import type { BuildEnv, Bundler, } from '@tanstack/compiler-test-utils/build-harness' -import type { Framework } from './build-harness' +import type { Framework } from '../build-harness' const timeout = 120_000 diff --git a/packages/router-plugin/tests/build-harness.test.ts b/packages/router-plugin/tests/integration/build-harness.test.ts similarity index 97% rename from packages/router-plugin/tests/build-harness.test.ts rename to packages/router-plugin/tests/integration/build-harness.test.ts index 358e85a09f2..698cd1ecafa 100644 --- a/packages/router-plugin/tests/build-harness.test.ts +++ b/packages/router-plugin/tests/integration/build-harness.test.ts @@ -9,8 +9,8 @@ import { BuildError, bundlers, } from '@tanstack/compiler-test-utils/build-harness' -import { build, buildWithRsbuild, routeApp } from './build-harness' -import { head } from './regression-helpers' +import { build, buildWithRsbuild, routeApp } from '../build-harness' +import { head } from '../regression-helpers' import type { BuildResult, Bundler, diff --git a/packages/router-plugin/tests/build-production.test.ts b/packages/router-plugin/tests/integration/build-production.test.ts similarity index 98% rename from packages/router-plugin/tests/build-production.test.ts rename to packages/router-plugin/tests/integration/build-production.test.ts index b7b9ed77259..85dea51c1cb 100644 --- a/packages/router-plugin/tests/build-production.test.ts +++ b/packages/router-plugin/tests/integration/build-production.test.ts @@ -17,16 +17,16 @@ import { frameworks, routeApp, routeHead, -} from './build-harness' -import { routerDevServer } from './dev-server' +} from '../build-harness' +import { routerDevServer } from '../dev-server' import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' -import type { Framework } from './build-harness' +import type { Framework } from '../build-harness' const timeout = 120_000 const workspaceRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - '../../..', + '../../../..', ) const route = ( diff --git a/packages/router-plugin/tests/build-react-compiler.test.ts b/packages/router-plugin/tests/integration/build-react-compiler.test.ts similarity index 95% rename from packages/router-plugin/tests/build-react-compiler.test.ts rename to packages/router-plugin/tests/integration/build-react-compiler.test.ts index ec8c192780e..9c5e69c97bf 100644 --- a/packages/router-plugin/tests/build-react-compiler.test.ts +++ b/packages/router-plugin/tests/integration/build-react-compiler.test.ts @@ -6,8 +6,8 @@ * environments only.) */ import { expect, test } from 'vitest' -import { build, routeApp } from './build-harness' -import { head } from './regression-helpers' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' const route = `${head}import { useState } from 'react' function Page() { diff --git a/packages/router-plugin/tests/build-source-maps.test.ts b/packages/router-plugin/tests/integration/build-source-maps.test.ts similarity index 99% rename from packages/router-plugin/tests/build-source-maps.test.ts rename to packages/router-plugin/tests/integration/build-source-maps.test.ts index 2a5f9a231a9..53c4a0007ef 100644 --- a/packages/router-plugin/tests/build-source-maps.test.ts +++ b/packages/router-plugin/tests/integration/build-source-maps.test.ts @@ -11,7 +11,7 @@ import { namedSegments, originalFrameOf, } from '@tanstack/compiler-test-utils/build-harness' -import { build, routeApp, routeHead } from './build-harness' +import { build, routeApp, routeHead } from '../build-harness' import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' const timeout = 60_000 diff --git a/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts new file mode 100644 index 00000000000..6ecdbb0ba75 --- /dev/null +++ b/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' + +describe('the reference module only drops code that splitting left unused', () => { + // Source: babel-dead-code-elimination dead-code-elimination.test.ts "only + // eliminates newly unreferenced identifiers", "unexported circular references" + // and "SCC with external caller used -> all preserved". Whether unused user + // code is kept or dropped is free, and unused code may name a binding its + // module no longer declares (user ruling 2026-10-17, widened: the bundlers + // delete it; the validator checks what Rolldown's tree-shaking keeps); + // every module stays valid and the split component still reaches its + // helper. + const unusedFunctions = `${head}function helper() { return 'helper' } +function unused() { return 'MARK_UNUSED_END' + helper() } +function ping(): number { return pong() + 'MARK_PING_END'.length } +function pong(): number { return ping() } +export const Route = createFileRoute('/')({ component: () =>

    {helper()}

    }) +` + + // The other half of the ruling: the unused functions (and any binding they + // name that their module no longer declares) do not reach a production + // client bundle. + it.each(['vite', 'rspack', 'webpack'] as const)( + 'drops unused functions, one calling a helper of the split component, from a production client build (%s)', + async (bundler) => { + const app = await build({ + bundler, + env: 'client', + files: routeApp(unusedFunctions, { env: 'client' }), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component)`, + ) + expect({ + unused: app.where('MARK_UNUSED_END'), + ping: app.where('MARK_PING_END'), + html: run.html, + }).toEqual({ + unused: 'nowhere', + ping: 'nowhere', + html: '

    helper

    ', + }) + }, + 60_000, + ) +}) diff --git a/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts b/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts new file mode 100644 index 00000000000..350b4984a00 --- /dev/null +++ b/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts @@ -0,0 +1,88 @@ +/** + * Code splitting does not depend on where the project lives or on how the + * platform spells paths: the same route compiles to the same modules under + * any project root and with Windows module ids, and building the same app + * twice, in two directories, emits the same files. + */ +import { expect, test, vi } from 'vitest' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' +import type * as NodeUrl from 'node:url' +import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' + +// Node's file URL helpers follow the host platform. For Windows paths +// (drive letter first) they behave here as they do on Windows; other paths +// are unaffected. +vi.mock('node:url', async (importOriginal) => { + const url = await importOriginal() + const windowsPath = /^[A-Za-z]:[\\/]/ + return { + ...url, + pathToFileURL: (file: string, options?: { windows?: boolean }) => + url.pathToFileURL(file, { + ...options, + windows: options?.windows ?? windowsPath.test(file), + }), + fileURLToPath: (href: string | URL, options?: { windows?: boolean }) => + url.fileURLToPath(href, { + ...options, + windows: + options?.windows ?? + /^\/[A-Za-z]:\//.test(new URL(String(href)).pathname), + }), + } +}) + +const appFiles = routeApp( + `${head}import { format } from '../format' +function Page() { + return

    {format('MARK_PAGE')}

    +} +function Failure() { + return

    MARK_FAILURE

    +} +export const Route = createFileRoute('/')({ + loader: () => 'MARK_DATA', + errorComponent: Failure, + component: Page, +}) +`, + { + env: 'client', + files: { + 'format.ts': 'export const format = (text: string) => `[${text}]`\n', + }, + }, +) + +/** Builds the app in a directory named after `prefix`; returns its files. */ +async function buildIn(bundler: Bundler, prefix: string, reverse: boolean) { + const files = Object.entries(appFiles) + const app = await build({ + bundler, + env: 'client', + prefix, + // Write the files in another order + files: Object.fromEntries(reverse ? files.reverse() : files), + }) + return Object.fromEntries( + app.chunks + .map((chunk) => [chunk.fileName, chunk.code] as const) + .sort(([a], [b]) => a.localeCompare(b)), + ) +} + +test.each(['vite', 'rspack', 'webpack'] as const)( + 'building the same app twice in two directories emits the same files (%s)', + async (bundler) => { + const first = await buildIn(bundler, '.determinism-a-', false) + const second = await buildIn( + bundler, + '.determinism-another-longer-directory-', + true, + ) + expect(Object.keys(first).length).toBeGreaterThan(1) + expect(second).toEqual(first) + }, + 120_000, +) diff --git a/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts b/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts new file mode 100644 index 00000000000..a3a9b207f89 --- /dev/null +++ b/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { build, clientMountEntry, routeApp } from '../build-harness' +import { head } from '../regression-helpers' + +describe('TypeScript-only syntax', () => { + // With `verbatimModuleSyntax`, TypeScript keeps `import { type Db } from + // './db'` as `import './db'`. A module a route file imports only for types + // need not load in the client (ruling L3, 2026-10-17; same as the Start + // client in start-plugin-core known-bugs-start-compiler.test.ts "a + // server-only module imported only for a type specifier"). Only Vite reads + // the app's tsconfig in this harness. + describe('a module imported only for a type specifier, with verbatimModuleSyntax (vite)', () => { + const files = { + 'tsconfig.json': JSON.stringify({ + compilerOptions: { verbatimModuleSyntax: true, jsx: 'react-jsx' }, + }), + 'db.ts': `;(globalThis as any).dbLoaded = 'MARK_DB_LOADED_END' +export type Db = { name: string } +`, + } + const route = `${head}import { type Db } from '../db' +function Page(props: { db?: Db }) { + return

    {props.db ? props.db.name : 'page'}

    +} +export const Route = createFileRoute('/')({ component: Page }) +` + + // Control: the tsconfig is in effect (the same import, in a module the + // code splitter does not compile, loads `db.ts`). + it('loads with the module that imports it outside route files', async () => { + const app = await build({ + bundler: 'vite', + env: 'client', + files: routeApp(route, { + env: 'client', + files: { + ...files, + 'entry.ts': `import { type Db } from './db'\n${clientMountEntry}`, + }, + }), + }) + expect(app.where('MARK_DB_LOADED_END')).toBe('eager') + }, 60_000) + + it('does not load in the client of a split route file, which renders', async () => { + const app = await build({ + bundler: 'vite', + env: 'client', + files: routeApp(route, { env: 'client', files }), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component) +return globalThis.dbLoaded ?? null`, + ) + expect({ + where: app.where('MARK_DB_LOADED_END'), + loaded: run.result, + html: run.html, + }).toEqual({ + where: 'nowhere', + loaded: null, + html: '

    page

    ', + }) + }, 60_000) + }) +}) diff --git a/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts b/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts new file mode 100644 index 00000000000..1adb5ef950e --- /dev/null +++ b/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { buildAndRun, head } from '../regression-helpers' + +describe('split chunks keep the module state the route relies on', () => { + // Route files commonly export a store or helpers over private state next to + // the route and use them from the route component as well. + it('renders a split component using exported bindings over private state like the unsplit route', async () => { + const result = await buildAndRun( + `${head}const state = { count: 0 } +export function increment() { + return ++state.count +} +let renders = 0 +export function getRenders() { + return renders +} +const initial = 0 +export const store = { count: initial } +function Page() { + increment() + renders++ + store.count++ + return

    {[state.count, getRenders(), store.count - initial].join('/')}

    +} +export const Route = createFileRoute('/')({ component: Page }) +`, + `const component = entry.Route.options.component +return [await entry.render(component), await entry.render(component), entry.store.count]`, + ) + // Known limitation on main: the chunk gets its own copy of `state` and + // `renders`, so callers of the exported functions are not checked here. + // Pinned in known-bugs-code-splitter.test.ts ("a binding read by an exported function"). + // `store.count` is 2: the chunk mutated the store the route module exports + expect(result).toEqual(['

    1/1/1

    ', '

    2/2/2

    ', 2]) + }, 30_000) + + // Source: #8504 (code-splitter-exported-bindings-identity.test.ts). + it('gives the split component the state the route file configures before it creates it', async () => { + const html = await buildAndRun( + `${head}import { configure, createStore } from '../store' +configure(5) +const store = createStore() +export function useCount() { + return store.count +} +function Page() { + return

    {useCount()}

    +} +export const Route = createFileRoute('/')({ component: Page }) +`, + `return await entry.render(entry.Route.options.component)`, + { + files: { + 'store.ts': `let initial = 0 +export function configure(count: number) { + initial = count +} +export function createStore() { + return { count: initial } +} +`, + }, + }, + ) + expect(html).toBe('

    5

    ') + }, 30_000) +}) diff --git a/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts b/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts new file mode 100644 index 00000000000..0474619801a --- /dev/null +++ b/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts @@ -0,0 +1,42 @@ +import { BuildError } from '@tanstack/compiler-test-utils/build-harness' +import { expect, it } from 'vitest' +import { build, routeApp } from '../build-harness' + +// A route file that both imports and declares `Route` is invalid TypeScript +// (TS2440). A real build either fails with an error naming `Route`, or +// builds a page that renders; it never builds a page that breaks. Source: +// the #8504 confidence campaign (DX-07). +it.each(['vite', 'rspack'] as const)( + 'a route file that imports and declares Route fails its build naming Route, or renders (%s)', + async (bundler) => { + let outcome: string + try { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `import { createFileRoute } from '@tanstack/react-router' +import { Route } from '../other' +export const Route = createFileRoute('/')({ component: () =>

    page

    }) +`, + { env: 'client', files: { 'other.ts': 'export const Route = 1\n' } }, + ), + }) + outcome = String( + (await app.run(`await entry.mount(entry.Route.options.component)`)) + .html, + ) + } catch (error) { + if (error instanceof BuildError && /\bRoute\b/.test(error.message)) { + outcome = 'refused naming Route' + } else { + throw error + } + } + expect([ + 'refused naming Route', + '

    page

    ', + ]).toContain(outcome) + }, + 60_000, +) diff --git a/packages/router-plugin/tests/code-splitter-robustness.test.ts b/packages/router-plugin/tests/integration/code-splitter-robustness.test.ts similarity index 96% rename from packages/router-plugin/tests/code-splitter-robustness.test.ts rename to packages/router-plugin/tests/integration/code-splitter-robustness.test.ts index a001bb30b3b..d35baf33554 100644 --- a/packages/router-plugin/tests/code-splitter-robustness.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-robustness.test.ts @@ -3,8 +3,8 @@ * non-ASCII names and JavaScript route files, in real builds. */ import { expect, test } from 'vitest' -import { build, clientMountEntry, rootRoute } from './build-harness' -import { head } from './regression-helpers' +import { build, clientMountEntry, rootRoute } from '../build-harness' +import { head } from '../regression-helpers' test.each(['vite', 'rspack', 'webpack'] as const)( 'a route file with a non-ASCII name is generated, split and rendered (%s)', diff --git a/packages/router-plugin/tests/code-splitter-router.test.ts b/packages/router-plugin/tests/integration/code-splitter-router.test.ts similarity index 98% rename from packages/router-plugin/tests/code-splitter-router.test.ts rename to packages/router-plugin/tests/integration/code-splitter-router.test.ts index 642f6ebb977..61d629f5ab8 100644 --- a/packages/router-plugin/tests/code-splitter-router.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-router.test.ts @@ -4,8 +4,8 @@ * render where the router needs them. */ import { describe, expect, test } from 'vitest' -import { build } from './build-harness' -import { head } from './regression-helpers' +import { build } from '../build-harness' +import { head } from '../regression-helpers' import type { BuildEnv } from '@tanstack/compiler-test-utils/build-harness' const rootRoute = `import { createRootRoute, Outlet } from '@tanstack/react-router' diff --git a/packages/router-plugin/tests/code-splitter-runtime.test.ts b/packages/router-plugin/tests/integration/code-splitter-runtime.test.ts similarity index 96% rename from packages/router-plugin/tests/code-splitter-runtime.test.ts rename to packages/router-plugin/tests/integration/code-splitter-runtime.test.ts index 4eeb954a3c9..60195011860 100644 --- a/packages/router-plugin/tests/code-splitter-runtime.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-runtime.test.ts @@ -4,10 +4,10 @@ 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' +import { tanstackRouter } from '../../src/vite' +import type { CodeSplitGroupings } from '../../src/core/constants' -const fixture = path.resolve(__dirname, 'test-files/shared-runtime.tsx') +const fixture = path.resolve(__dirname, '../test-files/shared-runtime.tsx') const groupings: Array<{ name: string; groupings: CodeSplitGroupings }> = [ { name: 'eager loader', groupings: [['component']] }, diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts new file mode 100644 index 00000000000..fab29dd8e43 --- /dev/null +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts @@ -0,0 +1,455 @@ +/** + * Known code-splitter bugs. Each test asserts correct behaviour for a bug on + * main and is marked .fails; remove .fails when the bug is fixed. + */ +import { BuildError } from '@tanstack/compiler-test-utils/build-harness' +import { describe, expect, test } from 'vitest' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' +import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' + +/** + * Bundlers whose router plugin splits routes (the esbuild adapter does not; + * pinned in build-harness.test.ts). + */ +const clientBundlers: Array = ['vite', 'rspack', 'webpack'] + +describe('route options written in other shapes', () => { + // The unused `makeOptions` of the two rows above may name a `Page` its + // module no longer declares (user ruling 2026-10-17, widened: the bundlers + // delete it), as long as that dead code does not reach a production client + // bundle (which the build harness also checks for any dangling name). + test.each( + clientBundlers.flatMap((bundler) => + [ + `createFileRoute('/')({ ssr: false, component: Page } as any)`, + `(createFileRoute('/') as any)({ ssr: false, component: Page })`, + ].map((route) => ({ bundler, route })), + ), + )( + 'a route written as $route drops its unused options factory from a production client build ($bundler)', + async ({ bundler, route }) => { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `${head}function Page() { + return

    page

    +} +const makeOptions = () => ({ component: Page, title: 'MARK_MAKE_OPTIONS_END' }) +export const Route = ${route} +`, + { env: 'client' }, + ), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component)`, + ) + expect({ + makeOptions: app.where('MARK_MAKE_OPTIONS_END'), + html: run.html, + }).toEqual({ + makeOptions: 'nowhere', + html: '

    page

    ', + }) + }, + 60_000, + ) +}) + +describe('what split chunks hold', () => { + // Bug: once the pin above is fixed, the split option must also end up + // split, or the build must say why (decision D11). Splitting the result of + // a call would change when the call runs, so the user has to change the + // pattern: a warning naming the file and the option is acceptable. Today + // the build fails first (same root cause as the pin above). + // Impact: the component loads with the entry chunk. + test.fails( + 'a component declared by an array destructuring of a call shared with the errorComponent is split or reported', + async () => { + const app = await build({ + bundler: 'vite', + env: 'client', + files: routeApp( + `${head}const createPair = () => [() =>
    {'MARK_PAGE'}
    , () =>

    error

    ] +const [Page, ErrorView] = createPair() +export const Route = createFileRoute('/')({ + component: Page, + errorComponent: ErrorView, +}) +`, + { env: 'client' }, + ), + }) + const reported = [...app.warnings, ...app.consoleMessages].some( + (message) => + message.includes('routes/index.tsx') && + /\b(?:component|Page)\b/.test(message), + ) + expect({ + lazy: app.where('MARK_PAGE') === 'lazy', + reported, + }).not.toEqual({ lazy: false, reported: false }) + }, + 60_000, + ) +}) + +describe('exported bindings', () => { + // Bug: same as above, in a real client build whose entry imports the + // exported component (`export * from './routes/index'`): the component is + // in both the entry chunk and the split chunk. One instance (split, or + // unsplit with a warning naming the route file, decision D11) is the goal. + // Impact: the component's code ships twice and runs as two components. + test.fails.each(clientBundlers)( + 'an exported errorComponent written as `PostErrorComponent as any` is in the client once (%s build)', + async (bundler) => { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `${head}export const Route = createFileRoute('/posts/$postId')({ + errorComponent: PostErrorComponent as any, + component: () =>

    post

    , +}) +export function PostErrorComponent() { + return

    {'MARK_POST_ERROR_END'}

    +} +`, + { env: 'client' }, + ), + }) + const { result: lazy } = await app.run( + `return typeof entry.Route.options.errorComponent.preload === 'function'`, + ) + const reported = [...app.warnings, ...app.consoleMessages].some( + (message) => + message.includes('routes/index.tsx') && + /\b(?:PostErrorComponent|errorComponent)\b/.test(message), + ) + const placement = app.where('MARK_POST_ERROR_END') + expect(['lazy', 'eager']).toContain(placement) + expect({ splitOrReported: lazy === true || reported }).toEqual({ + splitOrReported: true, + }) + }, + 60_000, + ) +}) + +describe('code that reads a split binding', () => { + /** + * Builds a route whose split component `Page` an unused module-level + * object also names, and mounts the component in the client bundle. + */ + async function mountUnusedReader(bundler: Bundler) { + const app = await build({ + bundler, + env: 'client', + files: routeApp( + `${head}function Page() { + return

    MARK_PAGE

    +} +const opts = { component: Page } +export const Route = createFileRoute('/')({ component: Page }) +`, + { env: 'client' }, + ), + }) + const run = await app.run( + `await entry.mount(entry.Route.options.component)`, + ) + return run.html + } + + // Control for the pin below: Rspack and webpack drop the unused object + // while minifying, together with its read of the deleted binding. + test.each(['rspack', 'webpack'] as const)( + 'a split component that an unused object names renders (%s)', + async (bundler) => { + expect(await mountUnusedReader(bundler)).toContain('

    MARK_PAGE

    ') + }, + 60_000, + ) + + // Bug: same root cause as above, for an unused module-level object that + // names the split component: the route module keeps the object but loses + // `Page`. Rolldown keeps the object, as reading an undeclared binding can + // throw. (The build harness now rejects that output before it runs: its + // entry chunk reads the undeclared `Page`.) + // Source: Wave B (a Vite Start client bundle crashed on it). + // Impact: the client bundle throws when the route module loads ("Page is + // not defined"), so the app never renders. + test.fails( + 'a split component that an unused object names renders (vite)', + async () => { + expect(await mountUnusedReader('vite')).toContain('

    MARK_PAGE

    ') + }, + 60_000, + ) +}) + +describe('server-only route options on the client', () => { + /** Start's client configuration of the code splitter. */ + const deleteNodes = ['ssr', 'server', 'headers'] + + /** A client build with Start's `deleteNodes`, reading a server secret. */ + async function secretPlacement(bundler: Bundler, option: string) { + const app = await build({ + bundler, + env: 'client', + pluginOptions: { codeSplittingOptions: { deleteNodes } }, + files: routeApp( + `${head}import { readSecret } from '../db.server' +export const Route = createFileRoute('/')({ + ${option}, + component: () =>

    page

    , +}) +`, + { + env: 'client', + files: { + 'db.server.ts': `export function readSecret() { + return (globalThis as any).MARK_SERVER_SECRET +} +`, + }, + }, + ), + }) + return app.where('MARK_SERVER_SECRET') + } + + // Control for the build-level canary below. + test.each(clientBundlers)( + 'a client build has no server code of an `ssr` property (%s)', + async (bundler) => { + expect(await secretPlacement(bundler, 'ssr: () => !!readSecret()')).toBe( + 'nowhere', + ) + }, + 60_000, + ) + + // Bug: build-level canary of the method pin above (same root cause). + // Impact: the server code of an `ssr()` method reaches the client bundle. + test.fails.each(clientBundlers)( + 'a client build has no server code of an `ssr()` method (%s)', + async (bundler) => { + expect( + await secretPlacement(bundler, 'ssr() { return !!readSecret() }'), + ).toBe('nowhere') + }, + 60_000, + ) +}) + +describe('symlinked route files', () => { + /** + * A client build with `deleteNodes: ['server']` of a route file reached + * through `routes/index.tsx`, a symlink when `linked`; returns where the + * server handler's code is, or `refused` when the build fails naming the + * route file. + */ + async function serverCodePlacement(bundler: Bundler, linked: boolean) { + const route = `${head}import { readSecret } from '../db.server' +export const Route = createFileRoute('/')({ + server: { handlers: { GET: () => readSecret() } }, + component: () =>

    page

    , +}) +` + const files = routeApp(route, { + env: 'client', + files: { + 'db.server.ts': `export function readSecret() { + return (globalThis as any).MARK_SERVER_SECRET +} +`, + }, + }) + if (linked) { + delete files['routes/index.tsx'] + files['linked/index.tsx'] = route + } + try { + const app = await build({ + bundler, + env: 'client', + pluginOptions: { codeSplittingOptions: { deleteNodes: ['server'] } }, + files, + symlinks: linked ? { 'routes/index.tsx': 'linked/index.tsx' } : {}, + }) + return app.where('MARK_SERVER_SECRET') + } catch (error) { + // A refusal names the route file + if ( + error instanceof BuildError && + error.errors.join('\n').includes('index.tsx') + ) { + return 'refused' + } + throw error + } + } + + // Control for the pin below (same app, a regular route file). + test.each(clientBundlers)( + 'a client build has no server handler code (%s)', + async (bundler) => { + expect(await serverCodePlacement(bundler, false)).toBe('nowhere') + }, + 60_000, + ) + + // Bug: the code splitter looks route files up by the path the generator + // scanned (the symlink), but bundlers resolve modules to their real path, + // so a symlinked route file is never compiled: nothing is split or + // removed, silently. Failing the build is a valid fix too. Source: #8514. + // Impact: with symlinked sources (Bazel, Nix, linked checkouts), Start's + // client bundle ships `server.handlers` and their server-only imports. + test.fails.each(clientBundlers)( + 'a client build of a symlinked route file has no server handler code (%s)', + async (bundler) => { + expect(['nowhere', 'refused']).toContain( + await serverCodePlacement(bundler, true), + ) + }, + 60_000, + ) +}) + +describe('source maps', () => { + /** + * Source files named by the source maps of a client build's chunks that + * hold the split component (`MARK_PAGE`) or a binding the route module + * shares with it (`MARK_SHARED`). + */ + async function splitChunkSources(bundler: Bundler) { + const app = await build({ + bundler, + env: 'client', + minify: false, + sourcemap: true, + files: routeApp( + `${head}const shared = { label: 'MARK_SHARED' } +function Page() { + return

    MARK_PAGE {shared.label}

    +} +export const Route = createFileRoute('/')({ + loader: () => shared, + component: Page, +}) +`, + { env: 'client' }, + ), + }) + const chunks = app.chunks.filter( + (chunk) => + chunk.code.includes('MARK_PAGE') || chunk.code.includes('MARK_SHARED'), + ) + return [...new Set(chunks.flatMap((chunk) => chunk.map?.sources ?? []))] + } + + /** Whether the sources name the route file, and none a virtual module. */ + function namesRouteFile(sources: Array) { + return { + routeFile: sources.some((source) => source.includes('routes/index.tsx')), + virtualModules: sources.filter((source) => /[?&]tsr-/.test(source)), + } + } + + // Control for the pin below: Rspack and webpack name modules by their + // resource path. + test.each(['rspack', 'webpack'] as const)( + 'split code maps to the route file (%s)', + async (bundler) => { + expect(namesRouteFile(await splitChunkSources(bundler))).toEqual({ + routeFile: true, + virtualModules: [], + }) + }, + 60_000, + ) + + // Bug: the split chunk and shared modules are compiled with their module + // id as the source file name, query included (`routes/index.tsx?tsr-split= + // component`), and Vite keeps the source names of the maps plugins return. + // Source: gap CC-S2 (plausible cause of #6667, #3828, #2991). + // Impact: devtools and error trackers show split code under a separate + // `index.tsx?tsr-split=…` file, so breakpoints set in the route file do + // not hit in split code. + test.fails( + 'split code maps to the route file (vite)', + async () => { + expect(namesRouteFile(await splitChunkSources('vite'))).toEqual({ + routeFile: true, + virtualModules: [], + }) + }, + 60_000, + ) +}) + +describe('production builds', () => { + /** + * A production client build of a route the code splitter warns about + * (today: an exported component it cannot split), with `NODE_ENV` as + * given (`null`: unset); returns whether the build warned, naming the + * route file, and where the app's absolute path (which the warning names) + * is in the bundle. + */ + async function warningPlacement(bundler: Bundler, nodeEnv?: null) { + const app = await build({ + bundler, + env: 'client', + nodeEnv, + files: routeApp( + `${head}export function Page() { + return

    page

    +} +export const Route = createFileRoute('/')({ + component: Page, + errorComponent: () =>

    error

    , +}) +`, + { env: 'client' }, + ), + }) + return { + warned: [...app.warnings, ...app.consoleMessages].some((message) => + message.includes('routes/index.tsx'), + ), + appPath: app.where(app.root), + } + } + + // Control for the pin below: with `NODE_ENV=production` exported. Its + // first assertion is a precondition of both tests, not a requirement: + // without a warning, the bundle has nothing to leak, so once the code + // splitter splits this route, use another route it warns about. + test.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no development warning (%s)', + async (bundler) => { + const { warned, appPath } = await warningPlacement(bundler) + expect(warned, 'precondition: the route makes the build warn').toBe(true) + expect(appPath).toBe('nowhere') + }, + 60_000, + ) + + // Bug: the code splitter's `console.warn` (today: "will not be + // code-split") is injected into the route module unless the build + // process's `NODE_ENV` is `production`, instead of following the + // bundler's mode as the rest of the code splitter does. webpack and Rspack + // set `NODE_ENV` only inside the bundle. Source: gap CC-P2. + // Impact: webpack/Rspack production bundles built from a shell without + // `NODE_ENV` log a development warning in every user's console, with the + // developer's absolute file path. + test.fails.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no development warning when NODE_ENV is unset (%s)', + async (bundler) => { + expect((await warningPlacement(bundler, null)).appPath).toBe('nowhere') + }, + 60_000, + ) +}) diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts new file mode 100644 index 00000000000..30c187a2fc4 --- /dev/null +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts @@ -0,0 +1,570 @@ +/** + * Known code-splitter bugs. Each test asserts correct behaviour for a bug on + * main and is marked .fails; remove .fails when the bug is fixed. + */ + +import { afterAll, describe, expect, test } from 'vitest' +import { build, buildWithRsbuild } from '../build-harness' +import { head } from '../regression-helpers' +import type { + BuildResult, + Bundler, +} from '@tanstack/compiler-test-utils/build-harness' + +/** + * Bundlers whose router plugin splits routes (the esbuild adapter does not; + * pinned in build-harness.test.ts). + */ +const clientBundlers: Array = ['vite', 'rspack', 'webpack'] + +/** + * Where a route's code ends up in a client build, which the bundler decides + * from what the code splitter emits, with or without side-effect hints. One + * app per bundler and hint holds a route file per case, each marking its + * page code with a marker; its entry imports the generated route tree, which + * imports only `Route` from each route file, like an app. + */ +describe('page code in client builds', () => { + type Case = { + name: string + /** Route file name in `routes/`; its path is `/`. */ + file: string + /** The route file after the `createFileRoute` import, with `$PATH`. */ + route: string + marker: string + } + + const page = (file: string, body: string): Case['route'] => + `${head}${body}`.replaceAll('$PATH', `/${file}`) + + const marker = (file: string) => `MARK_${file.replaceAll('-', '_')}_END` + + const routeCase = (name: string, file: string, body: string): Case => ({ + name, + file, + route: page(file, body.replaceAll('$MARK', marker(file))), + marker: marker(file), + }) + + const component = `export const Route = createFileRoute('$PATH')({ component: Page })` + + const displayName = routeCase( + 'a component the route file writes a property of', + 'display-name', + `function Page() { + return
    {'$MARK'}{Page.displayName}
    +} +Page.displayName = 'PageDisplay' +${component}`, + ) + + // Page code that loads with its chunk: code the route file does not + // export, and exported functions and classes (today the chunk declares its + // own copy, whose identity and state are pinned in "module state shared + // with split chunks" and "exported bindings", and the bundler drops the + // unused export of the route module). A fix must keep them out of the + // entry chunk. + const controls: Array = [ + routeCase( + 'a const only a split component renders', + 'local-const', + `const Card = () => {'$MARK'} +function Page() { + return +} +${component}`, + ), + routeCase( + 'an exported function component', + 'exported-function', + `export function Card() { + return {'$MARK'} +} +function Page() { + return +} +${component}`, + ), + routeCase( + 'a function component exported by specifier', + 'exported-specifier', + `function Card() { + return {'$MARK'} +} +export { Card } +function Page() { + return +} +${component}`, + ), + routeCase( + 'a default-exported function component', + 'exported-default', + `export default function Card() { + return {'$MARK'} +} +function Page() { + return +} +${component}`, + ), + routeCase( + 'an exported helper function', + 'exported-helper', + `export function format(value: number) { + return '$MARK' + value +} +function Page() { + return

    {format(1)}

    +} +${component}`, + ), + routeCase( + 'an exported class', + 'exported-class', + `export class Store { + value = '$MARK' +} +function Page() { + return

    {new Store().value}

    +} +${component}`, + ), + routeCase( + 'an exported function over module state', + 'exported-state', + `let count = 0 +export function next() { + count++ + return '$MARK' + count +} +function Page() { + return

    {next()}

    +} +${component}`, + ), + // A dependency with side effects next to the exported component: only + // the declaration is asserted, how the bundler groups the modules next + // to it is up to it. (A client build bundles `react/jsx-runtime`, a + // CommonJS module, whose interop call has side effects too.) + // Source: #8504 (code-splitter-exported-bindings-chunks.test.ts). + routeCase( + 'an exported function component with a dependency that has side effects', + 'exported-effect-dep', + `import { label } from '../label' +export function Card() { + return {'$MARK' + label} +} +function Page() { + return +} +${component}`, + ), + // What an exported function component imports loads with the chunk too. + // Source: #8504 (code-splitter-exported-bindings-chunks.test.ts). + { + ...routeCase( + "an exported function component's dependency", + 'exported-dep', + `import { heavy } from '../heavy' +export function Card() { + return {heavy()} +} +function Page() { + return +} +${component}`, + ), + marker: 'MARK_heavy_dependency_END', + }, + // Whether it renders is pinned at the compiler level ("top-level writes + // to the split component (property assignment) stay with the binding + // they write"); a fix must keep the component in its chunk. + // Source: the #8504 confidence campaign (exported-bindings fixture L, + // where a candidate fix moved the component to the entry). + routeCase( + 'an exported function component next to a helper the loader and the component share', + 'shared-helper-export', + `const label = (value: string) => value + '!' +export function Card() { + return {'$MARK'} +} +export const Route = createFileRoute('$PATH')({ + loader: () => label('data'), + component: () =>

    {label('page')}

    , +})`, + ), + ] + + // Bug: page code the route file exports as a variable, and an exported + // split component, stay in the route module, which the entry chunk loads, + // with everything they import: a split chunk imports exported variables + // from the route module instead of owning them, and an exported component + // is not split (the code splitter warns). Bundlers can only move the code + // out with the export unused, which the generated route tree (importing + // `Route` only) allows; whether our compiler or the bundler's + // tree-shaking does it is free, with or without `"sideEffects": false` or + // a `/* @__PURE__ */` initialiser. + // Sources: gaps S14, Z4, H1, H2; #8504's exported-bindings chunks tests. + // Impact: page code exported for tests or reuse loads with the entry chunk. + const exported: Array = [ + routeCase( + 'an exported const component only the split component renders', + 'exported-const', + `export const Card = () => {'$MARK'} +function Page() { + return +} +${component}`, + ), + routeCase( + 'an exported const with a pure initialiser', + 'exported-pure', + `function makeLabel(value: string) { + return { text: value } +} +export const label = /* @__PURE__ */ makeLabel('$MARK') +function Page() { + return

    {label.text}

    +} +${component}`, + ), + routeCase( + 'an exported function used as the component', + 'exported-component', + `export function Page() { + return

    {'$MARK'}

    +} +${component}`, + ), + routeCase( + 'a default-exported function used as the component', + 'exported-default-component', + `export default function Page() { + return

    {'$MARK'}

    +} +${component}`, + ), + // Source: the #8504 confidence campaign (exported-bindings S5, G1 and + // fixture C). + routeCase( + 'an exported const used as the component', + 'exported-const-component', + `export const Page = () =>

    {'$MARK'}

    +${component}`, + ), + routeCase( + "an exported component another route's split component renders", + 'exported-shared', + `export function Card() { + return {'$MARK'} +} +function Page() { + return +} +${component}`, + ), + ] + + // Bug: an exported component that two split options render goes to a + // module both chunks import, which the route module re-exports, so it + // loads with the entry chunk unless the app declares `"sideEffects": + // false` (then the bundler drops the unused re-export). + // Source: the #8504 confidence campaign (exported-bindings G1). + const exportedSharedByOptions = routeCase( + 'an exported component two split options render', + 'exported-two-options', + `export function Card() { + return {'$MARK'} +} +export const Route = createFileRoute('$PATH')({ + component: () =>

    , + errorComponent: () =>

    , +})`, + ) + + // Goals for split options the code splitter leaves in the route module. + const unsplit: Array = [ + // Bug: a route option written as a method (`component() {}`) is not + // split (only property values are). Source: gap S11, ruling on method + // shorthand. + // Impact: the component loads with the entry chunk. + routeCase( + 'a component written as a method', + 'method-component', + `export const Route = createFileRoute('$PATH')({ + component() { + return

    {'$MARK'}

    + }, +})`, + ), + ] + + // Bug: the root route's options are never split + // (`computeSharedBindings` and the reference compiler skip + // `createRootRoute`), so its errorComponent and notFoundComponent, which + // only render on errors, load with the entry chunk. Its `component` + // renders on every page and may stay. Source: gap S20, decision D12. + const rootRoute = `import { createRootRoute, Outlet } from '@tanstack/react-router' +export const Route = createRootRoute({ + component: () => , + errorComponent: () =>

    {'MARK_root_error_END'}

    , + notFoundComponent: () =>

    {'MARK_root_not_found_END'}

    , +}) +` + const rootMarkers = { + errorComponent: 'MARK_root_error_END', + notFoundComponent: 'MARK_root_not_found_END', + } + + // Bug: route options destructured from a call result + // (`const { component, loader } = createBits()`) are neither split nor + // reported: the destructuring moves to a module the route module imports + // statically, so the component's code loads with the entry, silently. + // Splitting them would change when the call runs, so the user has to + // change the pattern: a warning naming the file and the option is the + // expected outcome (decision D11). + const destructured = routeCase( + 'route options destructured from a call result', + 'destructured-options', + `function DefaultComponent() { + return
    Default
    +} +const createBits = () => ({ component: ActualComponent, loader: () => 'data' }) +const { component: MyComponent = DefaultComponent, loader } = createBits() +function ActualComponent() { + return
    {'$MARK'}
    +} +export const Route = createFileRoute('$PATH')({ component: MyComponent, loader })`, + ) + + // Bug: a split chunk keeps the route file's exported declarations that + // its option does not read (decision D5; compiler-level pin in "what + // split chunks hold"), and bundlers keep them. + const unreadExport = routeCase( + 'an exported helper the split option does not read', + 'unread-export', + `export function helper() { + return '$MARK' +} +function Page() { + return

    page

    +} +${component}`, + ) + + const cases = [ + ...controls, + ...exported, + ...unsplit, + exportedSharedByOptions, + destructured, + unreadExport, + ] + /** + * `mount(path)` mounts the component of the route at `path` into #root; + * `renderEach(paths)` mounts each into its own element and returns what + * each renders (or the error it throws), by path. + */ + const mountEntry = `import { createElement } from 'react' +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { routeTree } from './routeTree.gen' +export { routeTree } +async function mountInto(path: string, element: HTMLElement) { + const route = (routeTree.children as Array).find( + (child) => child.options.path === path, + ) + await route.options.component.preload?.() + const root = createRoot(element) + flushSync(() => root.render(createElement(route.options.component))) +} +/** Mounts the component of the route at \`path\` into #root. */ +export async function mount(path: string) { + await mountInto(path, document.getElementById('root')!) +} +export async function renderEach(paths: Array) { + const rendered: Record = {} + for (const path of paths) { + const element = document.createElement('div') + document.body.append(element) + try { + await mountInto(path, element) + rendered[path] = element.innerHTML + } catch (error) { + rendered[path] = 'threw: ' + String(error) + } + } + return rendered +} +` + const files = { + 'label.ts': `;(globalThis as any).labelLoaded = true +export const label = 'label' +`, + 'heavy.ts': `export function heavy() { + return 'MARK_heavy_dependency_END' +} +`, + // Renders the exported component of `exported-shared` in its split + // component. + 'routes/imports-exported.tsx': `${head}import { Card } from './exported-shared' +function Page() { + return +} +export const Route = createFileRoute('/imports-exported')({ component: Page }) +`, + 'routes/__root.tsx': rootRoute, + ...Object.fromEntries( + cases.map((row) => [`routes/${row.file}.tsx`, row.route]), + ), + 'entry.ts': mountEntry, + } + + /** + * A production client build of `files` with `bundler`, or with Rsbuild + * and its production defaults (`rsbuild`). + */ + const clientBuild = ( + bundler: Bundler | 'rsbuild', + options: { + sideEffects: boolean | undefined + files: Record + registerCleanup?: () => void + }, + ) => + bundler === 'rsbuild' + ? buildWithRsbuild(options) + : build({ bundler, env: 'client', ...options }) + + describe.each([...clientBundlers, 'rsbuild'] as const)('%s', (bundler) => { + describe.each([ + { hint: 'without a sideEffects field', sideEffects: undefined }, + { hint: 'with "sideEffects": false', sideEffects: false }, + ])('$hint', ({ sideEffects }) => { + let built: Promise | undefined + const app = () => + (built ??= clientBuild(bundler, { + sideEffects, + files, + registerCleanup: () => {}, + })) + afterAll(async () => { + await (await built)?.dispose() + }) + const timeout = 120_000 + let renders: Promise> | undefined + /** What the route of each case renders when mounted, by file. */ + const rendered = async (file: string) => { + renders ??= app().then(async (result) => { + const paths = cases.map((row) => `/${row.file}`) + const run = await result.run( + `return await entry.renderEach(${JSON.stringify(paths)})`, + ) + return run.result as Record + }) + return (await renders)[`/${file}`] + } + /** + * Where the case's marked page code is in the build, and whether its + * route renders it: the page code must load with its chunk and still + * work. + */ + const outcome = async ({ file, marker: mark }: Case) => ({ + placement: (await app()).where(mark), + rendersPage: (await rendered(file))?.includes(mark), + }) + const lazyAndRendered = { placement: 'lazy', rendersPage: true } + + test.each(controls)( + '$name loads with its chunk', + async (row) => { + expect(await outcome(row)).toEqual(lazyAndRendered) + }, + timeout, + ) + + // Bug: the code splitter moves a component into its chunk but leaves + // a top-level write to it (`Page.displayName = ...`) in the route + // module, where `Page` is no longer declared (same bug as "top-level + // writes to the split component (property assignment) stay with the + // binding they write"). Keeping a copy of the component and its write + // in the route module as well is no fix: the page code then loads + // with the entry chunk. Its own app: the build harness rejects the + // production client output, whose entry chunk reads the undeclared + // `Page`. + // Source: the #8504 probe (memos/splitter.md B6). + // Impact: the app's entry throws when it loads ("Page is not + // defined"). + test.fails( + `${displayName.name} loads with its chunk and renders the property`, + async () => { + const built = await clientBuild(bundler, { + sideEffects, + files: { + 'routes/__root.tsx': rootRoute, + [`routes/${displayName.file}.tsx`]: displayName.route, + 'entry.ts': mountEntry, + }, + }) + const { html } = await built.run(`await entry.mount('/display-name')`) + expect({ placement: built.where(displayName.marker), html }).toEqual({ + placement: 'lazy', + html: `
    ${displayName.marker}PageDisplay
    `, + }) + }, + timeout, + ) + + test.fails.each([...exported, ...unsplit])( + '$name loads with its chunk', + async (row) => { + expect(await outcome(row)).toEqual(lazyAndRendered) + }, + timeout, + ) + ;(sideEffects === false ? test : test.fails)( + `${exportedSharedByOptions.name} loads with its chunk`, + async () => { + expect(await outcome(exportedSharedByOptions)).toEqual( + lazyAndRendered, + ) + }, + timeout, + ) + + test.fails.each(['errorComponent', 'notFoundComponent'] as const)( + "a root route's %s loads lazily", + async (option) => { + expect((await app()).where(rootMarkers[option])).toBe('lazy') + }, + timeout, + ) + + test.fails( + `${destructured.name} load with their chunk or are reported`, + async () => { + const built = await app() + const reported = [...built.warnings, ...built.consoleMessages].some( + (message) => + message.includes(`routes/${destructured.file}.tsx`) && + /\b(?:component|MyComponent)\b/.test(message), + ) + expect({ + lazy: built.where(destructured.marker) === 'lazy', + reported, + }).not.toEqual({ lazy: false, reported: false }) + }, + timeout, + ) + + test.fails( + `${unreadExport.name} is not in its chunk`, + async () => { + expect((await app()).locate(unreadExport.marker).lazy).toEqual([]) + }, + timeout, + ) + }) + }) +}) diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts new file mode 100644 index 00000000000..77dbf07e295 --- /dev/null +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts @@ -0,0 +1,440 @@ +/** + * Known code-splitter bugs. Each test asserts correct behaviour for a bug on + * main and is marked .fails; remove .fails when the bug is fixed. + */ +import { BuildError } from '@tanstack/compiler-test-utils/build-harness' +import { describe, expect, test } from 'vitest' +import { build, routeApp } from '../build-harness' +import { buildAndRun, head } from '../regression-helpers' +import type { CodeSplitGroupings } from '../../src/core/constants' + +describe('module state shared with split chunks', () => { + // Bug: a `let` that code in another module than its declaration reassigns + // is imported there, read-only: a `let` the loader reassigns is shared, so + // the route module assigns to an import of the shared module; an exported + // `let` the split component reassigns stays in the route module, which the + // chunk imports it from ("Cannot assign to import"). + // Impact: the route cannot be built as soon as code splitting is on. + // Needs a real build: imports are live bindings, and the in-process harness + // snapshots them, so even a correct fix would read a stale value there. + test.fails.each([ + { + name: 'the loader reassigns', + route: `${head}let count = 0 +export const Route = createFileRoute('/')({ + loader: () => { count++; return count }, + component: () =>

    {count}

    , +})`, + script: `const loaded = [await entry.Route.options.loader({}), await entry.Route.options.loader({})] +return [...loaded, await entry.render(entry.Route.options.component)]`, + expected: [1, 2, '

    2

    '], + }, + { + name: 'the split component reassigns (exported)', + route: `${head}export let count = 0 +function Page() { + count++ + return

    {count}

    +} +export const Route = createFileRoute('/')({ component: Page })`, + script: `const component = entry.Route.options.component +return [await entry.render(component), await entry.render(component), entry.count]`, + expected: ['

    1

    ', '

    2

    ', 2], + }, + ])( + 'a let $name stays writable and the split component reads its value', + async ({ route, script, expected }) => { + expect(await buildAndRun(route, script)).toEqual(expected) + }, + 30_000, + ) +}) + +describe('split route options', () => { + /** Loads `/` with a real router and returns each match's loader data. */ + const routerEntry = `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) +}` + + function loadWithSplitLoader(loader: string, declarations = '') { + return buildAndRun( + `${head}${declarations}export const Route = createFileRoute('/')({ + loader: ${loader}, + component: () =>

    index

    , +})`, + `return await entry.load()`, + { + files: { 'entry.ts': routerEntry }, + groupings: [['loader'], ['component']], + }, + ) + } + + // Control for the object-form loader pin below (same harness). + test('a split loader loads route data', async () => { + expect(await loadWithSplitLoader(`() => 'loaded'`)).toEqual([ + null, + 'loaded', + ]) + }, 30_000) + + // Bug: the object form `loader: { handler }` cannot be split: the virtual + // compiler throws "Unexpected splitNode type ☝️: ObjectExpression". + // Splitting only the handler, or keeping the object in the route module, + // are both valid fixes. + // Impact: a route with an object-form loader cannot be built once the + // loader is split. + test.fails.each([ + { name: 'an arrow handler', loader: `{ handler: () => 'loaded' }` }, + { name: 'a method handler', loader: `{ handler() { return 'loaded' } }` }, + { + name: 'a spread after the handler', + loader: `{ handler: () => 'loaded', ...defaults }`, + declarations: `const defaults = { staleReloadMode: 'blocking' as const }\n`, + }, + ])( + 'a split loader in object form with $name loads route data', + async ({ loader, declarations }) => { + expect(await loadWithSplitLoader(loader, declarations)).toEqual([ + null, + 'loaded', + ]) + }, + 30_000, + ) + + // Bug: an object-form loader passed by reference (`loader` holding + // `{ handler }`) is split as if it were the loader function, so the route + // calls the object ("... is not a function"). + // Impact: the route's data never loads once the loader is split. + test.fails( + 'a split loader in object form passed by reference loads route data', + async () => { + expect( + await loadWithSplitLoader( + 'loader', + `const loader = { handler: () => 'loaded' }\n`, + ), + ).toEqual([null, 'loaded']) + }, + 30_000, + ) +}) + +describe('exported bindings', () => { + /** + * An entry that imports `Route` and, like another module of the app, + * `imports` from the route file, then runs `setup` before any split chunk + * loads. + */ + const entryImporting = ( + imports: string, + setup: string, + ) => `import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { Route, ${imports} } from './routes/index' +export { Route } +${setup} +export async function render(component: any) { + await component.preload?.() + return renderToString(createElement(component)) +} +` + + // Bug: a split chunk declares its own copy of the exported functions and + // classes of the route file it reads (and of the private state they read), + // so another module of the app that imports them gets other instances than + // the split component. Build-level form of "module state that the + // reference module keeps for a binding read by an exported function". + // Source: #8504 (code-splitter-exported-bindings-identity.test.ts), where + // they pass. + // Impact: `instanceof` checks, registries and counters see two copies + // once code splitting is on; module state is created twice. + test.fails.each([ + { + // An instance another module creates is an instance of the class the + // split component reads + name: 'class (instanceof)', + route: `export class Store {} +function Page() { + return

    {String((globalThis as any).__store instanceof Store)}

    +}`, + imports: 'Store', + setup: ';(globalThis as any).__store = new Store()', + expected: ['

    true

    '], + }, + { + // One state: the entry's call is the first, the split component's the + // second + name: 'function over module state', + route: `let count = 0 +export function next() { + return ++count +} +function Page() { + return

    {next()}

    +}`, + imports: 'next', + setup: 'next()', + expected: ['

    2

    '], + }, + { + // Created once and kept. The module that holds the state then has a + // top-level call, so the bundler has to keep it where it is imported + name: 'function over created state', + route: `import { init } from '../state' +const state = init({ count: 0 }) +export function increment() { + return ++state.count +} +function Page() { + return

    {increment()}

    +}`, + imports: 'increment', + setup: 'increment()', + expected: ['

    2

    ', 1], + }, + { + name: 'function component', + route: `export function Card() { + return card +} +function Page() { + return

    {String((globalThis as any).__Card === Card)}

    +}`, + imports: 'Card', + setup: ';(globalThis as any).__Card = Card', + expected: ['

    true

    '], + }, + { + name: 'component and its private state', + route: `const registry = new Set() +function size() { + return registry.size +} +export function Card({ id }: { id: string }) { + registry.add(id) + return {size()} +} +function Page() { + return ( +

    + {String((globalThis as any).__Card === Card)} + +

    + ) +}`, + imports: 'Card', + setup: ";(globalThis as any).__Card = Card\nCard({ id: 'entry' })", + expected: ['

    true2

    '], + }, + ])( + 'the split component and other modules share an exported $name', + async ({ route, imports, setup, expected }) => { + const result = await buildAndRun( + `${head}${route} +export const Route = createFileRoute('/')({ component: Page }) +`, + `const html = await entry.render(entry.Route.options.component) +return globalThis.__inits === undefined ? [html] : [html, globalThis.__inits]`, + { + files: { + 'entry.ts': entryImporting(imports, setup), + 'state.ts': `export function init(value: T) { + ;(globalThis as any).__inits = ((globalThis as any).__inits ?? 0) + 1 + return value +} +`, + }, + }, + ) + expect(result).toEqual(expected) + }, + 30_000, + ) +}) + +describe('app paths', () => { + const route = `${head}export const Route = createFileRoute('/')({ + component: () =>

    index

    , +})` + const script = `return await entry.render(entry.Route.options.component)` + + // Control for the `#` pin below (same app, without `#`). + test('an app builds and preloads a split component', async () => { + expect(await buildAndRun(route, script)).toBe('

    index

    ') + }, 30_000) + + // Bug: the route module imports its chunks by absolute file path with the + // split query (`/routes/index.tsx?tsr-split=component`), and a `#` + // in the path is read as the start of a URL fragment. + // Impact: an app in a directory whose name contains `#` cannot be built + // with code splitting ([UNRESOLVED_IMPORT]). + test.fails( + 'an app in a directory with # in its name builds and preloads a split component', + async () => { + expect( + await buildAndRun(route, script, { prefix: '.regression-build-#' }), + ).toBe('

    index

    ') + }, + 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 the modules of + // an app in a directory named like `tsr-split-demo`. + // Source: round-3 mutation testing (splitter-hosts P3). + // Impact: an app in such a directory cannot be built. + test.fails( + 'an app in a directory whose name contains tsr-split builds and preloads a split component', + async () => { + expect( + await buildAndRun(route, script, { + prefix: '.regression-build-tsr-split-', + }), + ).toBe('

    index

    ') + }, + 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. + // Source: #7754. + // Impact: an app in a directory whose name contains `'` (e.g. a user + // folder) cannot be built or served once code splitting is on: every route + // fails with a syntax error. + test.fails( + "an app in a directory with ' in its name builds and preloads a split component", + async () => { + expect( + await buildAndRun(route, script, { prefix: ".regression-build-it's-" }), + ).toBe('

    index

    ') + }, + 30_000, + ) +}) + +describe('code that reads a split binding', () => { + // Bug: same root cause as above, in a real build. + // Impact: the server render of the route throws "PostsPage is not defined" + // when it runs head(). + test.fails.each(['vite', 'rspack'] as const)( + 'an imported split component that head() reads renders, and head() reads it (%s build)', + async (bundler) => { + const app = await build({ + bundler, + env: 'ssr', + minify: false, + files: routeApp( + `${head}import { PostsPage } from '../posts-page' +export const Route = createFileRoute('/')({ + component: PostsPage, + head: () => ({ meta: [{ title: PostsPage.title }] }), +}) +`, + { + files: { + 'posts-page.tsx': `export function PostsPage() { + return

    posts

    +} +PostsPage.title = 'Posts' +`, + }, + }, + ), + }) + const { result } = await app.run( + `return [entry.Route.options.head().meta[0].title, await entry.render(entry.Route.options.component)]`, + ) + expect(result).toEqual(['Posts', '

    posts

    ']) + }, + 60_000, + ) +}) + +describe('`this` in split callbacks', () => { + /** Whether a message names the route file and `this`. */ + const reportsThis = (message: string) => + message.includes('routes/index.tsx') && /\bthis\b/.test(message) + + /** + * Builds an app whose `/` route has `loader`, with the given groupings, + * loads `/` with a real router and returns the route's loader data; or + * `reported` when the build fails or warns about `this`. + */ + async function loadWithLoader(loader: string, groupings: CodeSplitGroupings) { + let app + try { + app = await build({ + bundler: 'vite', + env: 'ssr', + minify: false, + files: routeApp( + `${head}export const Route = createFileRoute('/')({ + loader: ${loader}, + component: () =>

    index

    , +})`, + { + files: { + '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.at(-1)?.loaderData +}`, + }, + }, + ), + pluginOptions: { codeSplittingOptions: { defaultBehavior: groupings } }, + }) + } catch (error) { + if (error instanceof BuildError && reportsThis(error.message)) { + return 'reported' + } + throw error + } + if ([...app.warnings, ...app.consoleMessages].some(reportsThis)) { + return 'reported' + } + return (await app.run('return await entry.load()')).result + } + + const readsThis = `function () { + return this === undefined ? 'no this' : 'this: ' + Object.keys(this).join() +}` + + // Control for the pin below: the router calls an unsplit loader without a + // `this`. + test('an unsplit loader is called without a `this`', async () => { + expect(await loadWithLoader(readsThis, [['component']])).toBe('no this') + }, 60_000) + + // Bug: a split loader is called as a method of its chunk's module + // namespace (`lazyFn` calls `imported[key](...args)`), so its `this` is + // the chunk's exports, where the router calls an unsplit loader without a + // `this`. Source: #8688. + // Impact: a loader that reads `this` behaves differently once split, + // without any diagnostic. + test.fails( + 'a split loader sees the same `this` as an unsplit one, or the build reports it', + async () => { + expect(['no this', 'reported']).toContain( + await loadWithLoader(readsThis, [['loader'], ['component']]), + ) + }, + 60_000, + ) +}) diff --git a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts new file mode 100644 index 00000000000..d884c1e35c1 --- /dev/null +++ b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts @@ -0,0 +1,454 @@ +/** + * Known route HMR and React Refresh bugs. Each test asserts correct behaviour + * for a bug on main and is marked .fails; remove .fails when the bug is fixed. + * + * Route HMR keeps the previous `component` (and the other component options) + * so that React Refresh can patch it in place; a component React Refresh does + * not register keeps rendering the old code until a full reload. Pins of what + * the page shows run a real dev server and a real browser page + * (`route-hmr-page.ts`). + */ + +import { describe, expect, test } from 'vitest' +import type { TestContext } from 'vitest' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' +import { openRouteHmrPage } from '../route-hmr-page' +import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' + +// Route HMR in a real dev server and a real browser page +// (`route-hmr-page.ts`, as route-hmr-browser.test.ts). A pin waits for the +// new code at most `pinTimeout`, well under the test timeout. +// Concurrent: each pin mostly waits (`pinTimeout`) for an edit that never +// shows. +describe.concurrent('route HMR in a real page', { timeout: 60_000 }, () => { + const pinTimeout = 3_000 + + /** A React route at `/` whose component, `name`, keeps a click count. */ + const counterRoute = ( + version: string, + name = 'Page', + options = '', + ) => `import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +function ${name}() { + const [count, setCount] = useState(0) + return +} +function Failed() { + return

    failed ${version}

    +} +export const Route = createFileRoute('/')({ + component: ${name}, + ${options} +}) +` + + /** + * Opens a React app in Vite whose route file `file` is `route('v1')`, + * waits until it shows `shows('v1')`, edits it to `route('v2')` and + * returns whether the page reloaded once it shows `shows('v2')` (waiting + * at most `timeout`). + */ + async function editComponent( + { + file, + autoCodeSplitting, + route, + shows, + }: { + file: string + autoCodeSplitting: boolean + route: (version: string) => string + shows: (version: string) => string + }, + onTestFinished: TestContext['onTestFinished'], + timeout?: number, + ) { + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { [file]: route('v1') }, + ready: shows('v1'), + }) + return app.edit({ [file]: route('v2') }, shows('v2'), { timeout }) + } + + /** A route at `/` with the `component` option `option`, after `imports`. */ + const inlineRoute = + (imports: string, option: (version: string) => string) => + (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +${imports} +export const Route = createFileRoute('/')({ + ${option(version)}, +}) +` + + // Control for the inline component pins below (same harness). + test('vite: an edit of a component passed as memo(Page) shows in place (autoCodeSplitting: false)', async ({ + expect, + onTestFinished, + }) => { + expect( + await editComponent( + { + file: 'routes/index.tsx', + autoCodeSplitting: false, + route: inlineRoute( + `import { memo } from 'react' +function Page() { + return

    page v1

    +}`, + () => 'component: memo(Page)', + ), + shows: () => 'page v1', + }, + onTestFinished, + ), + ).toEqual({ reloaded: false }) + }) + + // Bug: inline component options that are not plain functions (`memo(...)`, + // `forwardRef(...)`, method shorthand, a function wrapped in a TypeScript + // `as` / `satisfies`) are neither hoisted nor registered for React + // Refresh, nor is the function they wrap; route HMR keeps the previous + // component for React Refresh to patch, which it cannot. + // Impact: on unsplit routes (root routes, or file routes without automatic + // code splitting), and for a split method-shorthand component, edits to + // these components do not show until a reload. + // Source: ReactFreshBabelPlugin-test "registers likely HOCs with inline + // functions" and "registers top-level variable declarations with arrow + // functions"; #8504 react-refresh-route-components ("written inline and + // wrapped in ..."), where the wrapped rows pass; ruling on #8721 "keeps a + // method-shorthand component option" (split row). Formerly read React + // Refresh's registrations and modelled its runtime (ruling 2026-10-18). + // Any fix passes: the edit shows without a full reload. + test.fails.for([ + ...[ + ['as any', '', `(() =>

    inline VERSION

    ) as any`], + [ + 'satisfies', + `import type { FC } from 'react'`, + `(() =>

    inline VERSION

    ) satisfies FC`, + ], + [ + 'memo(() => ...)', + `import { memo } from 'react'`, + `memo(() =>

    inline VERSION

    )`, + ], + [ + 'forwardRef(function () { ... })', + `import { forwardRef } from 'react'`, + `forwardRef(function (props, ref) { + return

    inline VERSION

    + })`, + ], + ].map(([name, imports, component]) => ({ + name: name!, + file: 'routes/index.tsx', + autoCodeSplitting: false, + route: inlineRoute( + imports!, + (version) => `component: ${component!.replace('VERSION', version)}`, + ), + shows: (version: string) => `inline ${version}`, + })), + ...[false, true].map((autoCodeSplitting) => ({ + name: 'method shorthand', + file: 'routes/index.tsx', + autoCodeSplitting, + route: inlineRoute( + '', + (version) => `component() { + return

    inline ${version}

    + }`, + ), + shows: (version: string) => `inline ${version}`, + })), + { + name: 'React.memo(forwardRef(...)) on a root route', + file: 'routes/__root.tsx', + autoCodeSplitting: true, + route: (version: string) => `import React, { forwardRef } from 'react' +import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({ + component: React.memo(forwardRef(function Root(props, ref) { + return

    root ${version}

    + })), +}) +`, + shows: (version: string) => `root ${version}`, + }, + ])( + 'vite: an edit of an inline component written as $name shows in place (autoCodeSplitting: $autoCodeSplitting)', + async (row, { expect, onTestFinished }: TestContext) => { + expect(await editComponent(row, onTestFinished, pinTimeout)).toEqual({ + reloaded: false, + }) + }, + ) + + // Bug: route HMR keeps the previous split components only for React: for + // Solid and Vue, the route module creates a new lazy component on every + // update and `handleRouteUpdate` keeps no component option + // (`getFrameworkHmrCompilerPlugins` returns no plugin and the kept + // component keys are empty for other targets); in Vite, the route module + // is not an HMR boundary either (its accept call is on an alias), so the + // page reloads. + // Impact: every edit of a Solid or Vue route file with automatic code + // splitting reloads the page in Vite dev, losing its state, instead of + // patching it in place. + // Goal (any fix): in a Vite dev server with the framework's plugin, the + // edit shows without a full reload, and the live route keeps the component + // the page rendered. + // Source: decision D10 (Solid/Vue split components keep their identity + // across hot updates, as React's do); gaps H8/H9. + test.fails.for(['solid', 'vue'] as const)( + 'vite: an edit of a split %s route shows in place, keeping the split component', + async ( + target: 'solid' | 'vue', + { expect, onTestFinished }: TestContext, + ) => { + const route = (version: string) => + `import { createFileRoute } from '@tanstack/${target}-router' +export const Route = createFileRoute('/')({ + loader: () => 'data ${version}', + component: Page, +}) +function Page() { + const data = Route.useLoaderData() + return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    +} +` + const app = await openRouteHmrPage({ + target, + onTestFinished, + files: { 'routes/index.tsx': route('v1') }, + ready: 'page v1 data v1', + }) + const liveComponent = `window.__TSR_ROUTER__.routesById['/'].options.component` + await app.evaluate(`window.shownComponent = ${liveComponent}`) + expect( + await app.edit({ 'routes/index.tsx': route('v2') }, 'page v2 data v2', { + timeout: pinTimeout, + }), + ).toEqual({ reloaded: false }) + expect( + await app.evaluate(`window.shownComponent === ${liveComponent}`), + ).toBe(true) + }, + ) + + // Bug: `handleRouteUpdate` keeps the previous component options so that + // React Refresh patches them in place, also when the edit replaced the + // component with another one (renamed, so another component family for + // React Refresh), which React Refresh never patches. With automatic code + // splitting, the kept lazy component keeps the component its chunk loaded + // first. The same happens when an edit replaces inline route options of a + // split route with options a factory call builds. + // Impact: after renaming a route's component, the page keeps showing the + // old component until a reload (Vite and Rsbuild). + // Source: router-plugin src/core/hmr/handle-route-update.ts (component + // keys kept whenever both versions have them); NEW (hmr-real, found by the + // real-page harness; the former model treated it as expected). + // Any fix passes: a hot update rendering the new component (React mounts + // it anew), or a reload. + describe.each(['vite', 'rsbuild'] as const)('%s', (bundler) => { + test.fails.for([true, false])( + 'renaming the route component shows the renamed component (autoCodeSplitting: %s)', + async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': counterRoute('v1') }, + ready: 'Page v1 count 0', + }) + await app.edit( + { 'routes/index.tsx': counterRoute('v2', 'Renamed') }, + 'Renamed v2 count', + { timeout: pinTimeout }, + ) + }, + ) + }) + + // Bug: on Rsbuild (Rspack), an edit that only changes a loader split from + // the route module (`codeSplitGroupings` with `loader` in a chunk) never + // reaches the page: the route module's code is unchanged, so Rspack does + // not run it again and the route HMR code (`webpack-adapter.ts`, in the + // route module only) never refreshes the route; the split loader module + // accepts no update of its own. With Rsbuild's default lazy compilation + // the loaded chunk is not even updated; without it (`dev.lazyCompilation: + // false`), the next load gets the new loader, but nothing reloads the + // route. Vite updates the route in place (route-hmr-browser.test.ts). + // Impact: the page keeps the old loader's data until a reload or a + // navigation, with no sign that the edit was not applied. + // Source: router-plugin src/core/hmr/webpack-adapter.ts (HMR code only in + // the route module); NEW (hmr-real). + // Any fix passes: a hot update showing the new loader's data, or a reload. + test.fails.for([`[['loader'], ['component']]`, `[['component', 'loader']]`])( + 'rsbuild: an edit of a split loader (codeSplitGroupings: %s) shows its data', + async (groupings: string, { onTestFinished }: TestContext) => { + const route = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +function Page() { + return

    {Route.useLoaderData()}

    +} +export const Route = createFileRoute('/')({ + loader: () => 'loader ${version}', + component: Page, + codeSplitGroupings: ${groupings}, +}) +` + const app = await openRouteHmrPage({ + bundler: 'rsbuild', + onTestFinished, + files: { 'routes/index.tsx': route('v1') }, + ready: 'loader v1', + }) + await app.edit({ 'routes/index.tsx': route('v2') }, 'loader v2', { + timeout: pinTimeout, + }) + }, + ) + + // Bug: an edit that removes a split component option (here + // `errorComponent`, which the page had loaded) leaves its split module in + // Vite's module graph; Vite updates it with the rest of the route file, the + // code splitter serves it without any export or import, and + // `@vitejs/plugin-react` compiles that module's JSX as CommonJS + // (`require("react/jsx-dev-runtime")`). + // Impact: the page logs "[vite] ReferenceError: require is not defined" + // and "[vite] Failed to reload /routes/index.tsx?tsr-split=errorComponent"; + // some runs reload the page. + // Source: router-plugin src/core/code-splitter (the virtual split module + // of an option the route file no longer has); NEW (hmr-real). + // Goal (any fix): the edit applies in place, and the page logs no error. + test.fails( + 'vite: an edit that removes a split option the page loaded applies without errors', + async ({ expect, onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + onTestFinished, + files: { + 'routes/index.tsx': counterRoute( + 'v1', + 'Page', + `errorComponent: Failed, + loader: () => { + throw new Error('loader failed') + },`, + ), + }, + ready: 'failed v1', + }) + const errors = app.page.errors.length + expect( + await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { + timeout: pinTimeout, + }), + ).toEqual({ reloaded: false }) + expect(app.page.errors.slice(errors)).toEqual([]) + }, + ) + + // Bug: `handleRouteUpdate` keeps the previous component options for React + // Refresh to patch in place, also when no React Refresh runtime is + // installed (an Rsbuild React app with `pluginReact({ fastRefresh: false + // })`, or webpack / Rspack without a refresh plugin, like the webpack + // quickstart example with the default `addHmr`), so the new component + // never renders. With automatic code splitting, the kept component is the + // old chunk's. + // Impact: the bundler reports the route as hot-updated, but the page shows + // the old component until a reload; without the route HMR code the same + // edit reloads the page. + // Any fix passes: a hot update rendering the new component, or a reload. + test.fails.for([true, false])( + 'rsbuild without React Refresh: an edit of the route component shows (autoCodeSplitting: %s)', + async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + bundler: 'rsbuild', + onTestFinished, + reactRefresh: false, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': counterRoute('v1') }, + ready: 'Page v1 count 0', + }) + await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { + timeout: pinTimeout, + }) + }, + ) +}) + +describe('production builds', () => { + /** + * Builds a production client bundle with automatic code splitting on or + * off (off: the route HMR plugin's case), the plugin's own `addHmr` + * default and `NODE_ENV` as given (`null`: unset), and returns where a + * bundler HMR API is used. + */ + async function hmrPlacement( + bundler: Bundler, + autoCodeSplitting: boolean, + nodeEnv?: null, + ) { + const app = await build({ + bundler, + env: 'client', + nodeEnv, + pluginOptions: { + autoCodeSplitting, + codeSplittingOptions: { addHmr: undefined }, + }, + files: routeApp( + `${head}function Page() { + return

    page

    +} +export const Route = createFileRoute('/')({ component: Page }) +`, + { env: 'client' }, + ), + }) + return app.where(/import\.meta\.(?:webpackHot|hot)\b|\bmodule\.hot\b/) + } + + // No Vite rows: Vite replaces `import.meta.hot` with `undefined` in a + // build and drops the code it guards, so they could not fail. + + // Control for the pin below: with `NODE_ENV=production` exported. + test.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no route HMR code (%s)', + async (bundler) => { + expect(await hmrPlacement(bundler, false)).toBe('nowhere') + }, + 60_000, + ) + + // Source: gap P1 / DV51. (No esbuild row: the esbuild plugin neither + // generates nor splits routes, pinned in build-harness.test.ts, so it + // could not fail.) + test.each(['rspack', 'webpack'] as const)( + 'a production client bundle with automatic code splitting has no route HMR code (%s)', + async (bundler) => { + expect(await hmrPlacement(bundler, true)).toBe('nowhere') + }, + 60_000, + ) + + // Bug: with automatic code splitting off, the composed router plugin adds + // the route HMR plugin unless `NODE_ENV` is `production` when the plugin is + // created (`router-composed-plugin.ts`), instead of following the + // bundler's mode. webpack and Rspack set `NODE_ENV` only inside the bundle. + // Source: gap CC-P1 / routes.md R7. + // Impact: webpack/Rspack production bundles built from a shell without + // `NODE_ENV` ship the route HMR code of every route. + test.fails.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no route HMR code when NODE_ENV is unset (%s)', + async (bundler) => { + expect(await hmrPlacement(bundler, false, null)).toBe('nowhere') + }, + 60_000, + ) +}) diff --git a/packages/router-plugin/tests/integration/plugin-options.test.ts b/packages/router-plugin/tests/integration/plugin-options.test.ts new file mode 100644 index 00000000000..6681431b96f --- /dev/null +++ b/packages/router-plugin/tests/integration/plugin-options.test.ts @@ -0,0 +1,267 @@ +/** + * Router plugin options and entry points, through real builds: what each + * option changes in the output or on disk. + */ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, test } from 'vitest' +import { build, routeApp, routeHead } from '../build-harness' +import type { + BuildResult, + Bundler, +} from '@tanstack/compiler-test-utils/build-harness' +import type { RouterPluginOptions } from '../build-harness' + +const timeout = 120_000 + +const bundlers = ['vite', 'rspack', 'webpack'] as const + +/** A route whose component is split by default. */ +const route = (routePath = '/') => `${routeHead()}function Page() { + return

    MARK_COMPONENT

    +} +export const Route = createFileRoute('${routePath}')({ + staticData: { label: 'MARK_ROUTE' }, + component: Page, +}) +` + +/** Builds the route for the client and renders its component. */ +async function buildRoute( + bundler: Bundler, + pluginOptions: RouterPluginOptions, + options: { + files?: Record + routes?: (config: RouterPluginOptions) => Array + } = {}, +) { + const app = await build({ + bundler, + env: 'client', + pluginOptions, + routerPlugins: options.routes, + files: routeApp(options.files?.['routes/index.tsx'] ?? route(), { + env: 'client', + files: options.files, + }), + }) + const run = await app.run(`await entry.mount(entry.Route.options.component)`) + return { app, html: run.html } +} + +const read = (app: BuildResult, file: string) => + readFile(path.join(app.root, file), 'utf8').catch(() => null) + +describe.each([false, undefined])( + 'autoCodeSplitting: %s', + (autoCodeSplitting) => { + test.each(bundlers)( + 'splits nothing and still generates the route tree (%s)', + async (bundler) => { + const { app, html } = await buildRoute(bundler, { autoCodeSplitting }) + expect(app.where('MARK_COMPONENT')).toBe('eager') + expect(app.lazy).toEqual([]) + expect(html).toContain('MARK_COMPONENT') + expect(await read(app, 'routeTree.gen.ts')).toContain( + "from './routes/index'", + ) + }, + timeout, + ) + }, +) + +describe('plugin.vite.environmentName', () => { + test.each([ + ['client', 'lazy'], + ['ssr', 'eager'], + ] as const)( + 'with %s code-splits only that environment (client build: %s)', + async (environmentName, placement) => { + const { app, html } = await buildRoute('vite', { + plugin: { vite: { environmentName } }, + }) + expect(app.where('MARK_COMPONENT')).toBe(placement) + expect(html).toContain('MARK_COMPONENT') + }, + timeout, + ) +}) + +// The webpack and Rspack entries use their bundler's HMR API whatever +// `plugin.hmr.style` says (src/rspack.ts, src/webpack.ts). +describe.each(['rspack', 'webpack'] as const)('%s entry', (bundler) => { + /** + * Development client build with the bundler's HMR plugin, `addHmr`, and + * `plugin.hmr.style: 'vite'`. + */ + async function hotDevelopmentBuild( + addHmr: boolean | undefined, + routes?: (config: RouterPluginOptions) => Array, + ) { + return build({ + bundler, + env: 'client', + mode: 'development', + pluginOptions: { + plugin: { hmr: { style: 'vite' } }, + codeSplittingOptions: { addHmr }, + }, + routerPlugins: routes, + extraPlugins: () => [ + { + apply(compiler: any) { + new compiler.webpack.HotModuleReplacementPlugin().apply(compiler) + }, + }, + ], + files: routeApp(route(), { env: 'client' }), + }) + } + + /** + * The plugins the entry is made of, for this bundler, without the entry's + * own handling of the options. + */ + async function unforcedPlugins() { + const { createRspackPlugin, createWebpackPlugin } = await import('unplugin') + const { createRouterGeneratorPlugin } = + await import('../../src/core/router-generator-plugin') + const { createRouterCodeSplitterPlugin } = + await import('../../src/core/router-code-splitter-plugin') + const { createRouterPluginContext } = + await import('../../src/core/router-plugin-context') + const create: ( + factory: (options: any) => any, + ) => (options: any) => unknown = + bundler === 'rspack' ? createRspackPlugin : createWebpackPlugin + return (config: RouterPluginOptions) => { + const context = createRouterPluginContext() + return [ + create((options) => createRouterGeneratorPlugin(options, context))( + config, + ), + create((options) => createRouterCodeSplitterPlugin(options, context))( + config, + ), + ] + } + } + + /** + * Whether the route modules accept hot updates through the hot module API + * of webpack and Rspack (`import.meta.webpackHot` / `module.hot`), and + * whether they use Vite's (`import.meta.hot`, undefined in these bundles). + */ + const hmrApi = (app: BuildResult) => ({ + accepts: app.where(/\bhot\.accept\(/) !== 'nowhere', + vite: app.where(/\bimport\.meta\.hot\b/) !== 'nowhere', + }) + + test( + 'makes routes accept hot updates through the bundler even when plugin.hmr.style is vite', + async () => { + const [entry, withoutHmr, unforced] = await Promise.all([ + hotDevelopmentBuild(undefined), + hotDevelopmentBuild(false), + hotDevelopmentBuild(undefined, await unforcedPlugins()), + ]) + // Controls: no route HMR code; Vite's HMR API when the style is not + // forced + expect(hmrApi(withoutHmr)).toEqual({ accepts: false, vite: false }) + expect(hmrApi(unforced).vite).toBe(true) + expect(hmrApi(entry)).toEqual({ accepts: true, vite: false }) + }, + timeout, + ) +}) + +describe('separate generator and code splitter entries', () => { + const entries: Record< + (typeof bundlers)[number], + () => Promise<(config: RouterPluginOptions) => Array> + > = { + vite: async () => { + const vite = await import('../../src/vite') + return (config) => [ + vite.tanstackRouterGenerator(config), + vite.tanStackRouterCodeSplitter(config), + ] + }, + rspack: async () => { + const rspack = await import('../../src/rspack') + return (config) => [ + rspack.TanStackRouterGeneratorRspack(config), + rspack.TanStackRouterCodeSplitterRspack(config), + ] + }, + webpack: async () => { + const webpack = await import('../../src/webpack') + return (config) => [ + webpack.TanStackRouterGeneratorWebpack(config), + webpack.TanStackRouterCodeSplitterWebpack(config), + ] + }, + } + + test.each(bundlers)( + 'generate the route tree and split like the combined plugin (%s)', + async (bundler) => { + const { app, html } = await buildRoute( + bundler, + {}, + { routes: await entries[bundler]() }, + ) + expect(app.where('MARK_COMPONENT')).toBe('lazy') + expect(app.where('MARK_ROUTE')).toBe('eager') + expect(html).toContain('MARK_COMPONENT') + expect(await read(app, 'routeTree.gen.ts')).toContain( + "from './routes/index'", + ) + }, + timeout, + ) +}) + +// The package root exports the generator's unplugin factory, for bundler +// adapters of its own. +// Source: r5-gaps N12 (rp:1935: the factory returned no plugin). +test( + 'the unplugin factory of the generator from the package root generates the route tree (vite)', + async () => { + const { createUnplugin } = await import('unplugin') + const { unpluginRouterGeneratorFactory } = await import('../../src') + const { app, html } = await buildRoute( + 'vite', + {}, + { + routes: (config) => [ + createUnplugin(unpluginRouterGeneratorFactory).vite(config), + ], + }, + ) + expect(html).toContain('MARK_COMPONENT') + expect(await read(app, 'routeTree.gen.ts')).toContain( + "from './routes/index'", + ) + }, + timeout, +) + +test( + 'the deprecated TanStackRouterVite generates the route tree and splits like tanstackRouter', + async () => { + const { TanStackRouterVite } = await import('../../src/vite') + const { app, html } = await buildRoute( + 'vite', + {}, + { routes: (config) => [TanStackRouterVite(config)] }, + ) + expect(app.where('MARK_COMPONENT')).toBe('lazy') + expect(html).toContain('MARK_COMPONENT') + expect(await read(app, 'routeTree.gen.ts')).toContain( + "from './routes/index'", + ) + }, + timeout, +) diff --git a/packages/router-plugin/tests/integration/regression-helpers.test.ts b/packages/router-plugin/tests/integration/regression-helpers.test.ts new file mode 100644 index 00000000000..79a7f708f60 --- /dev/null +++ b/packages/router-plugin/tests/integration/regression-helpers.test.ts @@ -0,0 +1,106 @@ +/** + * Controls for the regression harness itself: module discovery, lazy + * evaluation and output validation (module linking and module validation: + * `@tanstack/compiler-test-utils`), and the route HMR page + * (`route-hmr-page.ts`). + */ +import { describe, expect, it } from 'vitest' +import { buildAndRun, head } from '../regression-helpers' +import { openRouteHmrPage } from '../route-hmr-page' + +describe('buildAndRun', () => { + // A real app's package.json has no side-effect hint unless the app adds + // one: a module the route file imports for its side effect runs. + it.each([ + { sideEffects: undefined, ran: true }, + { sideEffects: false, ran: false }, + ])( + 'keeps a side-effect import unless the app declares "sideEffects": $sideEffects', + async ({ sideEffects, ran }) => { + expect( + await buildAndRun( + `${head}import '../effect' +export const Route = createFileRoute('/')({ component: () =>

    page

    })`, + `return globalThis.effectRan === true`, + { + sideEffects, + files: { 'effect.ts': `;(globalThis as any).effectRan = true\n` }, + }, + ), + ).toBe(ran) + }, + 30_000, + ) +}) + +// Controls of the route HMR page (`openRouteHmrPage`): it tells kept state +// from lost state, and a hot update from a full reload. A React app whose +// route component renders a counter, and whose entry renders a header +// imported from a module that no HMR boundary accepts. +describe.each(['vite', 'rsbuild'] as const)( + 'openRouteHmrPage (%s)', + (bundler) => { + const route = ( + version: string, + child = 'Counter', + ) => `import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +function ${child}() { + const [count, setCount] = useState(0) + return +} +function Page() { + return
    <${child} />
    +} +export const Route = createFileRoute('/')({ component: Page }) +` + const main = `import { createRoot } from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { header } from './header' +const router = createRouter({ routeTree }) +createRoot(document.getElementById('app')!).render( + <> +

    {header}

    + + , +) +` + + it('sees kept state, lost state and full reloads', async () => { + const app = await openRouteHmrPage({ + bundler, + files: { + 'main.tsx': main, + 'header.ts': `export const header = 'header v1'`, + 'routes/index.tsx': route('v1'), + }, + ready: 'Counter v1 count 0', + }) + await app.click('button') + await app.page.waitForText('Counter v1 count 1') + // An edit of the component's body: React Refresh keeps its state. + expect( + await app.edit( + { 'routes/index.tsx': route('v2') }, + 'Counter v2 count 1', + ), + ).toEqual({ reloaded: false }) + // A component the edit renames is another component for React Refresh: + // React mounts it anew. + expect( + await app.edit( + { 'routes/index.tsx': route('v3', 'Renamed') }, + 'Renamed v3 count 0', + ), + ).toEqual({ reloaded: false }) + // An edit of a module that no HMR boundary accepts reloads the page. + expect( + await app.edit( + { 'header.ts': `export const header = 'header v2'` }, + 'header v2', + ), + ).toEqual({ reloaded: true }) + }, 60_000) + }, +) diff --git a/packages/router-plugin/tests/route-hmr-browser.test.ts b/packages/router-plugin/tests/integration/route-hmr-browser.test.ts similarity index 99% rename from packages/router-plugin/tests/route-hmr-browser.test.ts rename to packages/router-plugin/tests/integration/route-hmr-browser.test.ts index e43f8189ff1..69c295e309e 100644 --- a/packages/router-plugin/tests/route-hmr-browser.test.ts +++ b/packages/router-plugin/tests/integration/route-hmr-browser.test.ts @@ -11,8 +11,8 @@ */ import { describe, it } from 'vitest' import type { TestContext } from 'vitest' -import { openRouteHmrPage } from './route-hmr-page' -import type { RouteHmrPage } from './route-hmr-page' +import { openRouteHmrPage } from '../route-hmr-page' +import type { RouteHmrPage } from '../route-hmr-page' const timeout = 90_000 diff --git a/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts b/packages/router-plugin/tests/integration/rsbuild-dev-server-generator.test.ts similarity index 98% rename from packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts rename to packages/router-plugin/tests/integration/rsbuild-dev-server-generator.test.ts index 6071b391efb..10c1071e9d9 100644 --- a/packages/router-plugin/tests/rsbuild-dev-server-generator.test.ts +++ b/packages/router-plugin/tests/integration/rsbuild-dev-server-generator.test.ts @@ -12,8 +12,8 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' 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' +import { routerRsbuildDevServer } from '../rsbuild-dev-server' +import type { RouterRsbuildDevServer } from '../rsbuild-dev-server' const timeout = 60_000 @@ -363,14 +363,15 @@ describe('route generator in webpack and Rspack watch mode', () => { if (bundler === 'webpack') { const { default: webpack } = await import('webpack') const { TanStackRouterGeneratorWebpack } = - await import('../src/webpack') + 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') + const { TanStackRouterGeneratorRspack } = + await import('../../src/rspack') compiler = rspack({ ...config, plugins: [TanStackRouterGeneratorRspack(pluginOptions)], 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 0986a1dc24f..591887d0c40 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -2,12 +2,10 @@ * Known code-splitter bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. */ -import { BuildError } from '@tanstack/compiler-test-utils/build-harness' + import { parseSync } from 'vite' -import { afterAll, describe, expect, onTestFinished, test, vi } from 'vitest' -import { build, buildWithRsbuild, routeApp } from './build-harness' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { - buildAndRun, compileRouteModules, declaredNames, eagerImportSources, @@ -23,19 +21,8 @@ import { routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' -import type { - BuildResult, - Bundler, -} from '@tanstack/compiler-test-utils/build-harness' -import type { CodeSplitGroupings } from '../src/core/constants' import type { TargetFramework } from './regression-helpers' -/** - * Bundlers whose router plugin splits routes (the esbuild adapter does not; - * pinned in build-harness.test.ts). - */ -const clientBundlers: Array = ['vite', 'rspack', 'webpack'] - /** * Whether a `console.warn` spy saw a warning that names the route file * `file` and the split component (`Page` or `component`): the user is told @@ -191,46 +178,6 @@ export const Route = createFileRoute('/')({ }, ) - // Bug: a `let` that code in another module than its declaration reassigns - // is imported there, read-only: a `let` the loader reassigns is shared, so - // the route module assigns to an import of the shared module; an exported - // `let` the split component reassigns stays in the route module, which the - // chunk imports it from ("Cannot assign to import"). - // Impact: the route cannot be built as soon as code splitting is on. - // Needs a real build: imports are live bindings, and the in-process harness - // snapshots them, so even a correct fix would read a stale value there. - test.fails.each([ - { - name: 'the loader reassigns', - route: `${head}let count = 0 -export const Route = createFileRoute('/')({ - loader: () => { count++; return count }, - component: () =>

    {count}

    , -})`, - script: `const loaded = [await entry.Route.options.loader({}), await entry.Route.options.loader({})] -return [...loaded, await entry.render(entry.Route.options.component)]`, - expected: [1, 2, '

    2

    '], - }, - { - name: 'the split component reassigns (exported)', - route: `${head}export let count = 0 -function Page() { - count++ - return

    {count}

    -} -export const Route = createFileRoute('/')({ component: Page })`, - script: `const component = entry.Route.options.component -return [await entry.render(component), await entry.render(component), entry.count]`, - expected: ['

    1

    ', '

    2

    ', 2], - }, - ])( - 'a let $name stays writable and the split component reads its value', - async ({ route, script, expected }) => { - expect(await buildAndRun(route, script)).toEqual(expected) - }, - 30_000, - ) - // Bug: a top-level statement that writes a binding stays in the reference // module when the binding moves to the shared module or a split chunk. // Impact: the write throws (an import is read-only, or the moved binding is @@ -856,65 +803,6 @@ ${code} }) describe('split route options', () => { - /** Loads `/` with a real router and returns each match's loader data. */ - const routerEntry = `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) -}` - - function loadWithSplitLoader(loader: string, declarations = '') { - return buildAndRun( - `${head}${declarations}export const Route = createFileRoute('/')({ - loader: ${loader}, - component: () =>

    index

    , -})`, - `return await entry.load()`, - { - files: { 'entry.ts': routerEntry }, - groupings: [['loader'], ['component']], - }, - ) - } - - // Control for the object-form loader pin below (same harness). - test('a split loader loads route data', async () => { - expect(await loadWithSplitLoader(`() => 'loaded'`)).toEqual([ - null, - 'loaded', - ]) - }, 30_000) - - // Bug: the object form `loader: { handler }` cannot be split: the virtual - // compiler throws "Unexpected splitNode type ☝️: ObjectExpression". - // Splitting only the handler, or keeping the object in the route module, - // are both valid fixes. - // Impact: a route with an object-form loader cannot be built once the - // loader is split. - test.fails.each([ - { name: 'an arrow handler', loader: `{ handler: () => 'loaded' }` }, - { name: 'a method handler', loader: `{ handler() { return 'loaded' } }` }, - { - name: 'a spread after the handler', - loader: `{ handler: () => 'loaded', ...defaults }`, - declarations: `const defaults = { staleReloadMode: 'blocking' as const }\n`, - }, - ])( - 'a split loader in object form with $name loads route data', - async ({ loader, declarations }) => { - expect(await loadWithSplitLoader(loader, declarations)).toEqual([ - null, - 'loaded', - ]) - }, - 30_000, - ) - // Bug: once the object form compiles (pins above), its handler must also // end up split: a loader configured to be split that stays in the route // module is our limitation, not a pattern the user can change (decision @@ -1060,23 +948,6 @@ export const Route = createFileRoute('/')({ }).toEqual(expected) }, ) - - // Bug: an object-form loader passed by reference (`loader` holding - // `{ handler }`) is split as if it were the loader function, so the route - // calls the object ("... is not a function"). - // Impact: the route's data never loads once the loader is split. - test.fails( - 'a split loader in object form passed by reference loads route data', - async () => { - expect( - await loadWithSplitLoader( - 'loader', - `const loader = { handler: () => 'loaded' }\n`, - ), - ).toEqual([null, 'loaded']) - }, - 30_000, - ) }) describe('route options written in other shapes', () => { @@ -1138,47 +1009,6 @@ export const Route = ${route} expect((await loaded.load('component'))()).toBe('

    page

    ') }) - // The unused `makeOptions` of the two rows above may name a `Page` its - // module no longer declares (user ruling 2026-10-17, widened: the bundlers - // delete it), as long as that dead code does not reach a production client - // bundle (which the build harness also checks for any dangling name). - test.each( - clientBundlers.flatMap((bundler) => - [ - `createFileRoute('/')({ ssr: false, component: Page } as any)`, - `(createFileRoute('/') as any)({ ssr: false, component: Page })`, - ].map((route) => ({ bundler, route })), - ), - )( - 'a route written as $route drops its unused options factory from a production client build ($bundler)', - async ({ bundler, route }) => { - const app = await build({ - bundler, - env: 'client', - files: routeApp( - `${head}function Page() { - return

    page

    -} -const makeOptions = () => ({ component: Page, title: 'MARK_MAKE_OPTIONS_END' }) -export const Route = ${route} -`, - { env: 'client' }, - ), - }) - const run = await app.run( - `await entry.mount(entry.Route.options.component)`, - ) - expect({ - makeOptions: app.where('MARK_MAKE_OPTIONS_END'), - html: run.html, - }).toEqual({ - makeOptions: 'nowhere', - html: '

    page

    ', - }) - }, - 60_000, - ) - // 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 ☝️: @@ -1682,42 +1512,6 @@ export const Route = createFileRoute('/')({ expect((await route.load('component'))()).toBe('
    page
    ') }, ) - - // Bug: once the pin above is fixed, the split option must also end up - // split, or the build must say why (decision D11). Splitting the result of - // a call would change when the call runs, so the user has to change the - // pattern: a warning naming the file and the option is acceptable. Today - // the build fails first (same root cause as the pin above). - // Impact: the component loads with the entry chunk. - test.fails( - 'a component declared by an array destructuring of a call shared with the errorComponent is split or reported', - async () => { - const app = await build({ - bundler: 'vite', - env: 'client', - files: routeApp( - `${head}const createPair = () => [() =>
    {'MARK_PAGE'}
    , () =>

    error

    ] -const [Page, ErrorView] = createPair() -export const Route = createFileRoute('/')({ - component: Page, - errorComponent: ErrorView, -}) -`, - { env: 'client' }, - ), - }) - const reported = [...app.warnings, ...app.consoleMessages].some( - (message) => - message.includes('routes/index.tsx') && - /\b(?:component|Page)\b/.test(message), - ) - expect({ - lazy: app.where('MARK_PAGE') === 'lazy', - reported, - }).not.toEqual({ lazy: false, reported: false }) - }, - 60_000, - ) }) describe('names the code splitter generates', () => { @@ -2432,137 +2226,6 @@ export const Route = createFileRoute('/')({ }, ) - /** - * An entry that imports `Route` and, like another module of the app, - * `imports` from the route file, then runs `setup` before any split chunk - * loads. - */ - const entryImporting = ( - imports: string, - setup: string, - ) => `import { createElement } from 'react' -import { renderToString } from 'react-dom/server' -import { Route, ${imports} } from './routes/index' -export { Route } -${setup} -export async function render(component: any) { - await component.preload?.() - return renderToString(createElement(component)) -} -` - - // Bug: a split chunk declares its own copy of the exported functions and - // classes of the route file it reads (and of the private state they read), - // so another module of the app that imports them gets other instances than - // the split component. Build-level form of "module state that the - // reference module keeps for a binding read by an exported function". - // Source: #8504 (code-splitter-exported-bindings-identity.test.ts), where - // they pass. - // Impact: `instanceof` checks, registries and counters see two copies - // once code splitting is on; module state is created twice. - test.fails.each([ - { - // An instance another module creates is an instance of the class the - // split component reads - name: 'class (instanceof)', - route: `export class Store {} -function Page() { - return

    {String((globalThis as any).__store instanceof Store)}

    -}`, - imports: 'Store', - setup: ';(globalThis as any).__store = new Store()', - expected: ['

    true

    '], - }, - { - // One state: the entry's call is the first, the split component's the - // second - name: 'function over module state', - route: `let count = 0 -export function next() { - return ++count -} -function Page() { - return

    {next()}

    -}`, - imports: 'next', - setup: 'next()', - expected: ['

    2

    '], - }, - { - // Created once and kept. The module that holds the state then has a - // top-level call, so the bundler has to keep it where it is imported - name: 'function over created state', - route: `import { init } from '../state' -const state = init({ count: 0 }) -export function increment() { - return ++state.count -} -function Page() { - return

    {increment()}

    -}`, - imports: 'increment', - setup: 'increment()', - expected: ['

    2

    ', 1], - }, - { - name: 'function component', - route: `export function Card() { - return card -} -function Page() { - return

    {String((globalThis as any).__Card === Card)}

    -}`, - imports: 'Card', - setup: ';(globalThis as any).__Card = Card', - expected: ['

    true

    '], - }, - { - name: 'component and its private state', - route: `const registry = new Set() -function size() { - return registry.size -} -export function Card({ id }: { id: string }) { - registry.add(id) - return {size()} -} -function Page() { - return ( -

    - {String((globalThis as any).__Card === Card)} - -

    - ) -}`, - imports: 'Card', - setup: ";(globalThis as any).__Card = Card\nCard({ id: 'entry' })", - expected: ['

    true2

    '], - }, - ])( - 'the split component and other modules share an exported $name', - async ({ route, imports, setup, expected }) => { - const result = await buildAndRun( - `${head}${route} -export const Route = createFileRoute('/')({ component: Page }) -`, - `const html = await entry.render(entry.Route.options.component) -return globalThis.__inits === undefined ? [html] : [html, globalThis.__inits]`, - { - files: { - 'entry.ts': entryImporting(imports, setup), - 'state.ts': `export function init(value: T) { - ;(globalThis as any).__inits = ((globalThis as any).__inits ?? 0) + 1 - return value -} -`, - }, - }, - ) - expect(result).toEqual(expected) - }, - 30_000, - ) - /** * Loads a route whose errorComponent option is the exported * `PostErrorComponent`, written as `value`, next to another module of the @@ -2662,598 +2325,6 @@ export const imported = PostErrorComponent`, }) }, ) - - // Bug: same as above, in a real client build whose entry imports the - // exported component (`export * from './routes/index'`): the component is - // in both the entry chunk and the split chunk. One instance (split, or - // unsplit with a warning naming the route file, decision D11) is the goal. - // Impact: the component's code ships twice and runs as two components. - test.fails.each(clientBundlers)( - 'an exported errorComponent written as `PostErrorComponent as any` is in the client once (%s build)', - async (bundler) => { - const app = await build({ - bundler, - env: 'client', - files: routeApp( - `${head}export const Route = createFileRoute('/posts/$postId')({ - errorComponent: PostErrorComponent as any, - component: () =>

    post

    , -}) -export function PostErrorComponent() { - return

    {'MARK_POST_ERROR_END'}

    -} -`, - { env: 'client' }, - ), - }) - const { result: lazy } = await app.run( - `return typeof entry.Route.options.errorComponent.preload === 'function'`, - ) - const reported = [...app.warnings, ...app.consoleMessages].some( - (message) => - message.includes('routes/index.tsx') && - /\b(?:PostErrorComponent|errorComponent)\b/.test(message), - ) - const placement = app.where('MARK_POST_ERROR_END') - expect(['lazy', 'eager']).toContain(placement) - expect({ splitOrReported: lazy === true || reported }).toEqual({ - splitOrReported: true, - }) - }, - 60_000, - ) -}) - -/** - * Where a route's code ends up in a client build, which the bundler decides - * from what the code splitter emits, with or without side-effect hints. One - * app per bundler and hint holds a route file per case, each marking its - * page code with a marker; its entry imports the generated route tree, which - * imports only `Route` from each route file, like an app. - */ -describe('page code in client builds', () => { - type Case = { - name: string - /** Route file name in `routes/`; its path is `/`. */ - file: string - /** The route file after the `createFileRoute` import, with `$PATH`. */ - route: string - marker: string - } - - const page = (file: string, body: string): Case['route'] => - `${head}${body}`.replaceAll('$PATH', `/${file}`) - - const marker = (file: string) => `MARK_${file.replaceAll('-', '_')}_END` - - const routeCase = (name: string, file: string, body: string): Case => ({ - name, - file, - route: page(file, body.replaceAll('$MARK', marker(file))), - marker: marker(file), - }) - - const component = `export const Route = createFileRoute('$PATH')({ component: Page })` - - const displayName = routeCase( - 'a component the route file writes a property of', - 'display-name', - `function Page() { - return
    {'$MARK'}{Page.displayName}
    -} -Page.displayName = 'PageDisplay' -${component}`, - ) - - // Page code that loads with its chunk: code the route file does not - // export, and exported functions and classes (today the chunk declares its - // own copy, whose identity and state are pinned in "module state shared - // with split chunks" and "exported bindings", and the bundler drops the - // unused export of the route module). A fix must keep them out of the - // entry chunk. - const controls: Array = [ - routeCase( - 'a const only a split component renders', - 'local-const', - `const Card = () => {'$MARK'} -function Page() { - return -} -${component}`, - ), - routeCase( - 'an exported function component', - 'exported-function', - `export function Card() { - return {'$MARK'} -} -function Page() { - return -} -${component}`, - ), - routeCase( - 'a function component exported by specifier', - 'exported-specifier', - `function Card() { - return {'$MARK'} -} -export { Card } -function Page() { - return -} -${component}`, - ), - routeCase( - 'a default-exported function component', - 'exported-default', - `export default function Card() { - return {'$MARK'} -} -function Page() { - return -} -${component}`, - ), - routeCase( - 'an exported helper function', - 'exported-helper', - `export function format(value: number) { - return '$MARK' + value -} -function Page() { - return

    {format(1)}

    -} -${component}`, - ), - routeCase( - 'an exported class', - 'exported-class', - `export class Store { - value = '$MARK' -} -function Page() { - return

    {new Store().value}

    -} -${component}`, - ), - routeCase( - 'an exported function over module state', - 'exported-state', - `let count = 0 -export function next() { - count++ - return '$MARK' + count -} -function Page() { - return

    {next()}

    -} -${component}`, - ), - // A dependency with side effects next to the exported component: only - // the declaration is asserted, how the bundler groups the modules next - // to it is up to it. (A client build bundles `react/jsx-runtime`, a - // CommonJS module, whose interop call has side effects too.) - // Source: #8504 (code-splitter-exported-bindings-chunks.test.ts). - routeCase( - 'an exported function component with a dependency that has side effects', - 'exported-effect-dep', - `import { label } from '../label' -export function Card() { - return {'$MARK' + label} -} -function Page() { - return -} -${component}`, - ), - // What an exported function component imports loads with the chunk too. - // Source: #8504 (code-splitter-exported-bindings-chunks.test.ts). - { - ...routeCase( - "an exported function component's dependency", - 'exported-dep', - `import { heavy } from '../heavy' -export function Card() { - return {heavy()} -} -function Page() { - return -} -${component}`, - ), - marker: 'MARK_heavy_dependency_END', - }, - // Whether it renders is pinned at the compiler level ("top-level writes - // to the split component (property assignment) stay with the binding - // they write"); a fix must keep the component in its chunk. - // Source: the #8504 confidence campaign (exported-bindings fixture L, - // where a candidate fix moved the component to the entry). - routeCase( - 'an exported function component next to a helper the loader and the component share', - 'shared-helper-export', - `const label = (value: string) => value + '!' -export function Card() { - return {'$MARK'} -} -export const Route = createFileRoute('$PATH')({ - loader: () => label('data'), - component: () =>

    {label('page')}

    , -})`, - ), - ] - - // Bug: page code the route file exports as a variable, and an exported - // split component, stay in the route module, which the entry chunk loads, - // with everything they import: a split chunk imports exported variables - // from the route module instead of owning them, and an exported component - // is not split (the code splitter warns). Bundlers can only move the code - // out with the export unused, which the generated route tree (importing - // `Route` only) allows; whether our compiler or the bundler's - // tree-shaking does it is free, with or without `"sideEffects": false` or - // a `/* @__PURE__ */` initialiser. - // Sources: gaps S14, Z4, H1, H2; #8504's exported-bindings chunks tests. - // Impact: page code exported for tests or reuse loads with the entry chunk. - const exported: Array = [ - routeCase( - 'an exported const component only the split component renders', - 'exported-const', - `export const Card = () => {'$MARK'} -function Page() { - return -} -${component}`, - ), - routeCase( - 'an exported const with a pure initialiser', - 'exported-pure', - `function makeLabel(value: string) { - return { text: value } -} -export const label = /* @__PURE__ */ makeLabel('$MARK') -function Page() { - return

    {label.text}

    -} -${component}`, - ), - routeCase( - 'an exported function used as the component', - 'exported-component', - `export function Page() { - return

    {'$MARK'}

    -} -${component}`, - ), - routeCase( - 'a default-exported function used as the component', - 'exported-default-component', - `export default function Page() { - return

    {'$MARK'}

    -} -${component}`, - ), - // Source: the #8504 confidence campaign (exported-bindings S5, G1 and - // fixture C). - routeCase( - 'an exported const used as the component', - 'exported-const-component', - `export const Page = () =>

    {'$MARK'}

    -${component}`, - ), - routeCase( - "an exported component another route's split component renders", - 'exported-shared', - `export function Card() { - return {'$MARK'} -} -function Page() { - return -} -${component}`, - ), - ] - - // Bug: an exported component that two split options render goes to a - // module both chunks import, which the route module re-exports, so it - // loads with the entry chunk unless the app declares `"sideEffects": - // false` (then the bundler drops the unused re-export). - // Source: the #8504 confidence campaign (exported-bindings G1). - const exportedSharedByOptions = routeCase( - 'an exported component two split options render', - 'exported-two-options', - `export function Card() { - return {'$MARK'} -} -export const Route = createFileRoute('$PATH')({ - component: () =>

    , - errorComponent: () =>

    , -})`, - ) - - // Goals for split options the code splitter leaves in the route module. - const unsplit: Array = [ - // Bug: a route option written as a method (`component() {}`) is not - // split (only property values are). Source: gap S11, ruling on method - // shorthand. - // Impact: the component loads with the entry chunk. - routeCase( - 'a component written as a method', - 'method-component', - `export const Route = createFileRoute('$PATH')({ - component() { - return

    {'$MARK'}

    - }, -})`, - ), - ] - - // Bug: the root route's options are never split - // (`computeSharedBindings` and the reference compiler skip - // `createRootRoute`), so its errorComponent and notFoundComponent, which - // only render on errors, load with the entry chunk. Its `component` - // renders on every page and may stay. Source: gap S20, decision D12. - const rootRoute = `import { createRootRoute, Outlet } from '@tanstack/react-router' -export const Route = createRootRoute({ - component: () => , - errorComponent: () =>

    {'MARK_root_error_END'}

    , - notFoundComponent: () =>

    {'MARK_root_not_found_END'}

    , -}) -` - const rootMarkers = { - errorComponent: 'MARK_root_error_END', - notFoundComponent: 'MARK_root_not_found_END', - } - - // Bug: route options destructured from a call result - // (`const { component, loader } = createBits()`) are neither split nor - // reported: the destructuring moves to a module the route module imports - // statically, so the component's code loads with the entry, silently. - // Splitting them would change when the call runs, so the user has to - // change the pattern: a warning naming the file and the option is the - // expected outcome (decision D11). - const destructured = routeCase( - 'route options destructured from a call result', - 'destructured-options', - `function DefaultComponent() { - return
    Default
    -} -const createBits = () => ({ component: ActualComponent, loader: () => 'data' }) -const { component: MyComponent = DefaultComponent, loader } = createBits() -function ActualComponent() { - return
    {'$MARK'}
    -} -export const Route = createFileRoute('$PATH')({ component: MyComponent, loader })`, - ) - - // Bug: a split chunk keeps the route file's exported declarations that - // its option does not read (decision D5; compiler-level pin in "what - // split chunks hold"), and bundlers keep them. - const unreadExport = routeCase( - 'an exported helper the split option does not read', - 'unread-export', - `export function helper() { - return '$MARK' -} -function Page() { - return

    page

    -} -${component}`, - ) - - const cases = [ - ...controls, - ...exported, - ...unsplit, - exportedSharedByOptions, - destructured, - unreadExport, - ] - /** - * `mount(path)` mounts the component of the route at `path` into #root; - * `renderEach(paths)` mounts each into its own element and returns what - * each renders (or the error it throws), by path. - */ - const mountEntry = `import { createElement } from 'react' -import { flushSync } from 'react-dom' -import { createRoot } from 'react-dom/client' -import { routeTree } from './routeTree.gen' -export { routeTree } -async function mountInto(path: string, element: HTMLElement) { - const route = (routeTree.children as Array).find( - (child) => child.options.path === path, - ) - await route.options.component.preload?.() - const root = createRoot(element) - flushSync(() => root.render(createElement(route.options.component))) -} -/** Mounts the component of the route at \`path\` into #root. */ -export async function mount(path: string) { - await mountInto(path, document.getElementById('root')!) -} -export async function renderEach(paths: Array) { - const rendered: Record = {} - for (const path of paths) { - const element = document.createElement('div') - document.body.append(element) - try { - await mountInto(path, element) - rendered[path] = element.innerHTML - } catch (error) { - rendered[path] = 'threw: ' + String(error) - } - } - return rendered -} -` - const files = { - 'label.ts': `;(globalThis as any).labelLoaded = true -export const label = 'label' -`, - 'heavy.ts': `export function heavy() { - return 'MARK_heavy_dependency_END' -} -`, - // Renders the exported component of `exported-shared` in its split - // component. - 'routes/imports-exported.tsx': `${head}import { Card } from './exported-shared' -function Page() { - return -} -export const Route = createFileRoute('/imports-exported')({ component: Page }) -`, - 'routes/__root.tsx': rootRoute, - ...Object.fromEntries( - cases.map((row) => [`routes/${row.file}.tsx`, row.route]), - ), - 'entry.ts': mountEntry, - } - - /** - * A production client build of `files` with `bundler`, or with Rsbuild - * and its production defaults (`rsbuild`). - */ - const clientBuild = ( - bundler: Bundler | 'rsbuild', - options: { - sideEffects: boolean | undefined - files: Record - registerCleanup?: () => void - }, - ) => - bundler === 'rsbuild' - ? buildWithRsbuild(options) - : build({ bundler, env: 'client', ...options }) - - describe.each([...clientBundlers, 'rsbuild'] as const)('%s', (bundler) => { - describe.each([ - { hint: 'without a sideEffects field', sideEffects: undefined }, - { hint: 'with "sideEffects": false', sideEffects: false }, - ])('$hint', ({ sideEffects }) => { - let built: Promise | undefined - const app = () => - (built ??= clientBuild(bundler, { - sideEffects, - files, - registerCleanup: () => {}, - })) - afterAll(async () => { - await (await built)?.dispose() - }) - const timeout = 120_000 - let renders: Promise> | undefined - /** What the route of each case renders when mounted, by file. */ - const rendered = async (file: string) => { - renders ??= app().then(async (result) => { - const paths = cases.map((row) => `/${row.file}`) - const run = await result.run( - `return await entry.renderEach(${JSON.stringify(paths)})`, - ) - return run.result as Record - }) - return (await renders)[`/${file}`] - } - /** - * Where the case's marked page code is in the build, and whether its - * route renders it: the page code must load with its chunk and still - * work. - */ - const outcome = async ({ file, marker: mark }: Case) => ({ - placement: (await app()).where(mark), - rendersPage: (await rendered(file))?.includes(mark), - }) - const lazyAndRendered = { placement: 'lazy', rendersPage: true } - - test.each(controls)( - '$name loads with its chunk', - async (row) => { - expect(await outcome(row)).toEqual(lazyAndRendered) - }, - timeout, - ) - - // Bug: the code splitter moves a component into its chunk but leaves - // a top-level write to it (`Page.displayName = ...`) in the route - // module, where `Page` is no longer declared (same bug as "top-level - // writes to the split component (property assignment) stay with the - // binding they write"). Keeping a copy of the component and its write - // in the route module as well is no fix: the page code then loads - // with the entry chunk. Its own app: the build harness rejects the - // production client output, whose entry chunk reads the undeclared - // `Page`. - // Source: the #8504 probe (memos/splitter.md B6). - // Impact: the app's entry throws when it loads ("Page is not - // defined"). - test.fails( - `${displayName.name} loads with its chunk and renders the property`, - async () => { - const built = await clientBuild(bundler, { - sideEffects, - files: { - 'routes/__root.tsx': rootRoute, - [`routes/${displayName.file}.tsx`]: displayName.route, - 'entry.ts': mountEntry, - }, - }) - const { html } = await built.run(`await entry.mount('/display-name')`) - expect({ placement: built.where(displayName.marker), html }).toEqual({ - placement: 'lazy', - html: `
    ${displayName.marker}PageDisplay
    `, - }) - }, - timeout, - ) - - test.fails.each([...exported, ...unsplit])( - '$name loads with its chunk', - async (row) => { - expect(await outcome(row)).toEqual(lazyAndRendered) - }, - timeout, - ) - ;(sideEffects === false ? test : test.fails)( - `${exportedSharedByOptions.name} loads with its chunk`, - async () => { - expect(await outcome(exportedSharedByOptions)).toEqual( - lazyAndRendered, - ) - }, - timeout, - ) - - test.fails.each(['errorComponent', 'notFoundComponent'] as const)( - "a root route's %s loads lazily", - async (option) => { - expect((await app()).where(rootMarkers[option])).toBe('lazy') - }, - timeout, - ) - - test.fails( - `${destructured.name} load with their chunk or are reported`, - async () => { - const built = await app() - const reported = [...built.warnings, ...built.consoleMessages].some( - (message) => - message.includes(`routes/${destructured.file}.tsx`) && - /\b(?:component|MyComponent)\b/.test(message), - ) - expect({ - lazy: built.where(destructured.marker) === 'lazy', - reported, - }).not.toEqual({ lazy: false, reported: false }) - }, - timeout, - ) - - test.fails( - `${unreadExport.name} is not in its chunk`, - async () => { - expect((await app()).locate(unreadExport.marker).lazy).toEqual([]) - }, - timeout, - ) - }) - }) }) describe('TypeScript-only syntax', () => { @@ -3505,68 +2576,6 @@ export function helper(value: string | number) { return value }` ) }) -describe('app paths', () => { - const route = `${head}export const Route = createFileRoute('/')({ - component: () =>

    index

    , -})` - const script = `return await entry.render(entry.Route.options.component)` - - // Control for the `#` pin below (same app, without `#`). - test('an app builds and preloads a split component', async () => { - expect(await buildAndRun(route, script)).toBe('

    index

    ') - }, 30_000) - - // Bug: the route module imports its chunks by absolute file path with the - // split query (`/routes/index.tsx?tsr-split=component`), and a `#` - // in the path is read as the start of a URL fragment. - // Impact: an app in a directory whose name contains `#` cannot be built - // with code splitting ([UNRESOLVED_IMPORT]). - test.fails( - 'an app in a directory with # in its name builds and preloads a split component', - async () => { - expect( - await buildAndRun(route, script, { prefix: '.regression-build-#' }), - ).toBe('

    index

    ') - }, - 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 the modules of - // an app in a directory named like `tsr-split-demo`. - // Source: round-3 mutation testing (splitter-hosts P3). - // Impact: an app in such a directory cannot be built. - test.fails( - 'an app in a directory whose name contains tsr-split builds and preloads a split component', - async () => { - expect( - await buildAndRun(route, script, { - prefix: '.regression-build-tsr-split-', - }), - ).toBe('

    index

    ') - }, - 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. - // Source: #7754. - // Impact: an app in a directory whose name contains `'` (e.g. a user - // folder) cannot be built or served once code splitting is on: every route - // fails with a syntax error. - test.fails( - "an app in a directory with ' in its name builds and preloads a split component", - async () => { - expect( - await buildAndRun(route, script, { prefix: ".regression-build-it's-" }), - ).toBe('

    index

    ') - }, - 30_000, - ) -}) - describe('code that reads a split binding', () => { /** * Loads a route whose component is `Page`, declared next to `exports`, in @@ -3947,91 +2956,6 @@ export const Route = createFileRoute('/')({ ]).toEqual(['
    spin
    ', 'spin']) }, ) - - // Bug: same root cause as above, in a real build. - // Impact: the server render of the route throws "PostsPage is not defined" - // when it runs head(). - test.fails.each(['vite', 'rspack'] as const)( - 'an imported split component that head() reads renders, and head() reads it (%s build)', - async (bundler) => { - const app = await build({ - bundler, - env: 'ssr', - minify: false, - files: routeApp( - `${head}import { PostsPage } from '../posts-page' -export const Route = createFileRoute('/')({ - component: PostsPage, - head: () => ({ meta: [{ title: PostsPage.title }] }), -}) -`, - { - files: { - 'posts-page.tsx': `export function PostsPage() { - return

    posts

    -} -PostsPage.title = 'Posts' -`, - }, - }, - ), - }) - const { result } = await app.run( - `return [entry.Route.options.head().meta[0].title, await entry.render(entry.Route.options.component)]`, - ) - expect(result).toEqual(['Posts', '

    posts

    ']) - }, - 60_000, - ) - /** - * Builds a route whose split component `Page` an unused module-level - * object also names, and mounts the component in the client bundle. - */ - async function mountUnusedReader(bundler: Bundler) { - const app = await build({ - bundler, - env: 'client', - files: routeApp( - `${head}function Page() { - return

    MARK_PAGE

    -} -const opts = { component: Page } -export const Route = createFileRoute('/')({ component: Page }) -`, - { env: 'client' }, - ), - }) - const run = await app.run( - `await entry.mount(entry.Route.options.component)`, - ) - return run.html - } - - // Control for the pin below: Rspack and webpack drop the unused object - // while minifying, together with its read of the deleted binding. - test.each(['rspack', 'webpack'] as const)( - 'a split component that an unused object names renders (%s)', - async (bundler) => { - expect(await mountUnusedReader(bundler)).toContain('

    MARK_PAGE

    ') - }, - 60_000, - ) - - // Bug: same root cause as above, for an unused module-level object that - // names the split component: the route module keeps the object but loses - // `Page`. Rolldown keeps the object, as reading an undeclared binding can - // throw. (The build harness now rejects that output before it runs: its - // entry chunk reads the undeclared `Page`.) - // Source: Wave B (a Vite Start client bundle crashed on it). - // Impact: the client bundle throws when the route module loads ("Page is - // not defined"), so the app never renders. - test.fails( - 'a split component that an unused object names renders (vite)', - async () => { - expect(await mountUnusedReader('vite')).toContain('

    MARK_PAGE

    ') - }, - 60_000, - ) }) describe('class components', () => { @@ -4187,84 +3111,6 @@ export const Route = createFileRoute('/')({ component: Page }) ) }) -describe('`this` in split callbacks', () => { - /** Whether a message names the route file and `this`. */ - const reportsThis = (message: string) => - message.includes('routes/index.tsx') && /\bthis\b/.test(message) - - /** - * Builds an app whose `/` route has `loader`, with the given groupings, - * loads `/` with a real router and returns the route's loader data; or - * `reported` when the build fails or warns about `this`. - */ - async function loadWithLoader(loader: string, groupings: CodeSplitGroupings) { - let app - try { - app = await build({ - bundler: 'vite', - env: 'ssr', - minify: false, - files: routeApp( - `${head}export const Route = createFileRoute('/')({ - loader: ${loader}, - component: () =>

    index

    , -})`, - { - files: { - '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.at(-1)?.loaderData -}`, - }, - }, - ), - pluginOptions: { codeSplittingOptions: { defaultBehavior: groupings } }, - }) - } catch (error) { - if (error instanceof BuildError && reportsThis(error.message)) { - return 'reported' - } - throw error - } - if ([...app.warnings, ...app.consoleMessages].some(reportsThis)) { - return 'reported' - } - return (await app.run('return await entry.load()')).result - } - - const readsThis = `function () { - return this === undefined ? 'no this' : 'this: ' + Object.keys(this).join() -}` - - // Control for the pin below: the router calls an unsplit loader without a - // `this`. - test('an unsplit loader is called without a `this`', async () => { - expect(await loadWithLoader(readsThis, [['component']])).toBe('no this') - }, 60_000) - - // Bug: a split loader is called as a method of its chunk's module - // namespace (`lazyFn` calls `imported[key](...args)`), so its `this` is - // the chunk's exports, where the router calls an unsplit loader without a - // `this`. Source: #8688. - // Impact: a loader that reads `this` behaves differently once split, - // without any diagnostic. - test.fails( - 'a split loader sees the same `this` as an unsplit one, or the build reports it', - async () => { - expect(['no this', 'reported']).toContain( - await loadWithLoader(readsThis, [['loader'], ['component']]), - ) - }, - 60_000, - ) -}) - describe('server-only route options on the client', () => { /** Start's client configuration of the code splitter. */ const deleteNodes = ['ssr', 'server', 'headers'] @@ -4446,264 +3292,4 @@ export const Route = createFileRoute('/')( expect(result).toEqual(stripped) }, ) - - /** A client build with Start's `deleteNodes`, reading a server secret. */ - async function secretPlacement(bundler: Bundler, option: string) { - const app = await build({ - bundler, - env: 'client', - pluginOptions: { codeSplittingOptions: { deleteNodes } }, - files: routeApp( - `${head}import { readSecret } from '../db.server' -export const Route = createFileRoute('/')({ - ${option}, - component: () =>

    page

    , -}) -`, - { - env: 'client', - files: { - 'db.server.ts': `export function readSecret() { - return (globalThis as any).MARK_SERVER_SECRET -} -`, - }, - }, - ), - }) - return app.where('MARK_SERVER_SECRET') - } - - // Control for the build-level canary below. - test.each(clientBundlers)( - 'a client build has no server code of an `ssr` property (%s)', - async (bundler) => { - expect(await secretPlacement(bundler, 'ssr: () => !!readSecret()')).toBe( - 'nowhere', - ) - }, - 60_000, - ) - - // Bug: build-level canary of the method pin above (same root cause). - // Impact: the server code of an `ssr()` method reaches the client bundle. - test.fails.each(clientBundlers)( - 'a client build has no server code of an `ssr()` method (%s)', - async (bundler) => { - expect( - await secretPlacement(bundler, 'ssr() { return !!readSecret() }'), - ).toBe('nowhere') - }, - 60_000, - ) -}) - -describe('symlinked route files', () => { - /** - * A client build with `deleteNodes: ['server']` of a route file reached - * through `routes/index.tsx`, a symlink when `linked`; returns where the - * server handler's code is, or `refused` when the build fails naming the - * route file. - */ - async function serverCodePlacement(bundler: Bundler, linked: boolean) { - const route = `${head}import { readSecret } from '../db.server' -export const Route = createFileRoute('/')({ - server: { handlers: { GET: () => readSecret() } }, - component: () =>

    page

    , -}) -` - const files = routeApp(route, { - env: 'client', - files: { - 'db.server.ts': `export function readSecret() { - return (globalThis as any).MARK_SERVER_SECRET -} -`, - }, - }) - if (linked) { - delete files['routes/index.tsx'] - files['linked/index.tsx'] = route - } - try { - const app = await build({ - bundler, - env: 'client', - pluginOptions: { codeSplittingOptions: { deleteNodes: ['server'] } }, - files, - symlinks: linked ? { 'routes/index.tsx': 'linked/index.tsx' } : {}, - }) - return app.where('MARK_SERVER_SECRET') - } catch (error) { - // A refusal names the route file - if ( - error instanceof BuildError && - error.errors.join('\n').includes('index.tsx') - ) { - return 'refused' - } - throw error - } - } - - // Control for the pin below (same app, a regular route file). - test.each(clientBundlers)( - 'a client build has no server handler code (%s)', - async (bundler) => { - expect(await serverCodePlacement(bundler, false)).toBe('nowhere') - }, - 60_000, - ) - - // Bug: the code splitter looks route files up by the path the generator - // scanned (the symlink), but bundlers resolve modules to their real path, - // so a symlinked route file is never compiled: nothing is split or - // removed, silently. Failing the build is a valid fix too. Source: #8514. - // Impact: with symlinked sources (Bazel, Nix, linked checkouts), Start's - // client bundle ships `server.handlers` and their server-only imports. - test.fails.each(clientBundlers)( - 'a client build of a symlinked route file has no server handler code (%s)', - async (bundler) => { - expect(['nowhere', 'refused']).toContain( - await serverCodePlacement(bundler, true), - ) - }, - 60_000, - ) -}) - -describe('source maps', () => { - /** - * Source files named by the source maps of a client build's chunks that - * hold the split component (`MARK_PAGE`) or a binding the route module - * shares with it (`MARK_SHARED`). - */ - async function splitChunkSources(bundler: Bundler) { - const app = await build({ - bundler, - env: 'client', - minify: false, - sourcemap: true, - files: routeApp( - `${head}const shared = { label: 'MARK_SHARED' } -function Page() { - return

    MARK_PAGE {shared.label}

    -} -export const Route = createFileRoute('/')({ - loader: () => shared, - component: Page, -}) -`, - { env: 'client' }, - ), - }) - const chunks = app.chunks.filter( - (chunk) => - chunk.code.includes('MARK_PAGE') || chunk.code.includes('MARK_SHARED'), - ) - return [...new Set(chunks.flatMap((chunk) => chunk.map?.sources ?? []))] - } - - /** Whether the sources name the route file, and none a virtual module. */ - function namesRouteFile(sources: Array) { - return { - routeFile: sources.some((source) => source.includes('routes/index.tsx')), - virtualModules: sources.filter((source) => /[?&]tsr-/.test(source)), - } - } - - // Control for the pin below: Rspack and webpack name modules by their - // resource path. - test.each(['rspack', 'webpack'] as const)( - 'split code maps to the route file (%s)', - async (bundler) => { - expect(namesRouteFile(await splitChunkSources(bundler))).toEqual({ - routeFile: true, - virtualModules: [], - }) - }, - 60_000, - ) - - // Bug: the split chunk and shared modules are compiled with their module - // id as the source file name, query included (`routes/index.tsx?tsr-split= - // component`), and Vite keeps the source names of the maps plugins return. - // Source: gap CC-S2 (plausible cause of #6667, #3828, #2991). - // Impact: devtools and error trackers show split code under a separate - // `index.tsx?tsr-split=…` file, so breakpoints set in the route file do - // not hit in split code. - test.fails( - 'split code maps to the route file (vite)', - async () => { - expect(namesRouteFile(await splitChunkSources('vite'))).toEqual({ - routeFile: true, - virtualModules: [], - }) - }, - 60_000, - ) -}) - -describe('production builds', () => { - /** - * A production client build of a route the code splitter warns about - * (today: an exported component it cannot split), with `NODE_ENV` as - * given (`null`: unset); returns whether the build warned, naming the - * route file, and where the app's absolute path (which the warning names) - * is in the bundle. - */ - async function warningPlacement(bundler: Bundler, nodeEnv?: null) { - const app = await build({ - bundler, - env: 'client', - nodeEnv, - files: routeApp( - `${head}export function Page() { - return

    page

    -} -export const Route = createFileRoute('/')({ - component: Page, - errorComponent: () =>

    error

    , -}) -`, - { env: 'client' }, - ), - }) - return { - warned: [...app.warnings, ...app.consoleMessages].some((message) => - message.includes('routes/index.tsx'), - ), - appPath: app.where(app.root), - } - } - - // Control for the pin below: with `NODE_ENV=production` exported. Its - // first assertion is a precondition of both tests, not a requirement: - // without a warning, the bundle has nothing to leak, so once the code - // splitter splits this route, use another route it warns about. - test.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no development warning (%s)', - async (bundler) => { - const { warned, appPath } = await warningPlacement(bundler) - expect(warned, 'precondition: the route makes the build warn').toBe(true) - expect(appPath).toBe('nowhere') - }, - 60_000, - ) - - // Bug: the code splitter's `console.warn` (today: "will not be - // code-split") is injected into the route module unless the build - // process's `NODE_ENV` is `production`, instead of following the - // bundler's mode as the rest of the code splitter does. webpack and Rspack - // set `NODE_ENV` only inside the bundle. Source: gap CC-P2. - // Impact: webpack/Rspack production bundles built from a shell without - // `NODE_ENV` log a development warning in every user's console, with the - // developer's absolute file path. - test.fails.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no development warning when NODE_ENV is unset (%s)', - async (bundler) => { - expect((await warningPlacement(bundler, null)).appPath).toBe('nowhere') - }, - 60_000, - ) }) 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 8a68e702e1c..7aa1f79fb3b 100644 --- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts @@ -12,8 +12,6 @@ import react from '@vitejs/plugin-react' import vueJsx from '@vitejs/plugin-vue-jsx' import solid from 'vite-plugin-solid' import { describe, expect, test } from 'vitest' -import type { TestContext } from 'vitest' -import { build, routeApp } from './build-harness' import { evaluateModule, expectValidModules, @@ -23,9 +21,7 @@ import { routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' -import { openRouteHmrPage } from './route-hmr-page' import { editRouteFile } from './vite-hot-update' -import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' import type { PluginOption } from 'vite' describe('React Refresh registration', () => { @@ -509,372 +505,6 @@ export { MyRoute as Route }`, ) }) -// Route HMR in a real dev server and a real browser page -// (`route-hmr-page.ts`, as route-hmr-browser.test.ts). A pin waits for the -// new code at most `pinTimeout`, well under the test timeout. -// Concurrent: each pin mostly waits (`pinTimeout`) for an edit that never -// shows. -describe.concurrent('route HMR in a real page', { timeout: 60_000 }, () => { - const pinTimeout = 3_000 - - /** A React route at `/` whose component, `name`, keeps a click count. */ - const counterRoute = ( - version: string, - name = 'Page', - options = '', - ) => `import { createFileRoute } from '@tanstack/react-router' -import { useState } from 'react' -function ${name}() { - const [count, setCount] = useState(0) - return -} -function Failed() { - return

    failed ${version}

    -} -export const Route = createFileRoute('/')({ - component: ${name}, - ${options} -}) -` - - /** - * Opens a React app in Vite whose route file `file` is `route('v1')`, - * waits until it shows `shows('v1')`, edits it to `route('v2')` and - * returns whether the page reloaded once it shows `shows('v2')` (waiting - * at most `timeout`). - */ - async function editComponent( - { - file, - autoCodeSplitting, - route, - shows, - }: { - file: string - autoCodeSplitting: boolean - route: (version: string) => string - shows: (version: string) => string - }, - onTestFinished: TestContext['onTestFinished'], - timeout?: number, - ) { - const app = await openRouteHmrPage({ - onTestFinished, - pluginOptions: { autoCodeSplitting }, - files: { [file]: route('v1') }, - ready: shows('v1'), - }) - return app.edit({ [file]: route('v2') }, shows('v2'), { timeout }) - } - - /** A route at `/` with the `component` option `option`, after `imports`. */ - const inlineRoute = - (imports: string, option: (version: string) => string) => - (version: string) => - `import { createFileRoute } from '@tanstack/react-router' -${imports} -export const Route = createFileRoute('/')({ - ${option(version)}, -}) -` - - // Control for the inline component pins below (same harness). - test('vite: an edit of a component passed as memo(Page) shows in place (autoCodeSplitting: false)', async ({ - expect, - onTestFinished, - }) => { - expect( - await editComponent( - { - file: 'routes/index.tsx', - autoCodeSplitting: false, - route: inlineRoute( - `import { memo } from 'react' -function Page() { - return

    page v1

    -}`, - () => 'component: memo(Page)', - ), - shows: () => 'page v1', - }, - onTestFinished, - ), - ).toEqual({ reloaded: false }) - }) - - // Bug: inline component options that are not plain functions (`memo(...)`, - // `forwardRef(...)`, method shorthand, a function wrapped in a TypeScript - // `as` / `satisfies`) are neither hoisted nor registered for React - // Refresh, nor is the function they wrap; route HMR keeps the previous - // component for React Refresh to patch, which it cannot. - // Impact: on unsplit routes (root routes, or file routes without automatic - // code splitting), and for a split method-shorthand component, edits to - // these components do not show until a reload. - // Source: ReactFreshBabelPlugin-test "registers likely HOCs with inline - // functions" and "registers top-level variable declarations with arrow - // functions"; #8504 react-refresh-route-components ("written inline and - // wrapped in ..."), where the wrapped rows pass; ruling on #8721 "keeps a - // method-shorthand component option" (split row). Formerly read React - // Refresh's registrations and modelled its runtime (ruling 2026-10-18). - // Any fix passes: the edit shows without a full reload. - test.fails.for([ - ...[ - ['as any', '', `(() =>

    inline VERSION

    ) as any`], - [ - 'satisfies', - `import type { FC } from 'react'`, - `(() =>

    inline VERSION

    ) satisfies FC`, - ], - [ - 'memo(() => ...)', - `import { memo } from 'react'`, - `memo(() =>

    inline VERSION

    )`, - ], - [ - 'forwardRef(function () { ... })', - `import { forwardRef } from 'react'`, - `forwardRef(function (props, ref) { - return

    inline VERSION

    - })`, - ], - ].map(([name, imports, component]) => ({ - name: name!, - file: 'routes/index.tsx', - autoCodeSplitting: false, - route: inlineRoute( - imports!, - (version) => `component: ${component!.replace('VERSION', version)}`, - ), - shows: (version: string) => `inline ${version}`, - })), - ...[false, true].map((autoCodeSplitting) => ({ - name: 'method shorthand', - file: 'routes/index.tsx', - autoCodeSplitting, - route: inlineRoute( - '', - (version) => `component() { - return

    inline ${version}

    - }`, - ), - shows: (version: string) => `inline ${version}`, - })), - { - name: 'React.memo(forwardRef(...)) on a root route', - file: 'routes/__root.tsx', - autoCodeSplitting: true, - route: (version: string) => `import React, { forwardRef } from 'react' -import { createRootRoute } from '@tanstack/react-router' -export const Route = createRootRoute({ - component: React.memo(forwardRef(function Root(props, ref) { - return

    root ${version}

    - })), -}) -`, - shows: (version: string) => `root ${version}`, - }, - ])( - 'vite: an edit of an inline component written as $name shows in place (autoCodeSplitting: $autoCodeSplitting)', - async (row, { expect, onTestFinished }: TestContext) => { - expect(await editComponent(row, onTestFinished, pinTimeout)).toEqual({ - reloaded: false, - }) - }, - ) - - // Bug: route HMR keeps the previous split components only for React: for - // Solid and Vue, the route module creates a new lazy component on every - // update and `handleRouteUpdate` keeps no component option - // (`getFrameworkHmrCompilerPlugins` returns no plugin and the kept - // component keys are empty for other targets); in Vite, the route module - // is not an HMR boundary either (its accept call is on an alias), so the - // page reloads. - // Impact: every edit of a Solid or Vue route file with automatic code - // splitting reloads the page in Vite dev, losing its state, instead of - // patching it in place. - // Goal (any fix): in a Vite dev server with the framework's plugin, the - // edit shows without a full reload, and the live route keeps the component - // the page rendered. - // Source: decision D10 (Solid/Vue split components keep their identity - // across hot updates, as React's do); gaps H8/H9. - test.fails.for(['solid', 'vue'] as const)( - 'vite: an edit of a split %s route shows in place, keeping the split component', - async ( - target: 'solid' | 'vue', - { expect, onTestFinished }: TestContext, - ) => { - const route = (version: string) => - `import { createFileRoute } from '@tanstack/${target}-router' -export const Route = createFileRoute('/')({ - loader: () => 'data ${version}', - component: Page, -}) -function Page() { - const data = Route.useLoaderData() - return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    -} -` - const app = await openRouteHmrPage({ - target, - onTestFinished, - files: { 'routes/index.tsx': route('v1') }, - ready: 'page v1 data v1', - }) - const liveComponent = `window.__TSR_ROUTER__.routesById['/'].options.component` - await app.evaluate(`window.shownComponent = ${liveComponent}`) - expect( - await app.edit({ 'routes/index.tsx': route('v2') }, 'page v2 data v2', { - timeout: pinTimeout, - }), - ).toEqual({ reloaded: false }) - expect( - await app.evaluate(`window.shownComponent === ${liveComponent}`), - ).toBe(true) - }, - ) - - // Bug: `handleRouteUpdate` keeps the previous component options so that - // React Refresh patches them in place, also when the edit replaced the - // component with another one (renamed, so another component family for - // React Refresh), which React Refresh never patches. With automatic code - // splitting, the kept lazy component keeps the component its chunk loaded - // first. The same happens when an edit replaces inline route options of a - // split route with options a factory call builds. - // Impact: after renaming a route's component, the page keeps showing the - // old component until a reload (Vite and Rsbuild). - // Source: router-plugin src/core/hmr/handle-route-update.ts (component - // keys kept whenever both versions have them); NEW (hmr-real, found by the - // real-page harness; the former model treated it as expected). - // Any fix passes: a hot update rendering the new component (React mounts - // it anew), or a reload. - describe.each(['vite', 'rsbuild'] as const)('%s', (bundler) => { - test.fails.for([true, false])( - 'renaming the route component shows the renamed component (autoCodeSplitting: %s)', - async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { - const app = await openRouteHmrPage({ - bundler, - onTestFinished, - pluginOptions: { autoCodeSplitting }, - files: { 'routes/index.tsx': counterRoute('v1') }, - ready: 'Page v1 count 0', - }) - await app.edit( - { 'routes/index.tsx': counterRoute('v2', 'Renamed') }, - 'Renamed v2 count', - { timeout: pinTimeout }, - ) - }, - ) - }) - - // Bug: on Rsbuild (Rspack), an edit that only changes a loader split from - // the route module (`codeSplitGroupings` with `loader` in a chunk) never - // reaches the page: the route module's code is unchanged, so Rspack does - // not run it again and the route HMR code (`webpack-adapter.ts`, in the - // route module only) never refreshes the route; the split loader module - // accepts no update of its own. With Rsbuild's default lazy compilation - // the loaded chunk is not even updated; without it (`dev.lazyCompilation: - // false`), the next load gets the new loader, but nothing reloads the - // route. Vite updates the route in place (route-hmr-browser.test.ts). - // Impact: the page keeps the old loader's data until a reload or a - // navigation, with no sign that the edit was not applied. - // Source: router-plugin src/core/hmr/webpack-adapter.ts (HMR code only in - // the route module); NEW (hmr-real). - // Any fix passes: a hot update showing the new loader's data, or a reload. - test.fails.for([`[['loader'], ['component']]`, `[['component', 'loader']]`])( - 'rsbuild: an edit of a split loader (codeSplitGroupings: %s) shows its data', - async (groupings: string, { onTestFinished }: TestContext) => { - const route = (version: string) => - `import { createFileRoute } from '@tanstack/react-router' -function Page() { - return

    {Route.useLoaderData()}

    -} -export const Route = createFileRoute('/')({ - loader: () => 'loader ${version}', - component: Page, - codeSplitGroupings: ${groupings}, -}) -` - const app = await openRouteHmrPage({ - bundler: 'rsbuild', - onTestFinished, - files: { 'routes/index.tsx': route('v1') }, - ready: 'loader v1', - }) - await app.edit({ 'routes/index.tsx': route('v2') }, 'loader v2', { - timeout: pinTimeout, - }) - }, - ) - - // Bug: an edit that removes a split component option (here - // `errorComponent`, which the page had loaded) leaves its split module in - // Vite's module graph; Vite updates it with the rest of the route file, the - // code splitter serves it without any export or import, and - // `@vitejs/plugin-react` compiles that module's JSX as CommonJS - // (`require("react/jsx-dev-runtime")`). - // Impact: the page logs "[vite] ReferenceError: require is not defined" - // and "[vite] Failed to reload /routes/index.tsx?tsr-split=errorComponent"; - // some runs reload the page. - // Source: router-plugin src/core/code-splitter (the virtual split module - // of an option the route file no longer has); NEW (hmr-real). - // Goal (any fix): the edit applies in place, and the page logs no error. - test.fails( - 'vite: an edit that removes a split option the page loaded applies without errors', - async ({ expect, onTestFinished }: TestContext) => { - const app = await openRouteHmrPage({ - onTestFinished, - files: { - 'routes/index.tsx': counterRoute( - 'v1', - 'Page', - `errorComponent: Failed, - loader: () => { - throw new Error('loader failed') - },`, - ), - }, - ready: 'failed v1', - }) - const errors = app.page.errors.length - expect( - await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { - timeout: pinTimeout, - }), - ).toEqual({ reloaded: false }) - expect(app.page.errors.slice(errors)).toEqual([]) - }, - ) - - // Bug: `handleRouteUpdate` keeps the previous component options for React - // Refresh to patch in place, also when no React Refresh runtime is - // installed (an Rsbuild React app with `pluginReact({ fastRefresh: false - // })`, or webpack / Rspack without a refresh plugin, like the webpack - // quickstart example with the default `addHmr`), so the new component - // never renders. With automatic code splitting, the kept component is the - // old chunk's. - // Impact: the bundler reports the route as hot-updated, but the page shows - // the old component until a reload; without the route HMR code the same - // edit reloads the page. - // Any fix passes: a hot update rendering the new component, or a reload. - test.fails.for([true, false])( - 'rsbuild without React Refresh: an edit of the route component shows (autoCodeSplitting: %s)', - async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { - const app = await openRouteHmrPage({ - bundler: 'rsbuild', - onTestFinished, - reactRefresh: false, - pluginOptions: { autoCodeSplitting }, - files: { 'routes/index.tsx': counterRoute('v1') }, - ready: 'Page v1 count 0', - }) - await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { - timeout: pinTimeout, - }) - }, - ) -}) - describe('Vite HMR boundaries', () => { /** A route file of `target` whose component renders `text`. */ const route = @@ -992,74 +622,3 @@ export const Route = createFileRoute('/')({ loader: () => '${text}' })`, 30_000, ) }) - -describe('production builds', () => { - /** - * Builds a production client bundle with automatic code splitting on or - * off (off: the route HMR plugin's case), the plugin's own `addHmr` - * default and `NODE_ENV` as given (`null`: unset), and returns where a - * bundler HMR API is used. - */ - async function hmrPlacement( - bundler: Bundler, - autoCodeSplitting: boolean, - nodeEnv?: null, - ) { - const app = await build({ - bundler, - env: 'client', - nodeEnv, - pluginOptions: { - autoCodeSplitting, - codeSplittingOptions: { addHmr: undefined }, - }, - files: routeApp( - `${head}function Page() { - return

    page

    -} -export const Route = createFileRoute('/')({ component: Page }) -`, - { env: 'client' }, - ), - }) - return app.where(/import\.meta\.(?:webpackHot|hot)\b|\bmodule\.hot\b/) - } - - // No Vite rows: Vite replaces `import.meta.hot` with `undefined` in a - // build and drops the code it guards, so they could not fail. - - // Control for the pin below: with `NODE_ENV=production` exported. - test.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no route HMR code (%s)', - async (bundler) => { - expect(await hmrPlacement(bundler, false)).toBe('nowhere') - }, - 60_000, - ) - - // Source: gap P1 / DV51. (No esbuild row: the esbuild plugin neither - // generates nor splits routes, pinned in build-harness.test.ts, so it - // could not fail.) - test.each(['rspack', 'webpack'] as const)( - 'a production client bundle with automatic code splitting has no route HMR code (%s)', - async (bundler) => { - expect(await hmrPlacement(bundler, true)).toBe('nowhere') - }, - 60_000, - ) - - // Bug: with automatic code splitting off, the composed router plugin adds - // the route HMR plugin unless `NODE_ENV` is `production` when the plugin is - // created (`router-composed-plugin.ts`), instead of following the - // bundler's mode. webpack and Rspack set `NODE_ENV` only inside the bundle. - // Source: gap CC-P1 / routes.md R7. - // Impact: webpack/Rspack production bundles built from a shell without - // `NODE_ENV` ship the route HMR code of every route. - test.fails.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no route HMR code when NODE_ENV is unset (%s)', - async (bundler) => { - expect(await hmrPlacement(bundler, false, null)).toBe('nowhere') - }, - 60_000, - ) -}) diff --git a/packages/router-plugin/tests/plugin-options.test.ts b/packages/router-plugin/tests/plugin-options.test.ts index 2e1fa7e12fd..6c992ce4d9c 100644 --- a/packages/router-plugin/tests/plugin-options.test.ts +++ b/packages/router-plugin/tests/plugin-options.test.ts @@ -10,93 +10,19 @@ import { evaluateServed, } from '@tanstack/compiler-test-utils/dev-server' import { describe, expect, onTestFinished, test } from 'vitest' -import { build, routeApp, routeHead } from './build-harness' +import { routeHead } from './build-harness' import { loadRouteOption, rootRouteOf, routerPackageStub } from './dev-server' import { compileRouteModules, head } from './regression-helpers' -import type { - BuildResult, - Bundler, -} from '@tanstack/compiler-test-utils/build-harness' +import type { BuildResult } from '@tanstack/compiler-test-utils/build-harness' 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. */ -const route = (routePath = '/') => `${routeHead()}function Page() { - return

    MARK_COMPONENT

    -} -export const Route = createFileRoute('${routePath}')({ - staticData: { label: 'MARK_ROUTE' }, - component: Page, -}) -` - -/** Builds the route for the client and renders its component. */ -async function buildRoute( - bundler: Bundler, - pluginOptions: RouterPluginOptions, - options: { - files?: Record - routes?: (config: RouterPluginOptions) => Array - } = {}, -) { - const app = await build({ - bundler, - env: 'client', - pluginOptions, - routerPlugins: options.routes, - files: routeApp(options.files?.['routes/index.tsx'] ?? route(), { - env: 'client', - files: options.files, - }), - }) - const run = await app.run(`await entry.mount(entry.Route.options.component)`) - return { app, html: run.html } -} - const read = (app: BuildResult, file: string) => readFile(path.join(app.root, file), 'utf8').catch(() => null) -describe.each([false, undefined])( - 'autoCodeSplitting: %s', - (autoCodeSplitting) => { - test.each(bundlers)( - 'splits nothing and still generates the route tree (%s)', - async (bundler) => { - const { app, html } = await buildRoute(bundler, { autoCodeSplitting }) - expect(app.where('MARK_COMPONENT')).toBe('eager') - expect(app.lazy).toEqual([]) - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) - }, - timeout, - ) - }, -) - -describe('plugin.vite.environmentName', () => { - test.each([ - ['client', 'lazy'], - ['ssr', 'eager'], - ] as const)( - 'with %s code-splits only that environment (client build: %s)', - async (environmentName, placement) => { - const { app, html } = await buildRoute('vite', { - plugin: { vite: { environmentName } }, - }) - expect(app.where('MARK_COMPONENT')).toBe(placement) - expect(html).toContain('MARK_COMPONENT') - }, - timeout, - ) -}) - // 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 @@ -213,181 +139,3 @@ describe('repeated options in split groupings', () => { ).toThrow(/groupings-route\.tsx[\s\S]*loader/) }) }) - -// The webpack and Rspack entries use their bundler's HMR API whatever -// `plugin.hmr.style` says (src/rspack.ts, src/webpack.ts). -describe.each(['rspack', 'webpack'] as const)('%s entry', (bundler) => { - /** - * Development client build with the bundler's HMR plugin, `addHmr`, and - * `plugin.hmr.style: 'vite'`. - */ - async function hotDevelopmentBuild( - addHmr: boolean | undefined, - routes?: (config: RouterPluginOptions) => Array, - ) { - return build({ - bundler, - env: 'client', - mode: 'development', - pluginOptions: { - plugin: { hmr: { style: 'vite' } }, - codeSplittingOptions: { addHmr }, - }, - routerPlugins: routes, - extraPlugins: () => [ - { - apply(compiler: any) { - new compiler.webpack.HotModuleReplacementPlugin().apply(compiler) - }, - }, - ], - files: routeApp(route(), { env: 'client' }), - }) - } - - /** - * The plugins the entry is made of, for this bundler, without the entry's - * own handling of the options. - */ - async function unforcedPlugins() { - const { createRspackPlugin, createWebpackPlugin } = await import('unplugin') - const { createRouterGeneratorPlugin } = - await import('../src/core/router-generator-plugin') - const { createRouterCodeSplitterPlugin } = - await import('../src/core/router-code-splitter-plugin') - const { createRouterPluginContext } = - await import('../src/core/router-plugin-context') - const create: ( - factory: (options: any) => any, - ) => (options: any) => unknown = - bundler === 'rspack' ? createRspackPlugin : createWebpackPlugin - return (config: RouterPluginOptions) => { - const context = createRouterPluginContext() - return [ - create((options) => createRouterGeneratorPlugin(options, context))( - config, - ), - create((options) => createRouterCodeSplitterPlugin(options, context))( - config, - ), - ] - } - } - - /** - * Whether the route modules accept hot updates through the hot module API - * of webpack and Rspack (`import.meta.webpackHot` / `module.hot`), and - * whether they use Vite's (`import.meta.hot`, undefined in these bundles). - */ - const hmrApi = (app: BuildResult) => ({ - accepts: app.where(/\bhot\.accept\(/) !== 'nowhere', - vite: app.where(/\bimport\.meta\.hot\b/) !== 'nowhere', - }) - - test( - 'makes routes accept hot updates through the bundler even when plugin.hmr.style is vite', - async () => { - const [entry, withoutHmr, unforced] = await Promise.all([ - hotDevelopmentBuild(undefined), - hotDevelopmentBuild(false), - hotDevelopmentBuild(undefined, await unforcedPlugins()), - ]) - // Controls: no route HMR code; Vite's HMR API when the style is not - // forced - expect(hmrApi(withoutHmr)).toEqual({ accepts: false, vite: false }) - expect(hmrApi(unforced).vite).toBe(true) - expect(hmrApi(entry)).toEqual({ accepts: true, vite: false }) - }, - timeout, - ) -}) - -describe('separate generator and code splitter entries', () => { - const entries: Record< - (typeof bundlers)[number], - () => Promise<(config: RouterPluginOptions) => Array> - > = { - vite: async () => { - const vite = await import('../src/vite') - return (config) => [ - vite.tanstackRouterGenerator(config), - vite.tanStackRouterCodeSplitter(config), - ] - }, - rspack: async () => { - const rspack = await import('../src/rspack') - return (config) => [ - rspack.TanStackRouterGeneratorRspack(config), - rspack.TanStackRouterCodeSplitterRspack(config), - ] - }, - webpack: async () => { - const webpack = await import('../src/webpack') - return (config) => [ - webpack.TanStackRouterGeneratorWebpack(config), - webpack.TanStackRouterCodeSplitterWebpack(config), - ] - }, - } - - test.each(bundlers)( - 'generate the route tree and split like the combined plugin (%s)', - async (bundler) => { - const { app, html } = await buildRoute( - bundler, - {}, - { routes: await entries[bundler]() }, - ) - expect(app.where('MARK_COMPONENT')).toBe('lazy') - expect(app.where('MARK_ROUTE')).toBe('eager') - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) - }, - timeout, - ) -}) - -// The package root exports the generator's unplugin factory, for bundler -// adapters of its own. -// Source: r5-gaps N12 (rp:1935: the factory returned no plugin). -test( - 'the unplugin factory of the generator from the package root generates the route tree (vite)', - async () => { - const { createUnplugin } = await import('unplugin') - const { unpluginRouterGeneratorFactory } = await import('../src') - const { app, html } = await buildRoute( - 'vite', - {}, - { - routes: (config) => [ - createUnplugin(unpluginRouterGeneratorFactory).vite(config), - ], - }, - ) - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) - }, - timeout, -) - -test( - 'the deprecated TanStackRouterVite generates the route tree and splits like tanstackRouter', - async () => { - const { TanStackRouterVite } = await import('../src/vite') - const { app, html } = await buildRoute( - 'vite', - {}, - { routes: (config) => [TanStackRouterVite(config)] }, - ) - expect(app.where('MARK_COMPONENT')).toBe('lazy') - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) - }, - timeout, -) diff --git a/packages/router-plugin/tests/regression-helpers.test.ts b/packages/router-plugin/tests/regression-helpers.test.ts index 2b53435ab98..a31e70d9673 100644 --- a/packages/router-plugin/tests/regression-helpers.test.ts +++ b/packages/router-plugin/tests/regression-helpers.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it } from 'vitest' import { routerDevServer } from './dev-server' import { - buildAndRun, compileRouteModules, componentChunk, expectRegisteredRouteOption, @@ -21,7 +20,6 @@ import { routeModuleSources, routeSpecifier, } from './regression-helpers' -import { openRouteHmrPage } from './route-hmr-page' describe('compileRouteModules', () => { const route = `${head}export const Route = createFileRoute('/')({ @@ -149,31 +147,6 @@ export const read = () => [store.count, typeof Route]`) }) }) -describe('buildAndRun', () => { - // A real app's package.json has no side-effect hint unless the app adds - // one: a module the route file imports for its side effect runs. - it.each([ - { sideEffects: undefined, ran: true }, - { sideEffects: false, ran: false }, - ])( - 'keeps a side-effect import unless the app declares "sideEffects": $sideEffects', - async ({ sideEffects, ran }) => { - expect( - await buildAndRun( - `${head}import '../effect' -export const Route = createFileRoute('/')({ component: () =>

    page

    })`, - `return globalThis.effectRan === true`, - { - sideEffects, - files: { 'effect.ts': `;(globalThis as any).effectRan = true\n` }, - }, - ), - ).toBe(ran) - }, - 30_000, - ) -}) - describe('loadRoute', () => { it('evaluates the route module first and a split option when it loads', async () => { const effects: Array = [] @@ -363,78 +336,6 @@ export const Route = createFileRoute('/')({ loader: () => '${loader}' })` }) }) -// Controls of the route HMR page (`openRouteHmrPage`): it tells kept state -// from lost state, and a hot update from a full reload. A React app whose -// route component renders a counter, and whose entry renders a header -// imported from a module that no HMR boundary accepts. -describe.each(['vite', 'rsbuild'] as const)( - 'openRouteHmrPage (%s)', - (bundler) => { - const route = ( - version: string, - child = 'Counter', - ) => `import { createFileRoute } from '@tanstack/react-router' -import { useState } from 'react' -function ${child}() { - const [count, setCount] = useState(0) - return -} -function Page() { - return
    <${child} />
    -} -export const Route = createFileRoute('/')({ component: Page }) -` - const main = `import { createRoot } from 'react-dom/client' -import { RouterProvider, createRouter } from '@tanstack/react-router' -import { routeTree } from './routeTree.gen' -import { header } from './header' -const router = createRouter({ routeTree }) -createRoot(document.getElementById('app')!).render( - <> -

    {header}

    - - , -) -` - - it('sees kept state, lost state and full reloads', async () => { - const app = await openRouteHmrPage({ - bundler, - files: { - 'main.tsx': main, - 'header.ts': `export const header = 'header v1'`, - 'routes/index.tsx': route('v1'), - }, - ready: 'Counter v1 count 0', - }) - await app.click('button') - await app.page.waitForText('Counter v1 count 1') - // An edit of the component's body: React Refresh keeps its state. - expect( - await app.edit( - { 'routes/index.tsx': route('v2') }, - 'Counter v2 count 1', - ), - ).toEqual({ reloaded: false }) - // A component the edit renames is another component for React Refresh: - // React mounts it anew. - expect( - await app.edit( - { 'routes/index.tsx': route('v3', 'Renamed') }, - 'Renamed v3 count 0', - ), - ).toEqual({ reloaded: false }) - // An edit of a module that no HMR boundary accepts reloads the page. - expect( - await app.edit( - { 'header.ts': `export const header = 'header v2'` }, - 'header v2', - ), - ).toEqual({ reloaded: true }) - }, 60_000) - }, -) - describe('routerDevServer', () => { it('serves a route of another target with the extra plugins after the router plugin', async () => { const stubId = '\0solid-router-stub' diff --git a/packages/router-plugin/vite.config.ts b/packages/router-plugin/vite.config.ts index e80f073d218..b20d04db66c 100644 --- a/packages/router-plugin/vite.config.ts +++ b/packages/router-plugin/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, mergeConfig } from 'vitest/config' +import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import packageJson from './package.json' @@ -7,7 +7,29 @@ const config = defineConfig({ name: packageJson.name, dir: './tests', watch: false, - typecheck: { enabled: true }, + projects: [ + { + // Fast tier (`test:unit`): compiles and evaluates in process; a Vite + // dev server in middleware mode is allowed. + extends: true, + test: { + name: 'unit', + exclude: [...configDefaults.exclude, 'integration/**'], + typecheck: { enabled: true }, + }, + }, + { + // Slow tier (`test:integration`): real builds, Rsbuild dev servers, + // child Node runs and browsers. + extends: true, + test: { + name: 'integration', + include: ['integration/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + }, + }, + ], }, }) diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index d1ab2b14557..bdc4b3d4c32 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -27,8 +27,10 @@ "scripts": { "clean": "rimraf ./dist && rimraf ./coverage", "clean:snapshots": "rimraf **/*snapshot* --glob", - "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit", - "test:unit": "vitest", + "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit && pnpm test:integration", + "test:unit": "vitest --project unit", + "test:integration": "vitest --project integration", + "test:integration:smoke": "vitest --project integration integration/importProtection/build.test.ts -t \"build a denied re-export barrel the client uses fails the build\"", "test:eslint": "eslint ./src", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", @@ -42,7 +44,18 @@ }, "nx": { "targets": { - "test:unit": { + "test:integration": { + "dependsOn": [ + "^build", + { + "projects": [ + "@tanstack/react-start" + ], + "target": "build" + } + ] + }, + "test:integration:smoke": { "dependsOn": [ "^build", { 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 531295be11a..53339544148 100644 --- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts @@ -2,23 +2,11 @@ * Known import-protection bugs. Each test asserts correct behaviour for a bug * on main and is marked .fails; remove .fails when the bug is fixed. */ -import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' + import { transformWithOxc } from 'vite' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { 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, - reactRuntimeFiles, - rejection, - reportedViolations, - serverFnFactoryApp, - startProtectedDevServer, -} from './start-apps' -import type { RsbuildConfig } from '@rsbuild/core' -import type { ImportProtectionOptions } from '../../src/schema' const mockModule = `data:text/javascript,${encodeURIComponent( `export default new Proxy({}, { get: (_, key) => typeof key === 'string' ? 'mock:' + key : undefined })`, @@ -157,677 +145,3 @@ export const result = api.delete()`, expect(isMock(module.result)).toBe(true) }, ) - -/** - * Start's import protection options of a build that denies `files` to the - * client and fails on a violation. - */ -function denyToClient(files: Array) { - return { importProtection: { behavior: 'error', client: { files } } } as const -} - -/** Same as `denyToClient`, mocking the denied modules instead. */ -function mockToClient(files: Array) { - return { importProtection: { behavior: 'mock', client: { files } } } as const -} - -/** A denied module whose code marks the bundle it is in. */ -const corpusModule = `export const corpus = () => 'MARK_CORPUS' + Math.random()\n` - -/** - * A client entry re-exporting a server fn of `layout.ts`, which also - * exports a function using the denied-to-be `corpus.ts` that nothing calls. - * Import protection checks the modules of the source directory (`src`). - */ -const droppedCorpusFiles = { - 'src/corpus.ts': corpusModule, - 'src/layout.ts': `import { createServerFn } from '@tanstack/react-start' -import { corpus } from './corpus' -export const size = () => corpus() -export const nav = createServerFn().handler(async () => 1) -`, - 'src/entry.ts': `export { nav } from './layout'\n`, -} - -describe.each(startBundlers)('%s build', (bundler) => { - // Control for the pin below (same harness): a denied module whose code the - // client bundle contains fails the build. - test('a denied module the client uses fails the build', async () => { - await expect( - buildStart({ - bundler, - files: { - 'src/corpus.ts': corpusModule, - 'src/entry.ts': `import { corpus } from './corpus'\nexport const read = () => corpus()\n`, - }, - entry: 'src/entry.ts', - start: denyToClient(['**/corpus.ts']), - }), - ).rejects.toThrow(/Import denied in client environment/) - }, 60_000) - - // Control for the pin below (same files, no import protection): the client - // bundle does not contain the module's code. - test('without import protection, the client bundle drops the unused module', async () => { - const { client } = await buildStart({ - bundler, - files: droppedCorpusFiles, - entry: 'src/entry.ts', - start: { importProtection: { enabled: false } }, - }) - expect(client.where('MARK_CORPUS')).toBe('nowhere') - }, 60_000) - - // Control for the pins below (same harness and files, import protection - // on): the build succeeds in mock mode, without the module's code. - test('in mock mode, the client bundle drops the unused module', async () => { - const { client } = await buildStart({ - bundler, - files: droppedCorpusFiles, - entry: 'src/entry.ts', - start: mockToClient(['**/corpus.ts']), - }) - expect(client.where('MARK_CORPUS')).toBe('nowhere') - }, 60_000) - - // Bug: the build check counts a denied module as surviving when the - // compilation contains it, whether or not the client uses it. Vite: the - // mock that replaces the module is never tree-shaken, because its exports - // read members of the mock (`export const corpus = mock.corpus`, - // import-protection/virtualModules.ts), which the bundler keeps (with - // side-effect-free exports, a scratch run passes this pin on Vite). - // Rsbuild: the report step walks every module of the module graph - // (connections are not filtered by whether they are used, - // INTERNALS-import-protection.md). - // Impact: a denied module that a client-reachable module imports, but that - // the client never uses, still fails the build. - // Source: issue #8101 (closed without a reproduction); gap IP6b - // (gaps/start-env.md). - test.fails( - 'a denied module the client bundle drops does not fail the build', - async () => { - const { client } = await buildStart({ - bundler, - files: droppedCorpusFiles, - entry: 'src/entry.ts', - start: denyToClient(['**/corpus.ts']), - }) - expect(client.where('MARK_CORPUS')).toBe('nowhere') - }, - 60_000, - ) - - // Bug: same as above, in mock mode: the mock of a denied module the client - // never uses stays in the client bundle, and the build warns about it. - // The mock module is marked side-effect free (`@__NO_SIDE_EFFECTS__`, - // `@__PURE__`), but its exports read members of the mock. - // Impact: mock-mode builds ship mock code (a Proxy runtime) for unused - // imports, and report violations the client never reaches. - // Source: weak check virtualModules.test.ts "returns mock code" (asserted - // the purity annotations as text), oracle-gaps REPORT.md "Weak checks". - test.fails( - 'in mock mode, an unused denied module leaves no mock in the client bundle and no warning', - async () => { - const { client } = await buildStart({ - bundler, - files: droppedCorpusFiles, - entry: 'src/entry.ts', - start: mockToClient(['**/corpus.ts']), - }) - expect({ - mock: client.where('[import-protection mock]'), - warnings: client.warnings.filter((warning) => - warning.includes('[import-protection]'), - ), - }).toEqual({ mock: 'nowhere', warnings: [] }) - }, - 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's modules reports, or `'loaded'`. (Not - * the modules of `@tanstack/react-start/server`, which imports Start's - * entries, which only Start's whole plugin provides.) - */ - async function loadOutcome(files: Record) { - const server = await startProtectedDevServer(files, { behavior: 'error' }) - return server.load('/src/entry.ts', { skip: /^\/@(vite|fs)\// }).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, - ) - - // Bug: same cause as the pin above, one module further: when the Start - // compiler's lookup of a server fn factory module resolves the module's - // imports, import protection records each imported module as a lookup - // module too (vite/import-protection-plugin/plugin.ts `resolveId`, - // "Propagate pre-transform status transitively"), by its path, so the - // client's own transform of that module is silenced as well. - // Impact: in dev with behavior "error", any module a server fn factory - // module imports gets the real denied module when it calls it at the top - // level, without any error (the control above fails to load). - // Source: mutation run R4 (spc:5168, 5169). - test.fails( - 'a module a server fn factory module imports, calling a denied import at the top level, fails to load', - async () => { - const app = topLevelCallApp('src/other.ts') - app['src/factory.ts'] += `export { request } from './other'\n` - app['src/entry.ts'] = `export { fn } from './usage'\n` - expect(await loadOutcome(app)).toEqual(['client src/other.ts']) - }, - 30_000, - ) -}) - -describe('Rsbuild dev', () => { - /** A module type another loader compiles to JS (a stand-in for `.mdx`). */ - const pageModules: RsbuildConfig = { - tools: { - rspack: { - module: { rules: [{ test: /\.page$/, type: 'javascript/auto' }] }, - }, - }, - } - - // Control for the pins below (same config and import protection): a - // module of that type is compiled, and its code runs in the client. - test('a module of another type without a denied import runs in the client', async () => { - const server = await startRsbuildDevServer({ - importProtection: { - behavior: 'error', - client: { specifiers: ['db-lib'] }, - }, - config: pageModules, - files: { - 'src/home.page': `export const page = () => 'MARK_PAGE'\n`, - 'src/entry.ts': `import { page } from './home.page'\nconsole.log(page())\n`, - }, - }) - expect(server.errors).toEqual([]) - expect(await runRsbuildClient(server)).toEqual(['MARK_PAGE']) - }, 30_000) - - // 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: string; files: Record }>([ - { - 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, - ) - - // In Rsbuild dev, an error thrown by `onViolation` (awaited in the - // `processAssets` hook, rsbuild/import-protection.ts `reportViolation`) - // fails the compile: Rspack gives up on it (`compiler.hooks.failed`, no - // `done`) and Rsbuild logs the error, like a build fails with it. - // Source: mutation run R3, ip-rsbuild (REPORT §6 #23). This was a pin - // ("never reported") while the dev harness only waited for `done`; the - // harness now fails the compile wait with the error Rspack reports - // (mutation run R3c). - test('an onViolation that throws ends the compile with its error', async () => { - const outcome = await startRsbuildDevServer({ - importProtection: { - onViolation: () => { - throw new Error('MARK_THROWN') - }, - }, - // Only a guard: the failed compile ends the wait (a slow first - // compile on a loaded machine must not fail the test). - compileTimeout: 10_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) -}) - -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, - ) -}) - -/** - * App-level twins of the pins above on internal functions (rewrite and mock - * modules): a real app with each input, on a Vite dev server (mock mode, the - * dev default) and in mock-mode builds. `src/entry.ts` exports `read()`; a - * use of a denied import must read the mock, whose text is `expected` - * unless a row says otherwise, and the app must load or build. - * Source: audit F, missing coverage 1. - */ -describe('app-level twins of the mock pins', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - /** A dependency `name` exporting `query`, denied by `client.specifiers`. */ - const deniedDependency = (name: string) => ({ - [`node_modules/${name}/package.json`]: JSON.stringify({ - name, - type: 'module', - exports: { '.': './index.js' }, - }), - [`node_modules/${name}/index.js`]: `export const query = () => 'MARK_SECRET'\n`, - }) - - const mockText = '[import-protection mock]' - - interface TwinCase { - files: Record - importProtection?: ImportProtectionOptions - /** What `String(entry.read())` returns (default: the mock's text). */ - expected?: string - } - - const twins: Record = { - // Twin of "a denied namespace re-export keeps its exported name". - 'a module re-exporting a denied file as a namespace': { - files: { - 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, - 'src/barrel.ts': `export * as secrets from './secret.server'\n`, - 'src/entry.ts': `import { secrets } from './barrel' -export const read = () => secrets.secret() -`, - }, - }, - // Twin of "a namespace re-export" (the mock of a denied file). - 'a denied file re-exporting a namespace': { - files: { - 'src/helpers.ts': `export const format = () => 'MARK_SECRET'\n`, - 'src/lib.server.ts': `export * as helpers from './helpers'\n`, - 'src/entry.ts': `import { helpers } from './lib.server' -export const read = () => helpers.format() -`, - }, - }, - // Twin of "the mock of a denied file exporting `delete` exports it" and - // "a denied import used as `api.delete()` evaluates to a mock". - 'a denied file exporting `delete`, used as `api.delete()`': { - files: { - 'src/api.server.ts': `const remove = () => 'MARK_SECRET' -export { remove as delete } -`, - 'src/entry.ts': `import * as api from './api.server' -export const read = () => api.delete() -`, - }, - }, - // Twin of "a JSX pragma that leads a denied import stays in effect": the - // page renders with the runtime its pragma names. - 'a JSX pragma leading a denied import': { - files: { - ...reactRuntimeFiles, - 'node_modules/custom-jsx/package.json': JSON.stringify({ - name: 'custom-jsx', - type: 'module', - exports: { - './jsx-runtime': './jsx-runtime.js', - './jsx-dev-runtime': './jsx-runtime.js', - }, - }), - 'node_modules/custom-jsx/jsx-runtime.js': `export const Fragment = Symbol('Fragment') -export const jsx = () => 'MARK_CUSTOM_JSX' -export const jsxs = jsx -export const jsxDEV = jsx -`, - 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, - 'src/page.tsx': `/** @jsxImportSource custom-jsx */ -import { secret } from './secret.server' -export const Page = () =>
    {String(secret())}
    -`, - 'src/entry.ts': `import { Page } from './page' -export const read = () => Page() -`, - }, - expected: 'MARK_CUSTOM_JSX', - }, - // Twin of "a namespace member read before the import". - 'a namespace member read in a function declared before the import': { - files: { - ...deniedDependency('db-lib'), - 'src/entry.ts': `export function read() { - return db.query() -} -import * as db from 'db-lib' -`, - }, - importProtection: { client: { specifiers: ['db-lib'] } }, - }, - // Twin of "an enum and a namespace" (the mock of a denied file). - 'a denied file exporting an enum and a namespace': { - files: { - 'src/model.server.ts': `export enum Color { - Red = 'MARK_SECRET', -} -export namespace Util { - export const x = 'MARK_SECRET' -} -`, - 'src/entry.ts': `import { Color, Util } from './model.server' -export const read = () => [String(Color.Red), String(Util.x)].join() -`, - }, - expected: `${mockText},${mockText}`, - }, - } - - /** - * The channels on which main fails each twin (`.fails`, red reason in - * brackets). The JSX pragma twin passes everywhere: with the default JSX - * pipeline, JSX is compiled before import protection rewrites the import - * (the internal pin needs a JSX plugin that runs after the rewrite). - */ - const failsOn: Record> = { - // Bug: same as "a denied namespace re-export keeps its exported name" - // (the dev rewrite of the importer drops `export * as ns`). - // Impact: in dev, the page fails to load [SyntaxError: '/src/barrel.ts' - // does not provide an export named 'secrets']. Builds mock the denied - // file itself and pass. - 'a module re-exporting a denied file as a namespace': ['vite dev'], - // Bug: same as "a namespace re-export" (the self-contained mock of a - // denied file skips `export * as ns`). - // Impact: a mock-mode build fails [Vite MISSING_EXPORT, Rspack - // ESModulesLinkingError 'helpers']. Vite dev passes. - 'a denied file re-exporting a namespace': ['vite build', 'rsbuild build'], - // Bug: same as "the mock of a denied file exporting `delete` exports it" - // (`export const delete = mock.delete` in every mock module). - // Impact: the dev page and mock-mode builds fail with a parse error - // [Unexpected token 'delete'; PARSE_ERROR; Expected ident]. - 'a denied file exporting `delete`, used as `api.delete()`': [ - 'vite dev', - 'vite build', - 'rsbuild build', - ], - // Bug: same as "a namespace member read before the import" (the mock - // edge of a denied specifier only exports the members read after the - // import). Rsbuild mocks the whole namespace and passes. - // Impact: the read gets `undefined` [db.query is not a function; Vite - // build: (void 0) is not a function]. - 'a namespace member read in a function declared before the import': [ - 'vite dev', - 'vite build', - ], - // NEW bug (Rsbuild), not the internal pin's cause (Vite passes): a - // mock-mode Rsbuild production build ships the real value of an enum - // member of a denied file [read 'MARK_SECRET,[import-protection mock]', - // the bundle contains MARK_SECRET]; the namespace member is mocked. - // Cause: Rsbuild's production SWC config collects exported enums - // (`collectTypeScriptInfo.exportedEnum: isProd`), and Rspack inlines - // `Color.Red` into the importer (`String("MARK_SECRET")`) from the - // denied file's own source; the import-protection loader's mock (which - // does export `Color`) is never read. A scratch build with - // `exportedEnum: false` mocks it. - // Impact: server-only values declared as enum members reach the client - // bundle in mock mode (`behavior: { build: 'mock' }`), with only a - // warning. - 'a denied file exporting an enum and a namespace': ['rsbuild build'], - } - - const twinTest = (channel: string, name: string) => - (failsOn[name] ?? []).includes(channel) ? test.fails : test - - for (const [name, twin] of Object.entries(twins)) { - const { files, importProtection = {}, expected = mockText } = twin - twinTest('vite dev', name)( - `vite dev: ${name}`, - async () => { - const server = await startProtectedDevServer(files, importProtection) - // The mock reports each use in the browser console. - vi.spyOn(console, 'error').mockImplementation(() => {}) - const module = await evaluateServed(server, '/src/entry.ts', { - skip: /^\/@(vite|react-refresh)\b/, - }) - expect(String(module.read())).toBe(expected) - }, - 30_000, - ) - for (const bundler of startBundlers) { - twinTest(`${bundler} build`, name)( - `${bundler} build, mock mode: ${name}`, - async () => { - const { client } = await buildStart({ - bundler, - files, - entry: 'src/entry.ts', - start: { - importProtection: { ...importProtection, behavior: 'mock' }, - }, - }) - expect({ - read: (await client.run(`return String(entry.read())`)).result, - hasRealCode: client.where('MARK_SECRET') !== 'nowhere', - }).toEqual({ read: expected, hasRealCode: false }) - }, - 60_000, - ) - } - } -}) diff --git a/packages/start-plugin-core/tests/build-harness.test.ts b/packages/start-plugin-core/tests/integration/build-harness.test.ts similarity index 99% rename from packages/start-plugin-core/tests/build-harness.test.ts rename to packages/start-plugin-core/tests/integration/build-harness.test.ts index 3de6485399e..f069563a418 100644 --- a/packages/start-plugin-core/tests/build-harness.test.ts +++ b/packages/start-plugin-core/tests/integration/build-harness.test.ts @@ -14,7 +14,7 @@ import { requestsOfPrelude, routerRuntimeFiles, startBundlers, -} from './build-harness' +} from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/hydrate-build.test.ts b/packages/start-plugin-core/tests/integration/hydrate-build.test.ts similarity index 99% rename from packages/start-plugin-core/tests/hydrate-build.test.ts rename to packages/start-plugin-core/tests/integration/hydrate-build.test.ts index 05d12cff938..074595ec7b7 100644 --- a/packages/start-plugin-core/tests/hydrate-build.test.ts +++ b/packages/start-plugin-core/tests/integration/hydrate-build.test.ts @@ -17,13 +17,13 @@ import { reactStartPackage, routerRuntimeFiles, startBundlers, -} from './build-harness' -import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' -import { startDevServer } from './dev-server' -import { startRsbuildDevServer } from './rsbuild-dev-server' -import { getChunkIds } from './regression-helpers' +} from '../build-harness' +import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' +import { startDevServer } from '../dev-server' +import { startRsbuildDevServer } from '../rsbuild-dev-server' +import { getChunkIds } from '../regression-helpers' import type { BuildResult } from '@tanstack/compiler-test-utils/build-harness' -import type { StartBuild } from './build-harness' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/importProtection/build-options.test.ts b/packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts similarity index 98% rename from packages/start-plugin-core/tests/importProtection/build-options.test.ts rename to packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts index 566647dea1e..d3140ec9166 100644 --- a/packages/start-plugin-core/tests/importProtection/build-options.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts @@ -6,16 +6,16 @@ * were parsed but never exercised for effect). */ import { describe, expect, test } from 'vitest' -import { buildStart, startBundlers } from '../build-harness' +import { buildStart, startBundlers } from '../../build-harness' import { distinct, expectMarkerViolation, rejection, reportedViolations, violationTexts, -} from './start-apps' -import { violationReports } from './violation-report' -import type { ImportProtectionOptions } from '../../src/schema' +} from '../../importProtection/start-apps' +import { violationReports } from '../../importProtection/violation-report' +import type { ImportProtectionOptions } from '../../../src/schema' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/importProtection/build.test.ts b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts similarity index 99% rename from packages/start-plugin-core/tests/importProtection/build.test.ts rename to packages/start-plugin-core/tests/integration/importProtection/build.test.ts index b6d16fb7f5f..de2d0da4229 100644 --- a/packages/start-plugin-core/tests/importProtection/build.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts @@ -4,14 +4,18 @@ * Import protection checks the modules of the source directory (`src`). */ import { describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import { + buildStart, + routerRuntimeFiles, + startBundlers, +} from '../../build-harness' import { expectMarkerViolation, rejection, reportedViolations, violationTexts, -} from './start-apps' -import type { TanStackStartInputConfig } from '../../src/schema' +} from '../../importProtection/start-apps' +import type { TanStackStartInputConfig } from '../../../src/schema' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/importProtection/dev.test.ts b/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts similarity index 99% rename from packages/start-plugin-core/tests/importProtection/dev.test.ts rename to packages/start-plugin-core/tests/integration/importProtection/dev.test.ts index a99bfaf5f83..0ec214b2d6f 100644 --- a/packages/start-plugin-core/tests/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts @@ -9,7 +9,7 @@ import path from 'node:path' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterEach, describe, expect, test, vi } from 'vitest' -import { startDevServer } from '../dev-server' +import { startDevServer } from '../../dev-server' import { distinct, factoryTopLevelCall, @@ -19,11 +19,11 @@ import { reportedViolations, serverFnFactoryApp, startProtectedDevServer, -} from './start-apps' -import { violationReports } from './violation-report' -import type { ViolationReport } from './violation-report' -import type { ImportProtectionOptions } from '../../src/schema' -import type { ViolationInfo } from '../../src/import-protection/trace' +} from '../../importProtection/start-apps' +import { violationReports } from '../../importProtection/violation-report' +import type { ViolationReport } from '../../importProtection/violation-report' +import type { ImportProtectionOptions } from '../../../src/schema' +import type { ViolationInfo } from '../../../src/import-protection/trace' import type { Plugin } from 'vite' const timeout = 30_000 diff --git a/packages/start-plugin-core/tests/importProtection/exemptions.test.ts b/packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts similarity index 98% rename from packages/start-plugin-core/tests/importProtection/exemptions.test.ts rename to packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts index 10754eaff67..19a7714d535 100644 --- a/packages/start-plugin-core/tests/importProtection/exemptions.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts @@ -10,10 +10,13 @@ */ import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterAll, afterEach, describe, expect, test, vi } from 'vitest' -import { buildStart, startBundlers } from '../build-harness' -import { reportedViolations, startProtectedDevServer } from './start-apps' -import type { StartBuild, StartBundler } from '../build-harness' -import type { ImportProtectionOptions } from '../../src/schema' +import { buildStart, startBundlers } from '../../build-harness' +import { + reportedViolations, + startProtectedDevServer, +} from '../../importProtection/start-apps' +import type { StartBuild, StartBundler } from '../../build-harness' +import type { ImportProtectionOptions } from '../../../src/schema' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts new file mode 100644 index 00000000000..97de98b81fa --- /dev/null +++ b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts @@ -0,0 +1,695 @@ +/** + * Known import-protection bugs. Each test asserts correct behaviour for a bug + * on main and is marked .fails; remove .fails when the bug is fixed. + */ +import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { buildStart, startBundlers } from '../../build-harness' +import { + runRsbuildClient, + startRsbuildDevServer, +} from '../../rsbuild-dev-server' +import { + factoryTopLevelCall, + reactRuntimeFiles, + rejection, + reportedViolations, + serverFnFactoryApp, + startProtectedDevServer, +} from '../../importProtection/start-apps' +import type { RsbuildConfig } from '@rsbuild/core' +import type { ImportProtectionOptions } from '../../../src/schema' + +/** + * Start's import protection options of a build that denies `files` to the + * client and fails on a violation. + */ +function denyToClient(files: Array) { + return { importProtection: { behavior: 'error', client: { files } } } as const +} + +/** Same as `denyToClient`, mocking the denied modules instead. */ +function mockToClient(files: Array) { + return { importProtection: { behavior: 'mock', client: { files } } } as const +} + +/** A denied module whose code marks the bundle it is in. */ +const corpusModule = `export const corpus = () => 'MARK_CORPUS' + Math.random()\n` + +/** + * A client entry re-exporting a server fn of `layout.ts`, which also + * exports a function using the denied-to-be `corpus.ts` that nothing calls. + * Import protection checks the modules of the source directory (`src`). + */ +const droppedCorpusFiles = { + 'src/corpus.ts': corpusModule, + 'src/layout.ts': `import { createServerFn } from '@tanstack/react-start' +import { corpus } from './corpus' +export const size = () => corpus() +export const nav = createServerFn().handler(async () => 1) +`, + 'src/entry.ts': `export { nav } from './layout'\n`, +} + +describe.each(startBundlers)('%s build', (bundler) => { + // Control for the pin below (same harness): a denied module whose code the + // client bundle contains fails the build. + test('a denied module the client uses fails the build', async () => { + await expect( + buildStart({ + bundler, + files: { + 'src/corpus.ts': corpusModule, + 'src/entry.ts': `import { corpus } from './corpus'\nexport const read = () => corpus()\n`, + }, + entry: 'src/entry.ts', + start: denyToClient(['**/corpus.ts']), + }), + ).rejects.toThrow(/Import denied in client environment/) + }, 60_000) + + // Control for the pin below (same files, no import protection): the client + // bundle does not contain the module's code. + test('without import protection, the client bundle drops the unused module', async () => { + const { client } = await buildStart({ + bundler, + files: droppedCorpusFiles, + entry: 'src/entry.ts', + start: { importProtection: { enabled: false } }, + }) + expect(client.where('MARK_CORPUS')).toBe('nowhere') + }, 60_000) + + // Control for the pins below (same harness and files, import protection + // on): the build succeeds in mock mode, without the module's code. + test('in mock mode, the client bundle drops the unused module', async () => { + const { client } = await buildStart({ + bundler, + files: droppedCorpusFiles, + entry: 'src/entry.ts', + start: mockToClient(['**/corpus.ts']), + }) + expect(client.where('MARK_CORPUS')).toBe('nowhere') + }, 60_000) + + // Bug: the build check counts a denied module as surviving when the + // compilation contains it, whether or not the client uses it. Vite: the + // mock that replaces the module is never tree-shaken, because its exports + // read members of the mock (`export const corpus = mock.corpus`, + // import-protection/virtualModules.ts), which the bundler keeps (with + // side-effect-free exports, a scratch run passes this pin on Vite). + // Rsbuild: the report step walks every module of the module graph + // (connections are not filtered by whether they are used, + // INTERNALS-import-protection.md). + // Impact: a denied module that a client-reachable module imports, but that + // the client never uses, still fails the build. + // Source: issue #8101 (closed without a reproduction); gap IP6b + // (gaps/start-env.md). + test.fails( + 'a denied module the client bundle drops does not fail the build', + async () => { + const { client } = await buildStart({ + bundler, + files: droppedCorpusFiles, + entry: 'src/entry.ts', + start: denyToClient(['**/corpus.ts']), + }) + expect(client.where('MARK_CORPUS')).toBe('nowhere') + }, + 60_000, + ) + + // Bug: same as above, in mock mode: the mock of a denied module the client + // never uses stays in the client bundle, and the build warns about it. + // The mock module is marked side-effect free (`@__NO_SIDE_EFFECTS__`, + // `@__PURE__`), but its exports read members of the mock. + // Impact: mock-mode builds ship mock code (a Proxy runtime) for unused + // imports, and report violations the client never reaches. + // Source: weak check virtualModules.test.ts "returns mock code" (asserted + // the purity annotations as text), oracle-gaps REPORT.md "Weak checks". + test.fails( + 'in mock mode, an unused denied module leaves no mock in the client bundle and no warning', + async () => { + const { client } = await buildStart({ + bundler, + files: droppedCorpusFiles, + entry: 'src/entry.ts', + start: mockToClient(['**/corpus.ts']), + }) + expect({ + mock: client.where('[import-protection mock]'), + warnings: client.warnings.filter((warning) => + warning.includes('[import-protection]'), + ), + }).toEqual({ mock: 'nowhere', warnings: [] }) + }, + 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's modules reports, or `'loaded'`. (Not + * the modules of `@tanstack/react-start/server`, which imports Start's + * entries, which only Start's whole plugin provides.) + */ + async function loadOutcome(files: Record) { + const server = await startProtectedDevServer(files, { behavior: 'error' }) + return server.load('/src/entry.ts', { skip: /^\/@(vite|fs)\// }).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, + ) + + // Bug: same cause as the pin above, one module further: when the Start + // compiler's lookup of a server fn factory module resolves the module's + // imports, import protection records each imported module as a lookup + // module too (vite/import-protection-plugin/plugin.ts `resolveId`, + // "Propagate pre-transform status transitively"), by its path, so the + // client's own transform of that module is silenced as well. + // Impact: in dev with behavior "error", any module a server fn factory + // module imports gets the real denied module when it calls it at the top + // level, without any error (the control above fails to load). + // Source: mutation run R4 (spc:5168, 5169). + test.fails( + 'a module a server fn factory module imports, calling a denied import at the top level, fails to load', + async () => { + const app = topLevelCallApp('src/other.ts') + app['src/factory.ts'] += `export { request } from './other'\n` + app['src/entry.ts'] = `export { fn } from './usage'\n` + expect(await loadOutcome(app)).toEqual(['client src/other.ts']) + }, + 30_000, + ) +}) + +describe('Rsbuild dev', () => { + /** A module type another loader compiles to JS (a stand-in for `.mdx`). */ + const pageModules: RsbuildConfig = { + tools: { + rspack: { + module: { rules: [{ test: /\.page$/, type: 'javascript/auto' }] }, + }, + }, + } + + // Control for the pins below (same config and import protection): a + // module of that type is compiled, and its code runs in the client. + test('a module of another type without a denied import runs in the client', async () => { + const server = await startRsbuildDevServer({ + importProtection: { + behavior: 'error', + client: { specifiers: ['db-lib'] }, + }, + config: pageModules, + files: { + 'src/home.page': `export const page = () => 'MARK_PAGE'\n`, + 'src/entry.ts': `import { page } from './home.page'\nconsole.log(page())\n`, + }, + }) + expect(server.errors).toEqual([]) + expect(await runRsbuildClient(server)).toEqual(['MARK_PAGE']) + }, 30_000) + + // 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: string; files: Record }>([ + { + 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, + ) + + // In Rsbuild dev, an error thrown by `onViolation` (awaited in the + // `processAssets` hook, rsbuild/import-protection.ts `reportViolation`) + // fails the compile: Rspack gives up on it (`compiler.hooks.failed`, no + // `done`) and Rsbuild logs the error, like a build fails with it. + // Source: mutation run R3, ip-rsbuild (REPORT §6 #23). This was a pin + // ("never reported") while the dev harness only waited for `done`; the + // harness now fails the compile wait with the error Rspack reports + // (mutation run R3c). + test('an onViolation that throws ends the compile with its error', async () => { + const outcome = await startRsbuildDevServer({ + importProtection: { + onViolation: () => { + throw new Error('MARK_THROWN') + }, + }, + // Only a guard: the failed compile ends the wait (a slow first + // compile on a loaded machine must not fail the test). + compileTimeout: 10_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) +}) + +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, + ) +}) + +/** + * App-level twins of the pins above on internal functions (rewrite and mock + * modules): a real app with each input, on a Vite dev server (mock mode, the + * dev default) and in mock-mode builds. `src/entry.ts` exports `read()`; a + * use of a denied import must read the mock, whose text is `expected` + * unless a row says otherwise, and the app must load or build. + * Source: audit F, missing coverage 1. + */ +describe('app-level twins of the mock pins', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + /** A dependency `name` exporting `query`, denied by `client.specifiers`. */ + const deniedDependency = (name: string) => ({ + [`node_modules/${name}/package.json`]: JSON.stringify({ + name, + type: 'module', + exports: { '.': './index.js' }, + }), + [`node_modules/${name}/index.js`]: `export const query = () => 'MARK_SECRET'\n`, + }) + + const mockText = '[import-protection mock]' + + interface TwinCase { + files: Record + importProtection?: ImportProtectionOptions + /** What `String(entry.read())` returns (default: the mock's text). */ + expected?: string + } + + const twins: Record = { + // Twin of "a denied namespace re-export keeps its exported name". + 'a module re-exporting a denied file as a namespace': { + files: { + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/barrel.ts': `export * as secrets from './secret.server'\n`, + 'src/entry.ts': `import { secrets } from './barrel' +export const read = () => secrets.secret() +`, + }, + }, + // Twin of "a namespace re-export" (the mock of a denied file). + 'a denied file re-exporting a namespace': { + files: { + 'src/helpers.ts': `export const format = () => 'MARK_SECRET'\n`, + 'src/lib.server.ts': `export * as helpers from './helpers'\n`, + 'src/entry.ts': `import { helpers } from './lib.server' +export const read = () => helpers.format() +`, + }, + }, + // Twin of "the mock of a denied file exporting `delete` exports it" and + // "a denied import used as `api.delete()` evaluates to a mock". + 'a denied file exporting `delete`, used as `api.delete()`': { + files: { + 'src/api.server.ts': `const remove = () => 'MARK_SECRET' +export { remove as delete } +`, + 'src/entry.ts': `import * as api from './api.server' +export const read = () => api.delete() +`, + }, + }, + // Twin of "a JSX pragma that leads a denied import stays in effect": the + // page renders with the runtime its pragma names. + 'a JSX pragma leading a denied import': { + files: { + ...reactRuntimeFiles, + 'node_modules/custom-jsx/package.json': JSON.stringify({ + name: 'custom-jsx', + type: 'module', + exports: { + './jsx-runtime': './jsx-runtime.js', + './jsx-dev-runtime': './jsx-runtime.js', + }, + }), + 'node_modules/custom-jsx/jsx-runtime.js': `export const Fragment = Symbol('Fragment') +export const jsx = () => 'MARK_CUSTOM_JSX' +export const jsxs = jsx +export const jsxDEV = jsx +`, + 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, + 'src/page.tsx': `/** @jsxImportSource custom-jsx */ +import { secret } from './secret.server' +export const Page = () =>
    {String(secret())}
    +`, + 'src/entry.ts': `import { Page } from './page' +export const read = () => Page() +`, + }, + expected: 'MARK_CUSTOM_JSX', + }, + // Twin of "a namespace member read before the import". + 'a namespace member read in a function declared before the import': { + files: { + ...deniedDependency('db-lib'), + 'src/entry.ts': `export function read() { + return db.query() +} +import * as db from 'db-lib' +`, + }, + importProtection: { client: { specifiers: ['db-lib'] } }, + }, + // Twin of "an enum and a namespace" (the mock of a denied file). + 'a denied file exporting an enum and a namespace': { + files: { + 'src/model.server.ts': `export enum Color { + Red = 'MARK_SECRET', +} +export namespace Util { + export const x = 'MARK_SECRET' +} +`, + 'src/entry.ts': `import { Color, Util } from './model.server' +export const read = () => [String(Color.Red), String(Util.x)].join() +`, + }, + expected: `${mockText},${mockText}`, + }, + } + + /** + * The channels on which main fails each twin (`.fails`, red reason in + * brackets). The JSX pragma twin passes everywhere: with the default JSX + * pipeline, JSX is compiled before import protection rewrites the import + * (the internal pin needs a JSX plugin that runs after the rewrite). + */ + const failsOn: Record> = { + // Bug: same as "a denied namespace re-export keeps its exported name" + // (the dev rewrite of the importer drops `export * as ns`). + // Impact: in dev, the page fails to load [SyntaxError: '/src/barrel.ts' + // does not provide an export named 'secrets']. Builds mock the denied + // file itself and pass. + 'a module re-exporting a denied file as a namespace': ['vite dev'], + // Bug: same as "a namespace re-export" (the self-contained mock of a + // denied file skips `export * as ns`). + // Impact: a mock-mode build fails [Vite MISSING_EXPORT, Rspack + // ESModulesLinkingError 'helpers']. Vite dev passes. + 'a denied file re-exporting a namespace': ['vite build', 'rsbuild build'], + // Bug: same as "the mock of a denied file exporting `delete` exports it" + // (`export const delete = mock.delete` in every mock module). + // Impact: the dev page and mock-mode builds fail with a parse error + // [Unexpected token 'delete'; PARSE_ERROR; Expected ident]. + 'a denied file exporting `delete`, used as `api.delete()`': [ + 'vite dev', + 'vite build', + 'rsbuild build', + ], + // Bug: same as "a namespace member read before the import" (the mock + // edge of a denied specifier only exports the members read after the + // import). Rsbuild mocks the whole namespace and passes. + // Impact: the read gets `undefined` [db.query is not a function; Vite + // build: (void 0) is not a function]. + 'a namespace member read in a function declared before the import': [ + 'vite dev', + 'vite build', + ], + // NEW bug (Rsbuild), not the internal pin's cause (Vite passes): a + // mock-mode Rsbuild production build ships the real value of an enum + // member of a denied file [read 'MARK_SECRET,[import-protection mock]', + // the bundle contains MARK_SECRET]; the namespace member is mocked. + // Cause: Rsbuild's production SWC config collects exported enums + // (`collectTypeScriptInfo.exportedEnum: isProd`), and Rspack inlines + // `Color.Red` into the importer (`String("MARK_SECRET")`) from the + // denied file's own source; the import-protection loader's mock (which + // does export `Color`) is never read. A scratch build with + // `exportedEnum: false` mocks it. + // Impact: server-only values declared as enum members reach the client + // bundle in mock mode (`behavior: { build: 'mock' }`), with only a + // warning. + 'a denied file exporting an enum and a namespace': ['rsbuild build'], + } + + const twinTest = (channel: string, name: string) => + (failsOn[name] ?? []).includes(channel) ? test.fails : test + + for (const [name, twin] of Object.entries(twins)) { + const { files, importProtection = {}, expected = mockText } = twin + twinTest('vite dev', name)( + `vite dev: ${name}`, + async () => { + const server = await startProtectedDevServer(files, importProtection) + // The mock reports each use in the browser console. + vi.spyOn(console, 'error').mockImplementation(() => {}) + const module = await evaluateServed(server, '/src/entry.ts', { + skip: /^\/@(vite|react-refresh)\b/, + }) + expect(String(module.read())).toBe(expected) + }, + 30_000, + ) + for (const bundler of startBundlers) { + twinTest(`${bundler} build`, name)( + `${bundler} build, mock mode: ${name}`, + async () => { + const { client } = await buildStart({ + bundler, + files, + entry: 'src/entry.ts', + start: { + importProtection: { ...importProtection, behavior: 'mock' }, + }, + }) + expect({ + read: (await client.run(`return String(entry.read())`)).result, + hasRealCode: client.where('MARK_SECRET') !== 'nowhere', + }).toEqual({ read: expected, hasRealCode: false }) + }, + 60_000, + ) + } + } +}) diff --git a/packages/start-plugin-core/tests/importProtection/reported-location.test.ts b/packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts similarity index 98% rename from packages/start-plugin-core/tests/importProtection/reported-location.test.ts rename to packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts index 15b343ff03d..7105d58e3b0 100644 --- a/packages/start-plugin-core/tests/importProtection/reported-location.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts @@ -9,8 +9,8 @@ * Rsbuild: original source first), so each case runs on both. */ import { describe, expect, test } from 'vitest' -import { buildStart, startBundlers } from '../build-harness' -import { startRsbuildDevServer } from '../rsbuild-dev-server' +import { buildStart, startBundlers } from '../../build-harness' +import { startRsbuildDevServer } from '../../rsbuild-dev-server' import { distinct, locationIn, @@ -18,10 +18,13 @@ import { rejection, reportedViolations, startProtectedDevServer, -} from './start-apps' -import { violationReports } from './violation-report' -import type { StartBuildOptions, StartBundler } from '../build-harness' -import type { ReportedTraceStep, ViolationReport } from './violation-report' +} from '../../importProtection/start-apps' +import { violationReports } from '../../importProtection/violation-report' +import type { StartBuildOptions, StartBundler } from '../../build-harness' +import type { + ReportedTraceStep, + ViolationReport, +} from '../../importProtection/violation-report' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/known-bugs-dev-server.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts similarity index 98% rename from packages/start-plugin-core/tests/known-bugs-dev-server.test.ts rename to packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts index 3fec7ccf3e7..7adb411bec7 100644 --- a/packages/start-plugin-core/tests/known-bugs-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts @@ -7,16 +7,16 @@ import path from 'node:path' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { describe, expect, test } from 'vitest' -import { importProtectionPlugin } from '../src/vite/import-protection-plugin/plugin' -import { runInRequestContext } from './build-harness' +import { importProtectionPlugin } from '../../src/vite/import-protection-plugin/plugin' +import { runInRequestContext } from '../build-harness' import { callServerFnById, servedStartRuntime, startDevServer, startServerEntryFiles, -} from './dev-server' -import { isMock } from './importProtection/mock-helpers' -import type { ImportProtectionPluginOptions } from '../src/vite/import-protection-plugin/types' +} from '../dev-server' +import { isMock } from '../importProtection/mock-helpers' +import type { ImportProtectionPluginOptions } from '../../src/vite/import-protection-plugin/types' const timeout = 30_000 diff --git a/packages/start-plugin-core/tests/known-bugs-route-options.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts similarity index 99% rename from packages/start-plugin-core/tests/known-bugs-route-options.test.ts rename to packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts index 9d6b36ac74a..70c7b0193bd 100644 --- a/packages/start-plugin-core/tests/known-bugs-route-options.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts @@ -12,8 +12,8 @@ * pins are in outside-compiler-corpus/prerender-route-options.test.ts.) */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' -import type { StartBuild } from './build-harness' +import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts new file mode 100644 index 00000000000..dfbd0162948 --- /dev/null +++ b/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts @@ -0,0 +1,724 @@ +/** + * Known Start compiler bugs. Each test asserts correct behaviour for a bug on + * main and is marked .fails; remove .fails when the bug is fixed. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { createServer, isRunnableDevEnvironment } from 'vite' +import { describe, expect, test, vi } from 'vitest' +import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' +import { + buildStart, + callByIdExports, + callByIdPrelude, + linkStartRuntime, + startBundlers, +} from '../build-harness' +import { + callServerFnById, + servedStartRuntime, + startPlugin, + startPluginFiles, + startServerEntryFiles, +} from '../dev-server' +import { callerRpcId, misplacedServerFnRejection } from '../regression-helpers' +import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' +import type { ModuleStub } from '../regression-helpers' + +const head = `import { createServerFn } from '@tanstack/react-start'\n` + +describe.each(startBundlers)('%s build', (bundler) => { + const timeout = 60_000 + + /** A server-only module whose top-level code marks the bundle it is in. */ + const dbModule = `globalThis.MARK_DB_LOADED = true +export const db = { users: () => 'users' } +` + + // Control for the pin below (same harness). + test( + 'client: the server-only module of a function only server code uses is not bundled', + async () => { + const { client } = await buildStart({ + bundler, + files: { + 'db.server.ts': dbModule, + 'fns.ts': `${head}import { db } from './db.server' +function find() { return db.users() } +export const fn = createServerFn().handler(async () => find()) +`, + 'entry.ts': `export { fn } from './fns'\n`, + }, + }) + expect(client.where('MARK_DB_LOADED')).toBe('nowhere') + }, + timeout, + ) + + // Bug: the client output keeps a class only server code uses, with its + // server-only import (compiler pin "client: a class used by a server fn + // handler does not load its server-only import" above). Tree-shaking drops + // the unused class but keeps the import, so the server-only module's + // top-level code is bundled and runs in the browser. + // Impact: server-only modules (database clients, SDKs initialised with + // secrets) ship to the client and run their top-level code there. + // Source: gap D2 (gaps/dead-code.md), build canary. + test.fails( + 'client: the server-only module of a class only server code uses is not bundled', + async () => { + const { client } = await buildStart({ + bundler, + files: { + 'db.server.ts': dbModule, + 'fns.ts': `${head}import { db } from './db.server' +class Repo { get() { return db.users() } } +export const fn = createServerFn().handler(async () => new Repo().get()) +`, + 'entry.ts': `export { fn } from './fns'\n`, + }, + }) + expect(client.where('MARK_DB_LOADED')).toBe('nowhere') + }, + timeout, + ) + + /** A server fn module whose `auth` import a type still mentions. */ + const typeofFiles = (verbatimModuleSyntax: boolean) => ({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { verbatimModuleSyntax }, + }), + 'auth.ts': `globalThis.MARK_AUTH_LOADED = true +export const auth = { get: () => 'session', session: 'session' } +`, + 'fns.ts': `${head}import { auth } from './auth' +type Session = typeof auth.session +export const fn = createServerFn().handler(async () => auth.get() as Session) +`, + 'entry.ts': `export { fn } from './fns'\n`, + }) + + // Control for the pin below (same harness): without verbatimModuleSyntax, + // TypeScript erases the import that only a type mentions. + test( + 'client: a server-only module a type mentions is not bundled', + async () => { + const { client } = await buildStart({ + bundler, + files: typeofFiles(false), + }) + expect(client.where('MARK_AUTH_LOADED')).toBe('nowhere') + }, + timeout, + ) + + // Bug (Vite): the client output keeps the server-only import that a + // `typeof` type query still mentions (compiler pin "client: a server-only + // import last mentioned by a type alias ..." above); with + // `verbatimModuleSyntax`, Vite's TypeScript transform keeps it. Rsbuild's + // transform of the same app drops it. + // Impact: the server-only module's top-level code is bundled and runs in + // the browser. + // Source: gap D4 (gaps/dead-code.md), build canary. + ;(bundler === 'vite' ? test.fails : test)( + 'client: a server-only module a type mentions is not bundled with verbatimModuleSyntax', + async () => { + const { client } = await buildStart({ bundler, files: typeofFiles(true) }) + expect(client.where('MARK_AUTH_LOADED')).toBe('nowhere') + }, + timeout, + ) + + // Bug: with SSR as the server fn provider, the SSR caller module and the + // provider module (`?tss-serverfn-split`) are two module instances of the + // same file in the SSR bundle, and the provider repeats the file's + // top-level code (exports-and-directives.test.ts "side-effect imports and + // top-level statements stay in every output" asserts this). + // Impact: in one server process, the file's top-level side effects run + // twice, handlers and SSR code of the file see different module state, and + // `instanceof` fails for a class of the file passed to a server fn. + // Source: gap SF17 (gaps/start-env.md), PR #8656 (class identity). + test.fails( + 'SSR: a server fn module is initialised once and shares its state with SSR code', + async () => { + const { ssr } = await buildStart({ + bundler, + files: { + 'fns.ts': `${head}globalThis.initialised = (globalThis.initialised ?? 0) + 1 +let count = 0 +export class Session {} +export const readCount = () => count +export const fn = createServerFn().handler(async ({ data }) => { + count++ + return data instanceof Session +}) +`, + 'entry.ts': `export { fn, readCount, Session } from './fns'\n`, + }, + }) + const { result } = + await ssr.run(`const isSession = await entry.fn({ data: new entry.Session() }) +return { initialised: globalThis.initialised, isSession, count: entry.readCount() }`) + expect(result).toEqual({ initialised: 1, isSession: true, count: 1 }) + }, + timeout, + ) + + /** + * An app whose client imports one server fn (`clientFn`) and whose server + * entry imports another one only server code uses (`serverOnlyFn`), with + * the server fn resolver the server-fn HTTP handler calls. The resolver is + * imported by the server-only module, so the SSR build compiles that + * module (and reports its server fn) before it generates the resolver. + */ + const clientAccessFiles = { + 'client-fns.ts': `${head}export const clientFn = createServerFn().handler(async () => 'client fn') +`, + 'server-only-fns.ts': `${head}export const serverOnlyFn = createServerFn().handler(async () => 'server-only fn') +export { getServerFnById } from '#tanstack-start-server-fn-resolver' +`, + 'client-entry.ts': `export { clientFn } from './client-fns' +`, + 'ssr-entry.ts': `export { serverOnlyFn, getServerFnById } from './server-only-fns' +${callByIdExports}`, + } + + /** + * Builds `clientAccessFiles` and, in the SSR build, sends a request for + * each server fn id to Start's server entry, as a page does + * (`callByIdPrelude`), and calls the server-only one from SSR code. + */ + async function callClientAccess() { + const { client, ssr } = await buildStart({ + bundler, + files: clientAccessFiles, + entry: { client: 'client-entry.ts', ssr: 'ssr-entry.ts' }, + }) + expect(client.where("'server-only fn'")).toBe('nowhere') + const clientFnId = ( + await client.run(`return entry.clientFn.serverFnMeta.id`) + ).result + const { result } = + await ssr.run(`${callByIdPrelude}const settle = async (run) => { + try { + return await run() + } catch { + return 'rejected' + } +} +const overHttp = (id) => + settle(async () => callById(id)) +return { + clientFn: await overHttp(${JSON.stringify(clientFnId)}), + serverOnlyFnOverHttp: await overHttp(entry.serverOnlyFn.serverFnMeta.id), + serverOnlyFnFromSsr: await settle(() => entry.serverOnlyFn()), +}`) + return result + } + + // Control for the pin below (same harness). + test( + 'SSR: a server fn the client references is callable over HTTP, and a server-only one from SSR code', + async () => { + expect(await callClientAccess()).toMatchObject({ + clientFn: 'client fn', + serverOnlyFnFromSsr: 'server-only fn', + }) + }, + timeout, + ) + + // Bug: with SSR as the provider, the production resolver lists every + // server fn the builds found and never checks the caller's origin + // (`includeClientReferencedCheck` is off, and the flag it would check is + // true for every SSR-caller compile, handleCreateServerFn.ts + // `isClientReferenced`). + // Impact: a server fn only server code uses (an SSR loader helper, a + // server-only module) is callable over HTTP by anyone who learns its id, + // bypassing what the server code around it checks. + // Source: user ruling D6 (only server fns client code references are + // HTTP-callable); the RSC-provider variant is pinned in + // createServerFn/createServerFn.test.ts. + test.fails( + 'SSR: a server fn only server code references is not callable over HTTP', + async () => { + expect(await callClientAccess()).toEqual({ + clientFn: 'client fn', + serverOnlyFnOverHttp: 'rejected', + serverOnlyFnFromSsr: 'server-only fn', + }) + }, + timeout, + ) + + // Bug (Vite): the production server fn resolver is generated when the SSR + // build first loads it, from the server fns found so far. A server fn that + // only another server fn's handler (or a middleware `.server()`) calls is + // found later, when the SSR build compiles the provider that imports it, so + // it is missing from the resolver. Rsbuild rebuilds the resolver after the + // SSR compilation found every server fn. + // Impact: in production, calling it throws "Server function info not + // found" (works in dev). + // Source: issue #7213. + ;(bundler === 'vite' ? test.fails : test)( + 'SSR: a server fn that only another server fn calls is callable', + async () => { + const { ssr } = await buildStart({ + bundler, + files: { + 'inner.ts': `${head}export const inner = createServerFn().handler(async () => 'inner') +`, + 'outer.ts': `${head}import { inner } from './inner' +export const outer = createServerFn().handler(async () => 'outer:' + (await inner())) +`, + 'entry.ts': `export { outer } from './outer'\n`, + }, + }) + expect((await ssr.run(`return await entry.outer()`)).result).toBe( + 'outer:inner', + ) + }, + timeout, + ) + + // Control: start-build-env-separation.test.ts "a library's server fn" + // (a library exporting a top-level server fn). + // Bug: a library in node_modules that creates server fns for its users + // (`createLoggedServerFn(handler)` returning + // `createServerFn().handler(handler)`) is compiled like any module, but + // the user's module only calls the library's function, so the compiler + // never sees a server fn there, and nothing warns. + // Impact: the handler the user passes, and what it imports, ship to the + // client bundle and run there instead of on the server. + // Source: lost-cases-campaign item 13 (b); user ruling D13 (a server fn + // created inside a function is supported, or rejected clearly). + test.fails( + 'a server fn created by a library function: the handler is not in the client bundle, or the build fails clearly', + async () => { + const outcome = await buildStart({ + bundler, + // The app depends on the library, as an installed one. + packageJson: { + name: 'app', + private: true, + type: 'module', + dependencies: { 'wrapper-lib': '*' }, + }, + files: { + 'node_modules/wrapper-lib/package.json': JSON.stringify({ + name: 'wrapper-lib', + type: 'module', + exports: './index.js', + peerDependencies: { '@tanstack/react-start': '*' }, + }), + 'node_modules/wrapper-lib/index.js': `import { createServerFn } from '@tanstack/react-start' +export function createLoggedServerFn(handler) { + return createServerFn().handler(handler) +} +`, + 'src/db.ts': `export const db = { secret: () => 'MARK_WRAPPED_HANDLER' }\n`, + 'src/fns.ts': `import { createLoggedServerFn } from 'wrapper-lib' +import { db } from './db' +export const fn = createLoggedServerFn(async () => db.secret()) +`, + 'src/entry.ts': `export { fn } from './fns'\n`, + }, + entry: 'src/entry.ts', + }).then( + async ({ client, ssr }) => ({ + client: client.where('MARK_WRAPPED_HANDLER'), + ssr: (await ssr.run(`return await entry.fn()`)).result, + }), + (error: unknown) => ({ error: String(error) }), + ) + if ('error' in outcome) { + expect(outcome.error).toMatch(misplacedServerFnRejection) + expect(outcome.error).not.toMatch( + /Expected createServerFn declaration in a statement list/, + ) + } else { + expect(outcome).toEqual({ + client: 'nowhere', + ssr: 'MARK_WRAPPED_HANDLER', + }) + } + }, + timeout, + ) + + // Bug (Vite): Start's Vite plugin looks for the Start libraries it bundles + // in SSR (`crawlFrameworkPkgs`: dependencies with a peer dependency on + // Start) in the package.json of the working directory (`root: + // process.cwd()`), not of Vite's root, which the plugin uses for its own + // root a few lines above. + // Source: src/vite/plugin.ts:143 (Vite's root: :96); found when the build + // harness ran Start's real runtime (it ran Vite builds outside the app). + // Impact: `vite build ` run from another directory (a monorepo root, + // a script) leaves a Start library external in the SSR build: Node loads + // it uncompiled, and an SSR call of its server fn returns undefined + // instead of the handler's result. + // Control: start-build-env-separation.test.ts "a library's server fn" + // (the same library, built in the app's directory). + ;(bundler === 'vite' ? test.fails : test)( + "a build run from another directory: a library's server fn runs in the SSR build", + async () => { + const { ssr } = await buildStart({ + bundler, + // The directory of this test file, whose package.json is not the + // app's. + cwd: __dirname, + packageJson: { + name: 'app', + private: true, + type: 'module', + dependencies: { 'server-fn-lib': '*' }, + }, + files: { + 'node_modules/server-fn-lib/package.json': JSON.stringify({ + name: 'server-fn-lib', + type: 'module', + exports: './index.js', + peerDependencies: { '@tanstack/react-start': '*' }, + }), + 'node_modules/server-fn-lib/index.js': `import { createServerFn } from '@tanstack/react-start' +export const libFn = createServerFn().handler(async () => 'MARK_LIB_HANDLER') +`, + 'src/entry.ts': `export { libFn } from 'server-fn-lib'\n`, + }, + entry: 'src/entry.ts', + }) + expect((await ssr.run(`return await entry.libFn()`)).result).toBe( + 'MARK_LIB_HANDLER', + ) + }, + timeout, + ) + + // Bug (Rsbuild): in the SSR build, compiling a module with an isomorphic + // fn makes the Start compiler resolve the module's `createMiddleware` + // import (the server lookup does not know that factory). The Rsbuild + // compiler host resolves it through the loader context, whose resolver + // does not match the package's `import`-only exports (those of + // `@tanstack/react-start`): the build fails with + // `PackagePathNotExported(".")` (start-compiler-host.ts `resolveId`). + // Impact: an app with a middleware and an isomorphic fn in one module + // does not build with Rsbuild. + // Source: lost-cases item 3 (BS-6 / K-13). + ;(bundler === 'rsbuild' ? test.fails : test)( + 'a module with a middleware and an isomorphic fn builds, and its middleware runs on the server', + async () => { + const { client, ssr } = await buildStart({ + bundler, + files: { + 'src/both.ts': `import { createIsomorphicFn, createMiddleware } from '@tanstack/react-start' +export const middleware = createMiddleware().server(({ next }) => + next({ context: { marker: 'MARK_MIDDLEWARE_SERVER' } }), +) +export const isomorphic = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') +`, + 'src/entry.ts': `export { middleware, isomorphic } from './both'\n`, + }, + entry: 'src/entry.ts', + }) + expect(client.where('MARK_MIDDLEWARE_SERVER')).toBe('nowhere') + const { result } = await ssr.run(`return { + middleware: entry.middleware.options.server({ next: (options) => options }), + isomorphic: entry.isomorphic(), +}`) + expect(result).toEqual({ + middleware: { context: { marker: 'MARK_MIDDLEWARE_SERVER' } }, + isomorphic: 'server', + }) + }, + timeout, + ) +}) + +describe('Vite dev server', () => { + /** + * Resolves `'hang'` once `pending` has made no progress for `quietMs`: no + * plugin hook ran (`lastActivity()`) and the main thread stayed idle (a + * dependency optimizer bundling, or any other work the request waits for, + * keeps it busy). Resolves `'settled'` when `pending` settles first, and + * `'hang'` after `limitMs` at the latest. + */ + async function settledOrHung( + pending: Promise, + lastActivity: () => number, + { quietMs = 1_500, limitMs = 15_000 } = {}, + ): Promise<'settled' | 'hang'> { + let settled = false + pending.then( + () => (settled = true), + () => (settled = true), + ) + const started = performance.now() + let busySince = started + let previous = performance.eventLoopUtilization() + while (!settled) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (settled) { + break + } + const current = performance.eventLoopUtilization() + if ( + performance.eventLoopUtilization(current, previous).utilization > 0.05 + ) { + busySince = performance.now() + } + previous = current + const now = performance.now() + const quietFor = now - Math.max(lastActivity(), busySince) + if (quietFor >= quietMs || now - started >= limitMs) { + return 'hang' + } + } + return 'settled' + } + + /** + * Starts a Vite dev server (middleware mode, empty dependency cache) on a + * temporary project with the Start compiler, Start's real runtime + * (`linkStartRuntime`), `src/fn.ts` = `code` and `files`, and requests the + * client transform of `src/fn.ts`. Returns `'hang'` when the transform + * stops making progress before it finishes (`settledOrHung`); otherwise + * its code and the module evaluated with Start's real client runtime + * (`clientRuntime`) and `zod`. + */ + async function transformClientModule( + code: string, + files: Record = {}, + ) { + // Keep the temporary project inside the package so dependencies resolve. + const root = await mkdtemp(path.join(__dirname, '.dev-transform-')) + let server: Awaited> | undefined + let lastActivity = performance.now() + const active = () => { + lastActivity = performance.now() + return null + } + try { + await mkdir(path.join(root, 'src')) + for (const [file, content] of Object.entries({ + 'fn.ts': code, + ...files, + })) { + await writeFile(path.join(root, 'src', file), content) + } + await linkStartRuntime(root) + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + cacheDir: path.join(root, '.vite'), + appType: 'custom', + server: { middlewareMode: true, hmr: false, ws: false, watch: null }, + plugins: [ + { + name: 'record-activity', + enforce: 'pre', + resolveId: active, + load: active, + transform: active, + }, + startCompilerPlugin({ + framework: 'react', + environments: [ + { name: 'client', type: 'client' }, + { name: 'ssr', type: 'server' }, + ], + providerEnvName: 'ssr', + }), + ], + }) + const transformed = + server.environments.client.transformRequest('/src/fn.ts') + if ((await settledOrHung(transformed, () => lastActivity)) === 'hang') { + transformed.catch(() => {}) + return 'hang' as const + } + const served = (await transformed)!.code + const client = server.environments.client + const zod = (await import('zod')) as ModuleStub + const module = await createModuleGraph({ + modules: { '/src/fn.ts': served }, + // Project modules as the dev server serves them. + load: async (id) => + id.startsWith('/src/') + ? (await client.transformRequest(id))?.code + : null, + transform: (source) => source, + stubs: servedStartRuntime, + // `zod`, served pre-bundled (`/node_modules/.vite/deps/zod.js`) + fallbackStub: (specifier) => { + if (specifier.includes('/zod')) { + return zod + } + throw new Error(`no stub for ${specifier}`) + }, + }).import('/src/fn.ts') + return { code: served, module } + } finally { + await Promise.race([ + server?.close(), + new Promise((resolve) => setTimeout(resolve, 3_000)), + ]) + await rm(root, { recursive: true, force: true }) + } + } + + /** + * The client got `fn` as an RPC carrying its id, without the handler body + * (`MARK_HANDLER`). + */ + function expectClientRpc( + result: Awaited>, + ) { + expect(result).not.toBe('hang') + const { code, module } = result as Exclude + expect(code).not.toContain('MARK_HANDLER') + expect(callerRpcId(module.fn)).toEqual(expect.any(String)) + } + + // Control for the pin below (same harness). + test('transforms a client module with a server fn and a top-level call of a project function', async () => { + expectClientRpc( + await transformClientModule( + `${head}import { define } from './define' +export const schema = define() +export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, + { 'define.ts': `export const define = () => ({})` }, + ), + ) + }, 30_000) + + // Bug: next to a server fn, a top-level `const x = f()` may be a renamed or + // re-exported Start factory, so in dev the client compiler loads the module + // of `f` through the client environment, from inside the transform. For a + // pre-bundled dependency (here `zod`) with an empty dependency cache, that + // load waits for the dependency optimizer, which waits for this transform. + // Impact: with an empty dependency cache, such a module (a `z.object()` + // schema next to a server fn) never finishes transforming when it is + // requested as the dev server starts (`server.warmup`, the first request + // of a middleware-mode server, or `transformRequest()`), and the page hangs. + test.fails( + 'transforms a client module with a server fn and a top-level call of a pre-bundled dependency', + async () => { + expectClientRpc( + await transformClientModule( + `${head}import { z } from 'zod' +export const schema = z.object({}) +export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, + ), + ) + }, + 30_000, + ) + + /** + * Starts a Vite dev server (middleware mode) with Start's Vite plugin on + * `root` (which links Start's real runtime and has `startAppFiles`), with + * SSR as the server fn provider. + */ + function startServerFnDevServer(root: string) { + return createServer({ + root, + configFile: false, + logLevel: 'silent', + cacheDir: path.join(root, '.vite'), + appType: 'custom', + server: { middlewareMode: true, hmr: false, ws: false, watch: null }, + plugins: [startPlugin(root, {}, undefined)], + }) + } + + /** + * The files of Start's plugin (`startPluginFiles`), with Start's default + * server entry (`startServerEntryFiles`), which handles server fn calls. + */ + const startAppFiles = { ...startPluginFiles, ...startServerEntryFiles } + + /** + * In a temporary project with `src/fns.ts` = a `greet` server fn, reads the + * id of `greet` through a first dev server, then calls the id the way a + * page does (`callServerFnById`: Start's server entry handles it) on a dev + * server started with `fresh` (a new server, which has compiled nothing) + * or on the same server. Returns the result or the error message. + */ + async function callServerFnId(fresh: boolean) { + const root = await mkdtemp(path.join(__dirname, '.dev-server-fn-')) + const servers: Array>> = [] + try { + for (const [file, code] of Object.entries({ + ...startAppFiles, + 'src/fns.ts': `${head}export const greet = createServerFn().handler(() => 'hello')\n`, + })) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }) + await writeFile(path.join(root, file), code) + } + await linkStartRuntime(root) + /** Imports a module in the SSR environment of a dev server. */ + const importSsr = ( + server: Awaited>, + id: string, + ) => { + const ssr = server.environments.ssr + if (!ssr || !isRunnableDevEnvironment(ssr)) { + throw new Error('expected a runnable SSR environment') + } + return ssr.runner.import(id) + } + const first = await startServerFnDevServer(root) + servers.push(first) + const { greet } = await importSsr(first, '/src/fns.ts') + let server = first + if (fresh) { + await servers.pop()!.close() + server = await startServerFnDevServer(root) + servers.push(server) + } + const ssr = server + // Start's server logs the error of a failed call (a Vite plugin error + // here), which Vitest's console forwarding does not survive. + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + return await callServerFnById( + { import: (id) => importSsr(ssr, id) }, + greet.serverFnMeta.id, + ) + } catch (error) { + return `throws: ${(error as Error).message}` + } finally { + logged.mockRestore() + } + } finally { + for (const server of servers) { + await server.close() + } + await rm(root, { recursive: true, force: true }) + } + } + + // Control for the pin below (same harness). + test('calls a server fn id on the dev server that compiled its module', async () => { + expect(await callServerFnId(false)).toBe('hello') + }, 30_000) + + // Bug: when a server fn id is not registered yet, the dev validator + // (`validate-server-fn-id`) transforms the source file under + // `?server-fn-module-lookup`, the one id the compiling transform excludes, + // so the id is never registered and the validator rejects it. + // Impact: after a dev server restart, calling a server fn whose module the + // new server has not compiled yet (e.g. from a page loaded before the + // restart) fails with "Invalid server function ID" (500). + // Source: issue #8447, gap SF8 (gaps/start-env.md). + test.fails( + 'calls a server fn id on a fresh dev server that has not compiled its module', + async () => { + expect(await callServerFnId(true)).toBe('hello') + }, + 30_000, + ) +}) diff --git a/packages/start-plugin-core/tests/outside-compiler-corpus/import-protection-perf.test.ts b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/import-protection-perf.test.ts similarity index 95% rename from packages/start-plugin-core/tests/outside-compiler-corpus/import-protection-perf.test.ts rename to packages/start-plugin-core/tests/integration/outside-compiler-corpus/import-protection-perf.test.ts index 77a6a8297ce..959801ed57b 100644 --- a/packages/start-plugin-core/tests/outside-compiler-corpus/import-protection-perf.test.ts +++ b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/import-protection-perf.test.ts @@ -5,7 +5,7 @@ * outputs). Moved from importProtection/build-options.test.ts (audit F). */ import { afterEach, expect, test, vi } from 'vitest' -import { buildStart, startBundlers } from '../build-harness' +import { buildStart, startBundlers } from '../../build-harness' afterEach(() => { vi.unstubAllEnvs() diff --git a/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-manifest.test.ts b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts similarity index 96% rename from packages/start-plugin-core/tests/outside-compiler-corpus/start-build-manifest.test.ts rename to packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts index d02c1541463..18408834cfa 100644 --- a/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-manifest.test.ts +++ b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts @@ -9,8 +9,12 @@ 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' +import { + buildStart, + routerRuntimeFiles, + startBundlers, +} from '../../build-harness' +import type { StartBuild } from '../../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-options.test.ts similarity index 97% rename from packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts rename to packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-options.test.ts index a4fb6c81428..d4a430380bf 100644 --- a/packages/start-plugin-core/tests/outside-compiler-corpus/start-build-options.test.ts +++ b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-options.test.ts @@ -9,7 +9,11 @@ * Start's Vite and Rsbuild plugins (build-harness.ts). */ import { describe, expect, test } from 'vitest' -import { buildStart, requestsOfPrelude, startBundlers } from '../build-harness' +import { + buildStart, + requestsOfPrelude, + startBundlers, +} from '../../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts b/packages/start-plugin-core/tests/integration/rsbuild/dev-server.test.ts similarity index 99% rename from packages/start-plugin-core/tests/rsbuild/dev-server.test.ts rename to packages/start-plugin-core/tests/integration/rsbuild/dev-server.test.ts index 9815f265860..ab42983a8fc 100644 --- a/packages/start-plugin-core/tests/rsbuild/dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/rsbuild/dev-server.test.ts @@ -9,8 +9,8 @@ import { startRsbuildDevServer, startRsbuildStartDevServer, webGlobals, -} from '../rsbuild-dev-server' -import { requestsOfPrelude } from '../build-harness' +} from '../../rsbuild-dev-server' +import { requestsOfPrelude } from '../../build-harness' const timeout = 30_000 diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts similarity index 98% rename from packages/start-plugin-core/tests/rsbuild/import-protection.test.ts rename to packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts index 4ecf54b63a1..42a8ff7b8f8 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts @@ -1,18 +1,18 @@ import path from 'node:path' import { describe, expect, test } from 'vitest' -import { buildStart } from '../build-harness' +import { buildStart } from '../../build-harness' import { distinct, rejection, reportedViolations, -} from '../importProtection/start-apps' +} from '../../importProtection/start-apps' import { runRsbuildClient, startRsbuildDevServer, startRsbuildStartDevServer, -} from '../rsbuild-dev-server' -import type { ViolationInfo } from '../../src/import-protection/trace' -import type { ImportProtectionOptions } from '../../src/schema' +} from '../../rsbuild-dev-server' +import type { ViolationInfo } from '../../../src/import-protection/trace' +import type { ImportProtectionOptions } from '../../../src/schema' describe('Rsbuild dev: a client module importing a .server file', () => { const esm = `import { secret } from './secret.server.js' diff --git a/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts new file mode 100644 index 00000000000..425e2e97688 --- /dev/null +++ b/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts @@ -0,0 +1,573 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { promisify } from 'node:util' +import { rspack } from '@rsbuild/core' +import { join } from 'pathe' +import { describe, expect, test, vi } from 'vitest' +import { + normalizeRspackClientBuild, + registerClientBuildCapture, +} from '../../../src/rsbuild/normalized-client-build' +import { buildStartManifest } from '../../../src/start-manifest-plugin/manifestBuilder' +import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' +import type { GetConfigFn, NormalizedClientBuild } from '../../../src/types' + +async function buildClientFixture( + files: Record, + config: Rspack.Configuration = {}, + captureBuild: (compilation: Rspack.Compilation) => NormalizedClientBuild = ( + compilation, + ) => normalizeRspackClientBuild(compilation, true), +) { + const directory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-css-')) + let clientBuild: NormalizedClientBuild | undefined + const compiler = rspack({ + ...config, + context: directory, + mode: 'production', + cache: false, + entry: config.entry ?? { index: './index.js' }, + output: { + path: join(directory, 'dist'), + filename: '[name].js', + chunkFilename: '[name].js', + cssFilename: '[name].css', + cssChunkFilename: '[name].css', + assetModuleFilename: '[name][ext]', + publicPath: '', + }, + module: config.module ?? { + rules: [ + { test: /\.css$/, type: 'css' }, + { test: /\.svg$/, type: 'asset/resource' }, + ], + }, + optimization: { + minimize: false, + concatenateModules: false, + splitChunks: false, + ...config.optimization, + }, + plugins: [ + new rspack.experiments.VirtualModulesPlugin( + Object.fromEntries( + Object.entries(files).map(([name, source]) => [ + join(directory, name), + source, + ]), + ), + ), + { + apply(compiler: Rspack.Compiler) { + compiler.hooks.thisCompilation.tap( + 'CaptureClientBuild', + (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'CaptureClientBuild', + stage: rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + clientBuild = captureBuild(compilation) + }, + ) + }, + ) + }, + }, + ], + }) + try { + const stats = await promisify(compiler.run.bind(compiler))() + expect(stats?.toJson({ all: false, errors: true }).errors).toEqual([]) + expect(clientBuild).toBeDefined() + return { clientBuild: clientBuild!, directory } + } finally { + await promisify(compiler.close.bind(compiler))() + await rm(directory, { recursive: true, force: true }) + } +} + +const stylesheetFiles = { + 'index.js': ` + import './root.css' + globalThis.route = () => import(/* webpackChunkName: "posts" */ './posts.js?tsr-split=component') + `, + 'posts.js': `import './posts.css'; export const render = () => 'posts'`, + 'root.css': '.card{background:url(./dot.svg)}', + 'posts.css': '.card{background:url(./dot.svg)}', + 'dot.svg': '', +} + +describe('normalizeRspackClientBuild', () => { + test.each( + ['shared', 'none', 'css-module', 'css-only'].flatMap((extraction) => + [false, true].flatMap((concatenateModules) => + [false, true].map((barrel) => ({ + extraction, + concatenateModules, + barrel, + })), + ), + ), + )( + 'isolates SSR assets with $extraction extraction, concatenateModules=$concatenateModules, barrel=$barrel', + async ({ extraction, concatenateModules, barrel }) => { + const files = { + 'index.js': ` + import './root.css' + globalThis.routes = [ + () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), + () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), + ] + `, + 'alpha.js': ` + import { header } from './${barrel ? 'barrel' : 'header'}.js' + import './alpha.css' + export const render = () => header() + 'alpha' + export const lazy = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + `, + 'beta.js': ` + import { header } from './${barrel ? 'barrel' : 'header'}.js' + import './beta.css' + export const render = () => header() + 'beta' + `, + 'barrel.js': `export { header } from './header.js'`, + 'header.js': + extraction === 'css-only' + ? `import './shared.module.css'; export const header = () => 'header'` + : ` + import * as styles from './shared.module.css' + export const header = () => styles.header + `, + 'lazy.js': `import './lazy.css'; export const lazy = 'lazy'`, + 'root.css': 'body { margin: 0; }', + 'alpha.css': '.alpha { color: red; }', + 'beta.css': '.beta { color: blue; }', + 'lazy.css': '.lazy { color: green; }', + 'shared.module.css': '.header { display: grid; height: 64px; }', + } + const { clientBuild, directory } = await buildClientFixture(files, { + module: { + rules: [ + { test: /barrel\.js$/, sideEffects: false }, + { + test: /\.css$/, + type: extraction === 'css-only' ? 'css' : 'css/auto', + }, + ], + }, + optimization: { + minimize: false, + concatenateModules, + splitChunks: + extraction === 'none' + ? false + : { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + shared: { + name: 'shared', + test: + extraction === 'shared' + ? /(?:header\.js|shared\.module\.css)$/ + : /shared\.module\.css$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }) + + expect( + clientBuild.chunksByFileName.get('alpha.js')?.dynamicImports, + ).toContain('lazy.js') + + for (const routeOrder of [ + ['alpha', 'beta'], + ['beta', 'alpha'], + ]) { + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: routeOrder.map((route) => `/${route}`) }, + ...Object.fromEntries( + routeOrder.map((route) => [ + `/${route}`, + { filePath: join(directory, `${route}.js`) }, + ]), + ), + }, + }) + + expect(manifest.routes.__root__?.css).toEqual(['/index.css']) + for (const route of routeOrder) { + expect(manifest.routes[`/${route}`]?.css).toEqual([ + ...(extraction === 'none' ? [] : ['/shared.css']), + `/${route}.css`, + ]) + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + ...(extraction === 'shared' || extraction === 'css-module' + ? ['/shared.js'] + : []), + ]) + const headerCss = clientBuild.cssContentByFileName?.get( + extraction === 'none' ? `${route}.css` : 'shared.css', + ) + expect(headerCss).toMatch(/display:\s*grid/) + expect(headerCss).toMatch(/height:\s*64px/) + } + } + }, + ) + + test('preloads second-level static dependency chunks and orders their css first', async () => { + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': ` + import './root.css' + globalThis.routes = [ + () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), + () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), + ] + `, + 'alpha.js': ` + import { header } from './header.js' + import './alpha.css' + export const render = () => header() + 'alpha' + `, + 'beta.js': ` + import { header } from './header.js' + import './beta.css' + export const render = () => header() + 'beta' + `, + 'header.js': ` + import { format } from './format.js' + import './header.css' + export const header = () => format('header') + `, + 'format.js': ` + import './format.css' + export const format = (value) => '[' + value + ']' + `, + 'root.css': 'body { margin: 0; }', + 'alpha.css': '.alpha { color: red; }', + 'beta.css': '.beta { color: blue; }', + 'header.css': '.header { display: grid; }', + 'format.css': '.format { font-weight: bold; }', + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + header: { + name: 'header', + test: /header\.(?:js|css)$/, + chunks: 'async', + enforce: true, + }, + format: { + name: 'format', + test: /format\.(?:js|css)$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }, + ) + + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: ['/alpha', '/beta'] }, + '/alpha': { filePath: join(directory, 'alpha.js') }, + '/beta': { filePath: join(directory, 'beta.js') }, + }, + }) + + for (const route of ['alpha', 'beta']) { + expect(manifest.routes[`/${route}`]?.css).toEqual([ + '/format.css', + '/header.css', + `/${route}.css`, + ]) + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + '/header.js', + '/format.js', + ]) + } + }) + + test('keeps route css off the root when an initial vendor chunk is shared with route groups', async () => { + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': ` + import './root.css' + import { lib } from './lib.js' + globalThis.lib = lib() + globalThis.routes = [ + () => import(/* webpackChunkName: "root" */ './root.js?tsr-split=component'), + () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), + () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), + ] + `, + 'root.js': ` + import { lib2 } from './lib2.js' + import './shell.css' + export const render = () => lib2() + 'root' + `, + 'alpha.js': ` + import { lib2 } from './lib2.js' + import './alpha.css' + export const render = () => lib2() + 'alpha' + `, + 'beta.js': ` + import { lib } from './lib.js' + import { lib2 } from './lib2.js' + import './beta.css' + export const render = () => lib() + lib2() + 'beta' + `, + 'lib.js': `export const lib = () => 'lib'`, + 'lib2.js': `export const lib2 = () => 'lib2'`, + 'root.css': 'body { margin: 0; }', + 'shell.css': '.shell { display: flex; }', + 'alpha.css': '.alpha { color: red; }', + 'beta.css': '.beta { color: blue; }', + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + vendor: { + name: 'vendor', + test: /lib2?\.js$/, + chunks: 'all', + enforce: true, + }, + }, + }, + }, + }, + ) + + // The vendor chunk belongs to the entry group and to every route group that + // uses it. Group membership must not pull route CSS into the root. + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { + filePath: join(directory, 'root.js'), + children: ['/alpha', '/beta'], + }, + '/alpha': { filePath: join(directory, 'alpha.js') }, + '/beta': { filePath: join(directory, 'beta.js') }, + }, + }) + + expect(manifest.routes.__root__?.css).toEqual(['/root.css', '/index.css']) + expect(manifest.routes.__root__?.preloads).toEqual([ + '/root.js', + '/vendor.js', + '/index.js', + ]) + expect(manifest.routes['/alpha']?.css).toEqual(['/alpha.css']) + expect(manifest.routes['/alpha']?.preloads).toEqual(['/alpha.js']) + expect(manifest.routes['/beta']?.css).toEqual(['/beta.css']) + expect(manifest.routes['/beta']?.preloads).toEqual(['/beta.js']) + }) + + test('excludes tree-shaken imports even when the module is in an initial chunk', async () => { + const { clientBuild } = await buildClientFixture( + { + 'index.js': ` + import { unused } from './unused.js' + globalThis.value = unused + globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component') + `, + 'alpha.js': ` + import { unused } from './unused.js' + import './alpha.css' + export const render = () => 'alpha' + `, + 'unused.js': `export const unused = 'unused'`, + 'alpha.css': '.alpha { color: red; }', + }, + { + module: { + rules: [ + { test: /unused\.js$/, sideEffects: false }, + { test: /\.css$/, type: 'css' }, + ], + }, + }, + ) + + expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([]) + expect(clientBuild.chunksByFileName.get('alpha.js')?.css).toEqual([ + 'alpha.css', + ]) + }) + + test('uses a dependency in the parent group without importing its copy in another entry', async () => { + const { clientBuild } = await buildClientFixture( + { + 'index.js': ` + import { header } from './header.js' + globalThis.header = header + globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component') + `, + 'other.js': `import { header } from './header.js'; globalThis.otherHeader = header`, + 'alpha.js': `import { header } from './header.js'; export const render = () => header()`, + 'header.js': `import './header.css'; export const header = () => 'header'`, + 'header.css': '.header { display: grid; }', + }, + { entry: { index: './index.js', other: './other.js' } }, + ) + + expect(clientBuild.chunksByFileName.get('index.js')?.css).toEqual([ + 'index.css', + ]) + expect(clientBuild.chunksByFileName.get('other.js')?.css).toEqual([ + 'other.css', + ]) + expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([ + 'index.js', + ]) + }) + + test('collects nested CSS imports before the importing route stylesheet', async () => { + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': `globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component')`, + 'alpha.js': `import './alpha.css'; export const render = () => 'alpha'`, + 'alpha.css': '@import "./theme.css"; .alpha { color: red; }', + 'theme.css': '@import "./base.css"; .theme { color: blue; }', + 'base.css': '.base { color: green; }', + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + theme: { + name: 'theme', + test: /theme\.css$/, + chunks: 'async', + enforce: true, + }, + base: { + name: 'base', + test: /base\.css$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }, + ) + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: ['/alpha'] }, + '/alpha': { filePath: join(directory, 'alpha.js') }, + }, + }) + + expect(clientBuild.chunksByFileName.has('base.js')).toBe(false) + expect(clientBuild.chunksByFileName.has('theme.js')).toBe(false) + expect(manifest.routes['/alpha']?.css).toEqual([ + '/base.css', + '/theme.css', + '/alpha.css', + ]) + }) + + test('keeps route stylesheet links with inline CSS disabled by default', async () => { + const { clientBuild, directory } = await buildClientFixture( + stylesheetFiles, + {}, + normalizeRspackClientBuild, + ) + const manifest = buildStartManifest({ + clientBuild, + routeTreeRoutes: { + __root__: { children: ['/posts'] }, + '/posts': { filePath: join(directory, 'posts.js') }, + }, + basePath: '/assets', + }) + + expect(manifest.routes.__root__?.css).toEqual(['/assets/index.css']) + expect(manifest.routes['/posts']?.css).toEqual(['/assets/posts.css']) + expect(manifest.inlineCss).toBeUndefined() + }) + + test.each([ + { action: 'build', enabled: false, captureCss: false }, + { action: 'build', enabled: true, captureCss: true }, + { action: 'dev', enabled: false, captureCss: false }, + { action: 'dev', enabled: true, captureCss: false }, + ])( + 'captures inline CSS according to the action and resolved config ($action, $enabled)', + async ({ action, enabled, captureCss }) => { + const processAssets = vi.fn() + const getConfig = () => ({ + startConfig: { server: { build: { inlineCss: { enabled } } } }, + }) + const { getClientBuild } = registerClientBuildCapture( + { context: { action }, processAssets } as unknown as RsbuildPluginAPI, + getConfig as unknown as GetConfigFn, + ) + + const [, capture] = processAssets.mock.calls[0]! + const { clientBuild, directory } = await buildClientFixture( + stylesheetFiles, + {}, + (compilation) => { + capture({ compilation } as Parameters[0]) + return getClientBuild()! + }, + ) + const manifest = buildStartManifest({ + clientBuild, + routeTreeRoutes: { + __root__: { children: ['/posts'] }, + '/posts': { filePath: join(directory, 'posts.js') }, + }, + basePath: '/assets', + inlineCss: { enabled: captureCss, transformAssets: false }, + }) + + expect(manifest.routes.__root__?.css).toEqual(['/assets/index.css']) + expect(manifest.routes['/posts']?.css).toEqual(['/assets/posts.css']) + if (captureCss) { + expect(manifest.inlineCss?.styles).toEqual({ + '/assets/index.css': expect.stringContaining('/assets/dot.svg'), + '/assets/posts.css': expect.stringContaining('/assets/dot.svg'), + }) + } else { + expect(manifest.inlineCss).toBeUndefined() + } + }, + ) +}) diff --git a/packages/start-plugin-core/tests/start-build-client-only-imports.test.ts b/packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts similarity index 93% rename from packages/start-plugin-core/tests/start-build-client-only-imports.test.ts rename to packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts index 708cab4d78d..c70bc53c8a4 100644 --- a/packages/start-plugin-core/tests/start-build-client-only-imports.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts @@ -7,9 +7,9 @@ * Source: user ruling 2026-10-18 (E1 ASK-1). */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { buildStart, startBundlers } from './build-harness' -import { reportedViolations } from './importProtection/start-apps' -import type { StartBuild } from './build-harness' +import { buildStart, startBundlers } from '../build-harness' +import { reportedViolations } from '../importProtection/start-apps' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-determinism.test.ts b/packages/start-plugin-core/tests/integration/start-build-determinism.test.ts similarity index 98% rename from packages/start-plugin-core/tests/start-build-determinism.test.ts rename to packages/start-plugin-core/tests/integration/start-build-determinism.test.ts index dfe8c9e1d6c..5e45618eacc 100644 --- a/packages/start-plugin-core/tests/start-build-determinism.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-determinism.test.ts @@ -12,8 +12,8 @@ import { callByIdPrelude, routerRuntimeFiles, startBundlers, -} from './build-harness' -import type { StartBuild } from './build-harness' +} from '../build-harness' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts similarity index 99% rename from packages/start-plugin-core/tests/start-build-env-separation.test.ts rename to packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts index 919362246a7..92261acd187 100644 --- a/packages/start-plugin-core/tests/start-build-env-separation.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts @@ -20,7 +20,7 @@ import { onTestFinished, test, } from 'vitest' -import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' +import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' import { buildStart, callByIdExports, @@ -30,8 +30,8 @@ import { runInRequestContext, startBundlers, startRuntimeLinks, -} from './build-harness' -import type { StartBuild } from './build-harness' +} from '../build-harness' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-manifest.test.ts b/packages/start-plugin-core/tests/integration/start-build-manifest.test.ts similarity index 98% rename from packages/start-plugin-core/tests/start-build-manifest.test.ts rename to packages/start-plugin-core/tests/integration/start-build-manifest.test.ts index 873b7739ea7..81b6d4f7671 100644 --- a/packages/start-plugin-core/tests/start-build-manifest.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-manifest.test.ts @@ -5,8 +5,8 @@ * outside-compiler-corpus/start-build-manifest.test.ts.) */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' -import type { StartBuild } from './build-harness' +import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-options.test.ts b/packages/start-plugin-core/tests/integration/start-build-options.test.ts similarity index 99% rename from packages/start-plugin-core/tests/start-build-options.test.ts rename to packages/start-plugin-core/tests/integration/start-build-options.test.ts index cec2b90121b..f249ec381e1 100644 --- a/packages/start-plugin-core/tests/start-build-options.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-options.test.ts @@ -15,8 +15,8 @@ import { callByIdPrelude, routerRuntimeFiles, startBundlers, -} from './build-harness' -import type { StartBuild } from './build-harness' +} from '../build-harness' +import type { StartBuild } from '../build-harness' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-route-hmr.test.ts b/packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts similarity index 96% rename from packages/start-plugin-core/tests/start-build-route-hmr.test.ts rename to packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts index 28d91f2ffd0..e1008c82277 100644 --- a/packages/start-plugin-core/tests/start-build-route-hmr.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts @@ -10,9 +10,9 @@ * the same app has some of them. */ import { describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' -import { startDevServer } from './dev-server' -import { startRsbuildStartDevServer } from './rsbuild-dev-server' +import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import { startDevServer } from '../dev-server' +import { startRsbuildStartDevServer } from '../rsbuild-dev-server' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-source-maps.test.ts b/packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts similarity index 99% rename from packages/start-plugin-core/tests/start-build-source-maps.test.ts rename to packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts index e599c66a63a..50f66962691 100644 --- a/packages/start-plugin-core/tests/start-build-source-maps.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts @@ -9,8 +9,8 @@ */ import { describe, expect, test } from 'vitest' import { originalFrameOf } from '@tanstack/compiler-test-utils/build-harness' -import { buildStart, routerRuntimeFiles, startBundlers } from './build-harness' -import type { StartBundler } from './build-harness' +import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import type { StartBundler } from '../build-harness' import type { InlineConfig } from 'vite' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-build-warnings.test.ts b/packages/start-plugin-core/tests/integration/start-build-warnings.test.ts similarity index 95% rename from packages/start-plugin-core/tests/start-build-warnings.test.ts rename to packages/start-plugin-core/tests/integration/start-build-warnings.test.ts index a384c7f38ee..a8025ab85e9 100644 --- a/packages/start-plugin-core/tests/start-build-warnings.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-warnings.test.ts @@ -8,10 +8,10 @@ * planned warning is dev-only, user ruling D8). Its errors fail the build. */ import { describe, expect, test } from 'vitest' -import { buildStart, startBundlers } from './build-harness' -import { warnMeTransform as warningTransform } from './compiler-transforms-seam' -import { startDevServer } from './dev-server' -import { startRsbuildStartDevServer } from './rsbuild-dev-server' +import { buildStart, startBundlers } from '../build-harness' +import { warnMeTransform as warningTransform } from '../compiler-transforms-seam' +import { startDevServer } from '../dev-server' +import { startRsbuildStartDevServer } from '../rsbuild-dev-server' const timeout = 60_000 diff --git a/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts b/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts similarity index 99% rename from packages/start-plugin-core/tests/start-compiler-dev-server.test.ts rename to packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts index c8082af7ee1..5c36d36b505 100644 --- a/packages/start-plugin-core/tests/start-compiler-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts @@ -9,13 +9,13 @@ import { describeHmr, evaluateServed, } from '@tanstack/compiler-test-utils/dev-server' -import { requestsOf, runInRequestContext } from './build-harness' +import { requestsOf, runInRequestContext } from '../build-harness' import { callServerFnById, servedStartRuntime, startDevServer, startServerEntryFiles, -} from './dev-server' +} from '../dev-server' const timeout = 30_000 diff --git a/packages/start-plugin-core/tests/start-router-dev-server.test.ts b/packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts similarity index 99% rename from packages/start-plugin-core/tests/start-router-dev-server.test.ts rename to packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts index 38291060e01..8f027ecdf5a 100644 --- a/packages/start-plugin-core/tests/start-router-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts @@ -9,7 +9,7 @@ import react from '@vitejs/plugin-react' import { describe, expect, onTestFinished, test } from 'vitest' import { openPage } from '@tanstack/compiler-test-utils/browser' import { describeHmr } from '@tanstack/compiler-test-utils/dev-server' -import { startDevServer } from './dev-server' +import { startDevServer } from '../dev-server' import type { DevServer } from '@tanstack/compiler-test-utils/dev-server' const timeout = 30_000 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 1a3794f856d..08b4e0f3e24 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 @@ -2,26 +2,10 @@ * Known Start compiler bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' + import path from 'node:path' -import { performance } from 'node:perf_hooks' -import { createServer, isRunnableDevEnvironment, transformWithOxc } from 'vite' +import { transformWithOxc } from 'vite' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' -import { - buildStart, - callByIdExports, - callByIdPrelude, - linkStartRuntime, - startBundlers, -} from './build-harness' -import { - callServerFnById, - servedStartRuntime, - startPlugin, - startPluginFiles, - startServerEntryFiles, -} from './dev-server' import { callProvider, callReportedServerFns, @@ -47,7 +31,6 @@ import { settle, startClientCore, } from './regression-helpers' -import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import { capturingPlacement, @@ -1546,698 +1529,3 @@ exports.run = function (app) { }, ) }) - -describe.each(startBundlers)('%s build', (bundler) => { - const timeout = 60_000 - - /** A server-only module whose top-level code marks the bundle it is in. */ - const dbModule = `globalThis.MARK_DB_LOADED = true -export const db = { users: () => 'users' } -` - - // Control for the pin below (same harness). - test( - 'client: the server-only module of a function only server code uses is not bundled', - async () => { - const { client } = await buildStart({ - bundler, - files: { - 'db.server.ts': dbModule, - 'fns.ts': `${head}import { db } from './db.server' -function find() { return db.users() } -export const fn = createServerFn().handler(async () => find()) -`, - 'entry.ts': `export { fn } from './fns'\n`, - }, - }) - expect(client.where('MARK_DB_LOADED')).toBe('nowhere') - }, - timeout, - ) - - // Bug: the client output keeps a class only server code uses, with its - // server-only import (compiler pin "client: a class used by a server fn - // handler does not load its server-only import" above). Tree-shaking drops - // the unused class but keeps the import, so the server-only module's - // top-level code is bundled and runs in the browser. - // Impact: server-only modules (database clients, SDKs initialised with - // secrets) ship to the client and run their top-level code there. - // Source: gap D2 (gaps/dead-code.md), build canary. - test.fails( - 'client: the server-only module of a class only server code uses is not bundled', - async () => { - const { client } = await buildStart({ - bundler, - files: { - 'db.server.ts': dbModule, - 'fns.ts': `${head}import { db } from './db.server' -class Repo { get() { return db.users() } } -export const fn = createServerFn().handler(async () => new Repo().get()) -`, - 'entry.ts': `export { fn } from './fns'\n`, - }, - }) - expect(client.where('MARK_DB_LOADED')).toBe('nowhere') - }, - timeout, - ) - - /** A server fn module whose `auth` import a type still mentions. */ - const typeofFiles = (verbatimModuleSyntax: boolean) => ({ - 'tsconfig.json': JSON.stringify({ - compilerOptions: { verbatimModuleSyntax }, - }), - 'auth.ts': `globalThis.MARK_AUTH_LOADED = true -export const auth = { get: () => 'session', session: 'session' } -`, - 'fns.ts': `${head}import { auth } from './auth' -type Session = typeof auth.session -export const fn = createServerFn().handler(async () => auth.get() as Session) -`, - 'entry.ts': `export { fn } from './fns'\n`, - }) - - // Control for the pin below (same harness): without verbatimModuleSyntax, - // TypeScript erases the import that only a type mentions. - test( - 'client: a server-only module a type mentions is not bundled', - async () => { - const { client } = await buildStart({ - bundler, - files: typeofFiles(false), - }) - expect(client.where('MARK_AUTH_LOADED')).toBe('nowhere') - }, - timeout, - ) - - // Bug (Vite): the client output keeps the server-only import that a - // `typeof` type query still mentions (compiler pin "client: a server-only - // import last mentioned by a type alias ..." above); with - // `verbatimModuleSyntax`, Vite's TypeScript transform keeps it. Rsbuild's - // transform of the same app drops it. - // Impact: the server-only module's top-level code is bundled and runs in - // the browser. - // Source: gap D4 (gaps/dead-code.md), build canary. - ;(bundler === 'vite' ? test.fails : test)( - 'client: a server-only module a type mentions is not bundled with verbatimModuleSyntax', - async () => { - const { client } = await buildStart({ bundler, files: typeofFiles(true) }) - expect(client.where('MARK_AUTH_LOADED')).toBe('nowhere') - }, - timeout, - ) - - // Bug: with SSR as the server fn provider, the SSR caller module and the - // provider module (`?tss-serverfn-split`) are two module instances of the - // same file in the SSR bundle, and the provider repeats the file's - // top-level code (exports-and-directives.test.ts "side-effect imports and - // top-level statements stay in every output" asserts this). - // Impact: in one server process, the file's top-level side effects run - // twice, handlers and SSR code of the file see different module state, and - // `instanceof` fails for a class of the file passed to a server fn. - // Source: gap SF17 (gaps/start-env.md), PR #8656 (class identity). - test.fails( - 'SSR: a server fn module is initialised once and shares its state with SSR code', - async () => { - const { ssr } = await buildStart({ - bundler, - files: { - 'fns.ts': `${head}globalThis.initialised = (globalThis.initialised ?? 0) + 1 -let count = 0 -export class Session {} -export const readCount = () => count -export const fn = createServerFn().handler(async ({ data }) => { - count++ - return data instanceof Session -}) -`, - 'entry.ts': `export { fn, readCount, Session } from './fns'\n`, - }, - }) - const { result } = - await ssr.run(`const isSession = await entry.fn({ data: new entry.Session() }) -return { initialised: globalThis.initialised, isSession, count: entry.readCount() }`) - expect(result).toEqual({ initialised: 1, isSession: true, count: 1 }) - }, - timeout, - ) - - /** - * An app whose client imports one server fn (`clientFn`) and whose server - * entry imports another one only server code uses (`serverOnlyFn`), with - * the server fn resolver the server-fn HTTP handler calls. The resolver is - * imported by the server-only module, so the SSR build compiles that - * module (and reports its server fn) before it generates the resolver. - */ - const clientAccessFiles = { - 'client-fns.ts': `${head}export const clientFn = createServerFn().handler(async () => 'client fn') -`, - 'server-only-fns.ts': `${head}export const serverOnlyFn = createServerFn().handler(async () => 'server-only fn') -export { getServerFnById } from '#tanstack-start-server-fn-resolver' -`, - 'client-entry.ts': `export { clientFn } from './client-fns' -`, - 'ssr-entry.ts': `export { serverOnlyFn, getServerFnById } from './server-only-fns' -${callByIdExports}`, - } - - /** - * Builds `clientAccessFiles` and, in the SSR build, sends a request for - * each server fn id to Start's server entry, as a page does - * (`callByIdPrelude`), and calls the server-only one from SSR code. - */ - async function callClientAccess() { - const { client, ssr } = await buildStart({ - bundler, - files: clientAccessFiles, - entry: { client: 'client-entry.ts', ssr: 'ssr-entry.ts' }, - }) - expect(client.where("'server-only fn'")).toBe('nowhere') - const clientFnId = ( - await client.run(`return entry.clientFn.serverFnMeta.id`) - ).result - const { result } = - await ssr.run(`${callByIdPrelude}const settle = async (run) => { - try { - return await run() - } catch { - return 'rejected' - } -} -const overHttp = (id) => - settle(async () => callById(id)) -return { - clientFn: await overHttp(${JSON.stringify(clientFnId)}), - serverOnlyFnOverHttp: await overHttp(entry.serverOnlyFn.serverFnMeta.id), - serverOnlyFnFromSsr: await settle(() => entry.serverOnlyFn()), -}`) - return result - } - - // Control for the pin below (same harness). - test( - 'SSR: a server fn the client references is callable over HTTP, and a server-only one from SSR code', - async () => { - expect(await callClientAccess()).toMatchObject({ - clientFn: 'client fn', - serverOnlyFnFromSsr: 'server-only fn', - }) - }, - timeout, - ) - - // Bug: with SSR as the provider, the production resolver lists every - // server fn the builds found and never checks the caller's origin - // (`includeClientReferencedCheck` is off, and the flag it would check is - // true for every SSR-caller compile, handleCreateServerFn.ts - // `isClientReferenced`). - // Impact: a server fn only server code uses (an SSR loader helper, a - // server-only module) is callable over HTTP by anyone who learns its id, - // bypassing what the server code around it checks. - // Source: user ruling D6 (only server fns client code references are - // HTTP-callable); the RSC-provider variant is pinned in - // createServerFn/createServerFn.test.ts. - test.fails( - 'SSR: a server fn only server code references is not callable over HTTP', - async () => { - expect(await callClientAccess()).toEqual({ - clientFn: 'client fn', - serverOnlyFnOverHttp: 'rejected', - serverOnlyFnFromSsr: 'server-only fn', - }) - }, - timeout, - ) - - // Bug (Vite): the production server fn resolver is generated when the SSR - // build first loads it, from the server fns found so far. A server fn that - // only another server fn's handler (or a middleware `.server()`) calls is - // found later, when the SSR build compiles the provider that imports it, so - // it is missing from the resolver. Rsbuild rebuilds the resolver after the - // SSR compilation found every server fn. - // Impact: in production, calling it throws "Server function info not - // found" (works in dev). - // Source: issue #7213. - ;(bundler === 'vite' ? test.fails : test)( - 'SSR: a server fn that only another server fn calls is callable', - async () => { - const { ssr } = await buildStart({ - bundler, - files: { - 'inner.ts': `${head}export const inner = createServerFn().handler(async () => 'inner') -`, - 'outer.ts': `${head}import { inner } from './inner' -export const outer = createServerFn().handler(async () => 'outer:' + (await inner())) -`, - 'entry.ts': `export { outer } from './outer'\n`, - }, - }) - expect((await ssr.run(`return await entry.outer()`)).result).toBe( - 'outer:inner', - ) - }, - timeout, - ) - - // Control: start-build-env-separation.test.ts "a library's server fn" - // (a library exporting a top-level server fn). - // Bug: a library in node_modules that creates server fns for its users - // (`createLoggedServerFn(handler)` returning - // `createServerFn().handler(handler)`) is compiled like any module, but - // the user's module only calls the library's function, so the compiler - // never sees a server fn there, and nothing warns. - // Impact: the handler the user passes, and what it imports, ship to the - // client bundle and run there instead of on the server. - // Source: lost-cases-campaign item 13 (b); user ruling D13 (a server fn - // created inside a function is supported, or rejected clearly). - test.fails( - 'a server fn created by a library function: the handler is not in the client bundle, or the build fails clearly', - async () => { - const outcome = await buildStart({ - bundler, - // The app depends on the library, as an installed one. - packageJson: { - name: 'app', - private: true, - type: 'module', - dependencies: { 'wrapper-lib': '*' }, - }, - files: { - 'node_modules/wrapper-lib/package.json': JSON.stringify({ - name: 'wrapper-lib', - type: 'module', - exports: './index.js', - peerDependencies: { '@tanstack/react-start': '*' }, - }), - 'node_modules/wrapper-lib/index.js': `import { createServerFn } from '@tanstack/react-start' -export function createLoggedServerFn(handler) { - return createServerFn().handler(handler) -} -`, - 'src/db.ts': `export const db = { secret: () => 'MARK_WRAPPED_HANDLER' }\n`, - 'src/fns.ts': `import { createLoggedServerFn } from 'wrapper-lib' -import { db } from './db' -export const fn = createLoggedServerFn(async () => db.secret()) -`, - 'src/entry.ts': `export { fn } from './fns'\n`, - }, - entry: 'src/entry.ts', - }).then( - async ({ client, ssr }) => ({ - client: client.where('MARK_WRAPPED_HANDLER'), - ssr: (await ssr.run(`return await entry.fn()`)).result, - }), - (error: unknown) => ({ error: String(error) }), - ) - if ('error' in outcome) { - expect(outcome.error).toMatch(misplacedServerFnRejection) - expect(outcome.error).not.toMatch( - /Expected createServerFn declaration in a statement list/, - ) - } else { - expect(outcome).toEqual({ - client: 'nowhere', - ssr: 'MARK_WRAPPED_HANDLER', - }) - } - }, - timeout, - ) - - // Bug (Vite): Start's Vite plugin looks for the Start libraries it bundles - // in SSR (`crawlFrameworkPkgs`: dependencies with a peer dependency on - // Start) in the package.json of the working directory (`root: - // process.cwd()`), not of Vite's root, which the plugin uses for its own - // root a few lines above. - // Source: src/vite/plugin.ts:143 (Vite's root: :96); found when the build - // harness ran Start's real runtime (it ran Vite builds outside the app). - // Impact: `vite build ` run from another directory (a monorepo root, - // a script) leaves a Start library external in the SSR build: Node loads - // it uncompiled, and an SSR call of its server fn returns undefined - // instead of the handler's result. - // Control: start-build-env-separation.test.ts "a library's server fn" - // (the same library, built in the app's directory). - ;(bundler === 'vite' ? test.fails : test)( - "a build run from another directory: a library's server fn runs in the SSR build", - async () => { - const { ssr } = await buildStart({ - bundler, - // The directory of this test file, whose package.json is not the - // app's. - cwd: __dirname, - packageJson: { - name: 'app', - private: true, - type: 'module', - dependencies: { 'server-fn-lib': '*' }, - }, - files: { - 'node_modules/server-fn-lib/package.json': JSON.stringify({ - name: 'server-fn-lib', - type: 'module', - exports: './index.js', - peerDependencies: { '@tanstack/react-start': '*' }, - }), - 'node_modules/server-fn-lib/index.js': `import { createServerFn } from '@tanstack/react-start' -export const libFn = createServerFn().handler(async () => 'MARK_LIB_HANDLER') -`, - 'src/entry.ts': `export { libFn } from 'server-fn-lib'\n`, - }, - entry: 'src/entry.ts', - }) - expect((await ssr.run(`return await entry.libFn()`)).result).toBe( - 'MARK_LIB_HANDLER', - ) - }, - timeout, - ) - - // Bug (Rsbuild): in the SSR build, compiling a module with an isomorphic - // fn makes the Start compiler resolve the module's `createMiddleware` - // import (the server lookup does not know that factory). The Rsbuild - // compiler host resolves it through the loader context, whose resolver - // does not match the package's `import`-only exports (those of - // `@tanstack/react-start`): the build fails with - // `PackagePathNotExported(".")` (start-compiler-host.ts `resolveId`). - // Impact: an app with a middleware and an isomorphic fn in one module - // does not build with Rsbuild. - // Source: lost-cases item 3 (BS-6 / K-13). - ;(bundler === 'rsbuild' ? test.fails : test)( - 'a module with a middleware and an isomorphic fn builds, and its middleware runs on the server', - async () => { - const { client, ssr } = await buildStart({ - bundler, - files: { - 'src/both.ts': `import { createIsomorphicFn, createMiddleware } from '@tanstack/react-start' -export const middleware = createMiddleware().server(({ next }) => - next({ context: { marker: 'MARK_MIDDLEWARE_SERVER' } }), -) -export const isomorphic = createIsomorphicFn() - .server(() => 'server') - .client(() => 'client') -`, - 'src/entry.ts': `export { middleware, isomorphic } from './both'\n`, - }, - entry: 'src/entry.ts', - }) - expect(client.where('MARK_MIDDLEWARE_SERVER')).toBe('nowhere') - const { result } = await ssr.run(`return { - middleware: entry.middleware.options.server({ next: (options) => options }), - isomorphic: entry.isomorphic(), -}`) - expect(result).toEqual({ - middleware: { context: { marker: 'MARK_MIDDLEWARE_SERVER' } }, - isomorphic: 'server', - }) - }, - timeout, - ) -}) - -describe('Vite dev server', () => { - /** - * Resolves `'hang'` once `pending` has made no progress for `quietMs`: no - * plugin hook ran (`lastActivity()`) and the main thread stayed idle (a - * dependency optimizer bundling, or any other work the request waits for, - * keeps it busy). Resolves `'settled'` when `pending` settles first, and - * `'hang'` after `limitMs` at the latest. - */ - async function settledOrHung( - pending: Promise, - lastActivity: () => number, - { quietMs = 1_500, limitMs = 15_000 } = {}, - ): Promise<'settled' | 'hang'> { - let settled = false - pending.then( - () => (settled = true), - () => (settled = true), - ) - const started = performance.now() - let busySince = started - let previous = performance.eventLoopUtilization() - while (!settled) { - await new Promise((resolve) => setTimeout(resolve, 100)) - if (settled) { - break - } - const current = performance.eventLoopUtilization() - if ( - performance.eventLoopUtilization(current, previous).utilization > 0.05 - ) { - busySince = performance.now() - } - previous = current - const now = performance.now() - const quietFor = now - Math.max(lastActivity(), busySince) - if (quietFor >= quietMs || now - started >= limitMs) { - return 'hang' - } - } - return 'settled' - } - - /** - * Starts a Vite dev server (middleware mode, empty dependency cache) on a - * temporary project with the Start compiler, Start's real runtime - * (`linkStartRuntime`), `src/fn.ts` = `code` and `files`, and requests the - * client transform of `src/fn.ts`. Returns `'hang'` when the transform - * stops making progress before it finishes (`settledOrHung`); otherwise - * its code and the module evaluated with Start's real client runtime - * (`clientRuntime`) and `zod`. - */ - async function transformClientModule( - code: string, - files: Record = {}, - ) { - // Keep the temporary project inside the package so dependencies resolve. - const root = await mkdtemp(path.join(__dirname, '.dev-transform-')) - let server: Awaited> | undefined - let lastActivity = performance.now() - const active = () => { - lastActivity = performance.now() - return null - } - try { - await mkdir(path.join(root, 'src')) - for (const [file, content] of Object.entries({ - 'fn.ts': code, - ...files, - })) { - await writeFile(path.join(root, 'src', file), content) - } - await linkStartRuntime(root) - server = await createServer({ - root, - configFile: false, - logLevel: 'silent', - cacheDir: path.join(root, '.vite'), - appType: 'custom', - server: { middlewareMode: true, hmr: false, ws: false, watch: null }, - plugins: [ - { - name: 'record-activity', - enforce: 'pre', - resolveId: active, - load: active, - transform: active, - }, - startCompilerPlugin({ - framework: 'react', - environments: [ - { name: 'client', type: 'client' }, - { name: 'ssr', type: 'server' }, - ], - providerEnvName: 'ssr', - }), - ], - }) - const transformed = - server.environments.client.transformRequest('/src/fn.ts') - if ((await settledOrHung(transformed, () => lastActivity)) === 'hang') { - transformed.catch(() => {}) - return 'hang' as const - } - const served = (await transformed)!.code - const client = server.environments.client - const zod = (await import('zod')) as ModuleStub - const module = await createModuleGraph({ - modules: { '/src/fn.ts': served }, - // Project modules as the dev server serves them. - load: async (id) => - id.startsWith('/src/') - ? (await client.transformRequest(id))?.code - : null, - transform: (source) => source, - stubs: servedStartRuntime, - // `zod`, served pre-bundled (`/node_modules/.vite/deps/zod.js`) - fallbackStub: (specifier) => { - if (specifier.includes('/zod')) { - return zod - } - throw new Error(`no stub for ${specifier}`) - }, - }).import('/src/fn.ts') - return { code: served, module } - } finally { - await Promise.race([ - server?.close(), - new Promise((resolve) => setTimeout(resolve, 3_000)), - ]) - await rm(root, { recursive: true, force: true }) - } - } - - /** - * The client got `fn` as an RPC carrying its id, without the handler body - * (`MARK_HANDLER`). - */ - function expectClientRpc( - result: Awaited>, - ) { - expect(result).not.toBe('hang') - const { code, module } = result as Exclude - expect(code).not.toContain('MARK_HANDLER') - expect(callerRpcId(module.fn)).toEqual(expect.any(String)) - } - - // Control for the pin below (same harness). - test('transforms a client module with a server fn and a top-level call of a project function', async () => { - expectClientRpc( - await transformClientModule( - `${head}import { define } from './define' -export const schema = define() -export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, - { 'define.ts': `export const define = () => ({})` }, - ), - ) - }, 30_000) - - // Bug: next to a server fn, a top-level `const x = f()` may be a renamed or - // re-exported Start factory, so in dev the client compiler loads the module - // of `f` through the client environment, from inside the transform. For a - // pre-bundled dependency (here `zod`) with an empty dependency cache, that - // load waits for the dependency optimizer, which waits for this transform. - // Impact: with an empty dependency cache, such a module (a `z.object()` - // schema next to a server fn) never finishes transforming when it is - // requested as the dev server starts (`server.warmup`, the first request - // of a middleware-mode server, or `transformRequest()`), and the page hangs. - test.fails( - 'transforms a client module with a server fn and a top-level call of a pre-bundled dependency', - async () => { - expectClientRpc( - await transformClientModule( - `${head}import { z } from 'zod' -export const schema = z.object({}) -export const fn = createServerFn().handler(() => 'MARK_HANDLER')`, - ), - ) - }, - 30_000, - ) - - /** - * Starts a Vite dev server (middleware mode) with Start's Vite plugin on - * `root` (which links Start's real runtime and has `startAppFiles`), with - * SSR as the server fn provider. - */ - function startServerFnDevServer(root: string) { - return createServer({ - root, - configFile: false, - logLevel: 'silent', - cacheDir: path.join(root, '.vite'), - appType: 'custom', - server: { middlewareMode: true, hmr: false, ws: false, watch: null }, - plugins: [startPlugin(root, {}, undefined)], - }) - } - - /** - * The files of Start's plugin (`startPluginFiles`), with Start's default - * server entry (`startServerEntryFiles`), which handles server fn calls. - */ - const startAppFiles = { ...startPluginFiles, ...startServerEntryFiles } - - /** - * In a temporary project with `src/fns.ts` = a `greet` server fn, reads the - * id of `greet` through a first dev server, then calls the id the way a - * page does (`callServerFnById`: Start's server entry handles it) on a dev - * server started with `fresh` (a new server, which has compiled nothing) - * or on the same server. Returns the result or the error message. - */ - async function callServerFnId(fresh: boolean) { - const root = await mkdtemp(path.join(__dirname, '.dev-server-fn-')) - const servers: Array>> = [] - try { - for (const [file, code] of Object.entries({ - ...startAppFiles, - 'src/fns.ts': `${head}export const greet = createServerFn().handler(() => 'hello')\n`, - })) { - await mkdir(path.dirname(path.join(root, file)), { recursive: true }) - await writeFile(path.join(root, file), code) - } - await linkStartRuntime(root) - /** Imports a module in the SSR environment of a dev server. */ - const importSsr = ( - server: Awaited>, - id: string, - ) => { - const ssr = server.environments.ssr - if (!ssr || !isRunnableDevEnvironment(ssr)) { - throw new Error('expected a runnable SSR environment') - } - return ssr.runner.import(id) - } - const first = await startServerFnDevServer(root) - servers.push(first) - const { greet } = await importSsr(first, '/src/fns.ts') - let server = first - if (fresh) { - await servers.pop()!.close() - server = await startServerFnDevServer(root) - servers.push(server) - } - const ssr = server - // Start's server logs the error of a failed call (a Vite plugin error - // here), which Vitest's console forwarding does not survive. - const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - return await callServerFnById( - { import: (id) => importSsr(ssr, id) }, - greet.serverFnMeta.id, - ) - } catch (error) { - return `throws: ${(error as Error).message}` - } finally { - logged.mockRestore() - } - } finally { - for (const server of servers) { - await server.close() - } - await rm(root, { recursive: true, force: true }) - } - } - - // Control for the pin below (same harness). - test('calls a server fn id on the dev server that compiled its module', async () => { - expect(await callServerFnId(false)).toBe('hello') - }, 30_000) - - // Bug: when a server fn id is not registered yet, the dev validator - // (`validate-server-fn-id`) transforms the source file under - // `?server-fn-module-lookup`, the one id the compiling transform excludes, - // so the id is never registered and the validator rejects it. - // Impact: after a dev server restart, calling a server fn whose module the - // new server has not compiled yet (e.g. from a page loaded before the - // restart) fails with "Invalid server function ID" (500). - // Source: issue #8447, gap SF8 (gaps/start-env.md). - test.fails( - 'calls a server fn id on a fresh dev server that has not compiled its module', - async () => { - expect(await callServerFnId(true)).toBe('hello') - }, - 30_000, - ) -}) diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index f563c57bf7d..ce88ade3c5a 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -1,103 +1,7 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { promisify } from 'node:util' -import { rspack } from '@rsbuild/core' -import { join } from 'pathe' -import { describe, expect, test, vi } from 'vitest' -import { - normalizeRspackClientBuild, - registerClientBuildCapture, -} from '../../src/rsbuild/normalized-client-build' +import { describe, expect, test } from 'vitest' +import { normalizeRspackClientBuild } from '../../src/rsbuild/normalized-client-build' import { buildStartManifest } from '../../src/start-manifest-plugin/manifestBuilder' -import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' -import type { GetConfigFn, NormalizedClientBuild } from '../../src/types' - -async function buildClientFixture( - files: Record, - config: Rspack.Configuration = {}, - captureBuild: (compilation: Rspack.Compilation) => NormalizedClientBuild = ( - compilation, - ) => normalizeRspackClientBuild(compilation, true), -) { - const directory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-css-')) - let clientBuild: NormalizedClientBuild | undefined - const compiler = rspack({ - ...config, - context: directory, - mode: 'production', - cache: false, - entry: config.entry ?? { index: './index.js' }, - output: { - path: join(directory, 'dist'), - filename: '[name].js', - chunkFilename: '[name].js', - cssFilename: '[name].css', - cssChunkFilename: '[name].css', - assetModuleFilename: '[name][ext]', - publicPath: '', - }, - module: config.module ?? { - rules: [ - { test: /\.css$/, type: 'css' }, - { test: /\.svg$/, type: 'asset/resource' }, - ], - }, - optimization: { - minimize: false, - concatenateModules: false, - splitChunks: false, - ...config.optimization, - }, - plugins: [ - new rspack.experiments.VirtualModulesPlugin( - Object.fromEntries( - Object.entries(files).map(([name, source]) => [ - join(directory, name), - source, - ]), - ), - ), - { - apply(compiler: Rspack.Compiler) { - compiler.hooks.thisCompilation.tap( - 'CaptureClientBuild', - (compilation) => { - compilation.hooks.processAssets.tap( - { - name: 'CaptureClientBuild', - stage: rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, - }, - () => { - clientBuild = captureBuild(compilation) - }, - ) - }, - ) - }, - }, - ], - }) - try { - const stats = await promisify(compiler.run.bind(compiler))() - expect(stats?.toJson({ all: false, errors: true }).errors).toEqual([]) - expect(clientBuild).toBeDefined() - return { clientBuild: clientBuild!, directory } - } finally { - await promisify(compiler.close.bind(compiler))() - await rm(directory, { recursive: true, force: true }) - } -} - -const stylesheetFiles = { - 'index.js': ` - import './root.css' - globalThis.route = () => import(/* webpackChunkName: "posts" */ './posts.js?tsr-split=component') - `, - 'posts.js': `import './posts.css'; export const render = () => 'posts'`, - 'root.css': '.card{background:url(./dot.svg)}', - 'posts.css': '.card{background:url(./dot.svg)}', - 'dot.svg': '', -} +import type { Rspack } from '@rsbuild/core' // Rspack reports module paths using the OS native separator on Windows // (e.g. `C:\app\src\routes\posts.tsx`), while the generated route tree always @@ -145,477 +49,6 @@ function makeWindowsCompilation(readCss: () => string | Uint8Array) { } describe('normalizeRspackClientBuild', () => { - test.each( - ['shared', 'none', 'css-module', 'css-only'].flatMap((extraction) => - [false, true].flatMap((concatenateModules) => - [false, true].map((barrel) => ({ - extraction, - concatenateModules, - barrel, - })), - ), - ), - )( - 'isolates SSR assets with $extraction extraction, concatenateModules=$concatenateModules, barrel=$barrel', - async ({ extraction, concatenateModules, barrel }) => { - const files = { - 'index.js': ` - import './root.css' - globalThis.routes = [ - () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), - () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), - ] - `, - 'alpha.js': ` - import { header } from './${barrel ? 'barrel' : 'header'}.js' - import './alpha.css' - export const render = () => header() + 'alpha' - export const lazy = () => import(/* webpackChunkName: "lazy" */ './lazy.js') - `, - 'beta.js': ` - import { header } from './${barrel ? 'barrel' : 'header'}.js' - import './beta.css' - export const render = () => header() + 'beta' - `, - 'barrel.js': `export { header } from './header.js'`, - 'header.js': - extraction === 'css-only' - ? `import './shared.module.css'; export const header = () => 'header'` - : ` - import * as styles from './shared.module.css' - export const header = () => styles.header - `, - 'lazy.js': `import './lazy.css'; export const lazy = 'lazy'`, - 'root.css': 'body { margin: 0; }', - 'alpha.css': '.alpha { color: red; }', - 'beta.css': '.beta { color: blue; }', - 'lazy.css': '.lazy { color: green; }', - 'shared.module.css': '.header { display: grid; height: 64px; }', - } - const { clientBuild, directory } = await buildClientFixture(files, { - module: { - rules: [ - { test: /barrel\.js$/, sideEffects: false }, - { - test: /\.css$/, - type: extraction === 'css-only' ? 'css' : 'css/auto', - }, - ], - }, - optimization: { - minimize: false, - concatenateModules, - splitChunks: - extraction === 'none' - ? false - : { - minSize: 0, - cacheGroups: { - default: false, - defaultVendors: false, - shared: { - name: 'shared', - test: - extraction === 'shared' - ? /(?:header\.js|shared\.module\.css)$/ - : /shared\.module\.css$/, - chunks: 'async', - enforce: true, - }, - }, - }, - }, - }) - - expect( - clientBuild.chunksByFileName.get('alpha.js')?.dynamicImports, - ).toContain('lazy.js') - - for (const routeOrder of [ - ['alpha', 'beta'], - ['beta', 'alpha'], - ]) { - const manifest = buildStartManifest({ - clientBuild, - basePath: '/', - routeTreeRoutes: { - __root__: { children: routeOrder.map((route) => `/${route}`) }, - ...Object.fromEntries( - routeOrder.map((route) => [ - `/${route}`, - { filePath: join(directory, `${route}.js`) }, - ]), - ), - }, - }) - - expect(manifest.routes.__root__?.css).toEqual(['/index.css']) - for (const route of routeOrder) { - expect(manifest.routes[`/${route}`]?.css).toEqual([ - ...(extraction === 'none' ? [] : ['/shared.css']), - `/${route}.css`, - ]) - expect(manifest.routes[`/${route}`]?.preloads).toEqual([ - `/${route}.js`, - ...(extraction === 'shared' || extraction === 'css-module' - ? ['/shared.js'] - : []), - ]) - const headerCss = clientBuild.cssContentByFileName?.get( - extraction === 'none' ? `${route}.css` : 'shared.css', - ) - expect(headerCss).toMatch(/display:\s*grid/) - expect(headerCss).toMatch(/height:\s*64px/) - } - } - }, - ) - - test('preloads second-level static dependency chunks and orders their css first', async () => { - const { clientBuild, directory } = await buildClientFixture( - { - 'index.js': ` - import './root.css' - globalThis.routes = [ - () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), - () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), - ] - `, - 'alpha.js': ` - import { header } from './header.js' - import './alpha.css' - export const render = () => header() + 'alpha' - `, - 'beta.js': ` - import { header } from './header.js' - import './beta.css' - export const render = () => header() + 'beta' - `, - 'header.js': ` - import { format } from './format.js' - import './header.css' - export const header = () => format('header') - `, - 'format.js': ` - import './format.css' - export const format = (value) => '[' + value + ']' - `, - 'root.css': 'body { margin: 0; }', - 'alpha.css': '.alpha { color: red; }', - 'beta.css': '.beta { color: blue; }', - 'header.css': '.header { display: grid; }', - 'format.css': '.format { font-weight: bold; }', - }, - { - optimization: { - splitChunks: { - minSize: 0, - cacheGroups: { - default: false, - defaultVendors: false, - header: { - name: 'header', - test: /header\.(?:js|css)$/, - chunks: 'async', - enforce: true, - }, - format: { - name: 'format', - test: /format\.(?:js|css)$/, - chunks: 'async', - enforce: true, - }, - }, - }, - }, - }, - ) - - const manifest = buildStartManifest({ - clientBuild, - basePath: '/', - routeTreeRoutes: { - __root__: { children: ['/alpha', '/beta'] }, - '/alpha': { filePath: join(directory, 'alpha.js') }, - '/beta': { filePath: join(directory, 'beta.js') }, - }, - }) - - for (const route of ['alpha', 'beta']) { - expect(manifest.routes[`/${route}`]?.css).toEqual([ - '/format.css', - '/header.css', - `/${route}.css`, - ]) - expect(manifest.routes[`/${route}`]?.preloads).toEqual([ - `/${route}.js`, - '/header.js', - '/format.js', - ]) - } - }) - - test('keeps route css off the root when an initial vendor chunk is shared with route groups', async () => { - const { clientBuild, directory } = await buildClientFixture( - { - 'index.js': ` - import './root.css' - import { lib } from './lib.js' - globalThis.lib = lib() - globalThis.routes = [ - () => import(/* webpackChunkName: "root" */ './root.js?tsr-split=component'), - () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), - () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), - ] - `, - 'root.js': ` - import { lib2 } from './lib2.js' - import './shell.css' - export const render = () => lib2() + 'root' - `, - 'alpha.js': ` - import { lib2 } from './lib2.js' - import './alpha.css' - export const render = () => lib2() + 'alpha' - `, - 'beta.js': ` - import { lib } from './lib.js' - import { lib2 } from './lib2.js' - import './beta.css' - export const render = () => lib() + lib2() + 'beta' - `, - 'lib.js': `export const lib = () => 'lib'`, - 'lib2.js': `export const lib2 = () => 'lib2'`, - 'root.css': 'body { margin: 0; }', - 'shell.css': '.shell { display: flex; }', - 'alpha.css': '.alpha { color: red; }', - 'beta.css': '.beta { color: blue; }', - }, - { - optimization: { - splitChunks: { - minSize: 0, - cacheGroups: { - default: false, - defaultVendors: false, - vendor: { - name: 'vendor', - test: /lib2?\.js$/, - chunks: 'all', - enforce: true, - }, - }, - }, - }, - }, - ) - - // The vendor chunk belongs to the entry group and to every route group that - // uses it. Group membership must not pull route CSS into the root. - const manifest = buildStartManifest({ - clientBuild, - basePath: '/', - routeTreeRoutes: { - __root__: { - filePath: join(directory, 'root.js'), - children: ['/alpha', '/beta'], - }, - '/alpha': { filePath: join(directory, 'alpha.js') }, - '/beta': { filePath: join(directory, 'beta.js') }, - }, - }) - - expect(manifest.routes.__root__?.css).toEqual(['/root.css', '/index.css']) - expect(manifest.routes.__root__?.preloads).toEqual([ - '/root.js', - '/vendor.js', - '/index.js', - ]) - expect(manifest.routes['/alpha']?.css).toEqual(['/alpha.css']) - expect(manifest.routes['/alpha']?.preloads).toEqual(['/alpha.js']) - expect(manifest.routes['/beta']?.css).toEqual(['/beta.css']) - expect(manifest.routes['/beta']?.preloads).toEqual(['/beta.js']) - }) - - test('excludes tree-shaken imports even when the module is in an initial chunk', async () => { - const { clientBuild } = await buildClientFixture( - { - 'index.js': ` - import { unused } from './unused.js' - globalThis.value = unused - globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component') - `, - 'alpha.js': ` - import { unused } from './unused.js' - import './alpha.css' - export const render = () => 'alpha' - `, - 'unused.js': `export const unused = 'unused'`, - 'alpha.css': '.alpha { color: red; }', - }, - { - module: { - rules: [ - { test: /unused\.js$/, sideEffects: false }, - { test: /\.css$/, type: 'css' }, - ], - }, - }, - ) - - expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([]) - expect(clientBuild.chunksByFileName.get('alpha.js')?.css).toEqual([ - 'alpha.css', - ]) - }) - - test('uses a dependency in the parent group without importing its copy in another entry', async () => { - const { clientBuild } = await buildClientFixture( - { - 'index.js': ` - import { header } from './header.js' - globalThis.header = header - globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component') - `, - 'other.js': `import { header } from './header.js'; globalThis.otherHeader = header`, - 'alpha.js': `import { header } from './header.js'; export const render = () => header()`, - 'header.js': `import './header.css'; export const header = () => 'header'`, - 'header.css': '.header { display: grid; }', - }, - { entry: { index: './index.js', other: './other.js' } }, - ) - - expect(clientBuild.chunksByFileName.get('index.js')?.css).toEqual([ - 'index.css', - ]) - expect(clientBuild.chunksByFileName.get('other.js')?.css).toEqual([ - 'other.css', - ]) - expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([ - 'index.js', - ]) - }) - - test('collects nested CSS imports before the importing route stylesheet', async () => { - const { clientBuild, directory } = await buildClientFixture( - { - 'index.js': `globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component')`, - 'alpha.js': `import './alpha.css'; export const render = () => 'alpha'`, - 'alpha.css': '@import "./theme.css"; .alpha { color: red; }', - 'theme.css': '@import "./base.css"; .theme { color: blue; }', - 'base.css': '.base { color: green; }', - }, - { - optimization: { - splitChunks: { - minSize: 0, - cacheGroups: { - default: false, - defaultVendors: false, - theme: { - name: 'theme', - test: /theme\.css$/, - chunks: 'async', - enforce: true, - }, - base: { - name: 'base', - test: /base\.css$/, - chunks: 'async', - enforce: true, - }, - }, - }, - }, - }, - ) - const manifest = buildStartManifest({ - clientBuild, - basePath: '/', - routeTreeRoutes: { - __root__: { children: ['/alpha'] }, - '/alpha': { filePath: join(directory, 'alpha.js') }, - }, - }) - - expect(clientBuild.chunksByFileName.has('base.js')).toBe(false) - expect(clientBuild.chunksByFileName.has('theme.js')).toBe(false) - expect(manifest.routes['/alpha']?.css).toEqual([ - '/base.css', - '/theme.css', - '/alpha.css', - ]) - }) - - test('keeps route stylesheet links with inline CSS disabled by default', async () => { - const { clientBuild, directory } = await buildClientFixture( - stylesheetFiles, - {}, - normalizeRspackClientBuild, - ) - const manifest = buildStartManifest({ - clientBuild, - routeTreeRoutes: { - __root__: { children: ['/posts'] }, - '/posts': { filePath: join(directory, 'posts.js') }, - }, - basePath: '/assets', - }) - - expect(manifest.routes.__root__?.css).toEqual(['/assets/index.css']) - expect(manifest.routes['/posts']?.css).toEqual(['/assets/posts.css']) - expect(manifest.inlineCss).toBeUndefined() - }) - - test.each([ - { action: 'build', enabled: false, captureCss: false }, - { action: 'build', enabled: true, captureCss: true }, - { action: 'dev', enabled: false, captureCss: false }, - { action: 'dev', enabled: true, captureCss: false }, - ])( - 'captures inline CSS according to the action and resolved config ($action, $enabled)', - async ({ action, enabled, captureCss }) => { - const processAssets = vi.fn() - const getConfig = () => ({ - startConfig: { server: { build: { inlineCss: { enabled } } } }, - }) - const { getClientBuild } = registerClientBuildCapture( - { context: { action }, processAssets } as unknown as RsbuildPluginAPI, - getConfig as unknown as GetConfigFn, - ) - - const [, capture] = processAssets.mock.calls[0]! - const { clientBuild, directory } = await buildClientFixture( - stylesheetFiles, - {}, - (compilation) => { - capture({ compilation } as Parameters[0]) - return getClientBuild()! - }, - ) - const manifest = buildStartManifest({ - clientBuild, - routeTreeRoutes: { - __root__: { children: ['/posts'] }, - '/posts': { filePath: join(directory, 'posts.js') }, - }, - basePath: '/assets', - inlineCss: { enabled: captureCss, transformAssets: false }, - }) - - expect(manifest.routes.__root__?.css).toEqual(['/assets/index.css']) - expect(manifest.routes['/posts']?.css).toEqual(['/assets/posts.css']) - if (captureCss) { - expect(manifest.inlineCss?.styles).toEqual({ - '/assets/index.css': expect.stringContaining('/assets/dot.svg'), - '/assets/posts.css': expect.stringContaining('/assets/dot.svg'), - }) - } else { - expect(manifest.inlineCss).toBeUndefined() - } - }, - ) - test('keys the route chunk by a POSIX path when rspack reports OS native module paths', () => { const compilation = makeWindowsCompilation(() => '.card{color:red}') const clientBuild = normalizeRspackClientBuild(compilation) diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index 39b3e69a9d0..bf58d2ebaae 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, mergeConfig } from 'vitest/config' +import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import packageJson from './package.json' @@ -7,7 +7,29 @@ const config = defineConfig({ name: packageJson.name, dir: './tests', watch: false, - typecheck: { enabled: true }, + projects: [ + { + // Fast tier (`test:unit`): compiles and evaluates in process; a Vite + // dev server in middleware mode is allowed. + extends: true, + test: { + name: 'unit', + exclude: [...configDefaults.exclude, 'integration/**'], + typecheck: { enabled: true }, + }, + }, + { + // Slow tier (`test:integration`): real builds, Rsbuild dev servers, + // child Node runs and browsers. + extends: true, + test: { + name: 'integration', + include: ['integration/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + }, + }, + ], }, }) From 78eeddbb362a7bad4710268552a88fb8883cc034 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 19:13:03 +0200 Subject: [PATCH 0414/1425] test(compiler): split the regression helper modules by section Mechanical moves, so that the owners of the next harness changes do not edit one shared file: router-plugin's regression-helpers.ts keeps the evaluation helpers and buildAndRun and re-exports plugin-host.ts (the in-process host of the code-splitter, generator and route HMR plugins), lazy-tracking.ts (loadRoute and its router stubs) and hmr-model.ts (hotUpdate); start-plugin-core's keeps the compiler and runtime helpers and re-exports imports.ts, diagnostics.ts, source-maps.ts and render.ts (the chunk helpers). Every name the old modules exported is still exported from them (checked with the TypeScript checker; start-plugin-core now also exports createTestModuleGraph and moduleId, which render.ts imports). No code changed. Co-Authored-By: Claude Opus 5.5 --- packages/router-plugin/tests/hmr-model.ts | 299 ++++ packages/router-plugin/tests/lazy-tracking.ts | 283 ++++ packages/router-plugin/tests/plugin-host.ts | 773 ++++++++++ .../router-plugin/tests/regression-helpers.ts | 1364 +---------------- .../start-plugin-core/tests/diagnostics.ts | 131 ++ packages/start-plugin-core/tests/imports.ts | 160 ++ .../tests/regression-helpers.ts | 870 +---------- packages/start-plugin-core/tests/render.ts | 523 +++++++ .../start-plugin-core/tests/source-maps.ts | 70 + 9 files changed, 2313 insertions(+), 2160 deletions(-) create mode 100644 packages/router-plugin/tests/hmr-model.ts create mode 100644 packages/router-plugin/tests/lazy-tracking.ts create mode 100644 packages/router-plugin/tests/plugin-host.ts create mode 100644 packages/start-plugin-core/tests/diagnostics.ts create mode 100644 packages/start-plugin-core/tests/imports.ts create mode 100644 packages/start-plugin-core/tests/render.ts create mode 100644 packages/start-plugin-core/tests/source-maps.ts diff --git a/packages/router-plugin/tests/hmr-model.ts b/packages/router-plugin/tests/hmr-model.ts new file mode 100644 index 00000000000..6a84db509b3 --- /dev/null +++ b/packages/router-plugin/tests/hmr-model.ts @@ -0,0 +1,299 @@ +/** + * `hotUpdate`: runs the route HMR code of a compiled route file against a + * hot API and a live router, in process. Re-exported by + * `regression-helpers.ts`. + */ +import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' +import { + BaseRootRoute, + BaseRoute, + RouterCore, + createNonReactiveMutableStore, + createNonReactiveReadonlyStore, +} from '@tanstack/router-core' +import { vi } from 'vitest' +import { createMemoryHistory } from '../../history/src' +import type { Config } from '../src/core/config' +import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' +import { + compileRouteGraph, + routeFile, + transformWithRouteHmrPlugin, + webpackCompiler, +} from './plugin-host' +import type { RouteGraph, TargetFramework } from './plugin-host' +import { createRouterStub, stubExports } from './lazy-tracking' +import type { Stubs } from './lazy-tracking' + +const getStoreConfig: GetStoreConfig = () => ({ + createMutableStore: createNonReactiveMutableStore, + createReadonlyStore: createNonReactiveReadonlyStore, + batch: (fn) => fn(), +}) + +export type HotUpdateOptions = { + /** The bundler HMR API the route is compiled for and run with. Default `vite`. */ + style?: 'vite' | 'webpack' + /** + * Compile with automatic code splitting (the code splitter with HMR, its + * modules linked like `loadRoute`) instead of the route HMR plugin alone. + */ + codeSplitting?: boolean + /** Default `react`. */ + targetFramework?: TargetFramework + /** + * 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. */ + updated?: string + /** + * Route id the route file was registered under when the first version + * was compiled, when the route tree registers it under another one since + * (`routeId`), e.g. after a route configuration change. Default `routeId`. + */ + previousRouteId?: string + /** The route file's other imports, by specifier. */ + stubs?: Stubs +} + +/** A version of the route file as the bundler compiled it. */ +type CompiledRouteFile = Pick + +/** + * Runs the route HMR code the router plugin emits, on its own: compiles a + * route file for the dev server, loads the route module (one hot context per + * module, its `data` kept across the update), mounts its `Route` in a route + * tree as the generated route tree does and creates a router with it + * (`window.__TSR_ROUTER__`), then loads the modules of the edited file + * (`updated`) and gives each module that accepted its own updates its new + * version, per the bundler's hot API (Vite: its accept callbacks receive the + * new module; webpack: the module runs again with the data its dispose + * handlers wrote). + * + * Which modules a bundler updates, and what the page shows after an update, + * are not modelled here: tests of those run a real dev server and a real + * page (`route-hmr-page.ts`). + * + * Returns the options of the live route before (`previous`) and after + * (`live`) the update, a copy of the options each module load created a + * route with (`created`), whether a module asked the bundler for a full + * reload (`invalidated`), the `window` the modules ran with, and + * `load(option)`, which resolves a lazy option of the live route to what its + * module provides. + */ +export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { + const { + style = 'vite', + codeSplitting = false, + targetFramework = 'react', + routeId = '/', + } = options + const pluginOptions: Partial = { + target: targetFramework, + plugin: { hmr: { style } }, + } + const compiler = + style === 'webpack' + ? webpackCompiler(targetFramework === 'react') + : undefined + const compile = ( + source: string, + compiledRouteId = routeId, + ): CompiledRouteFile => { + if (codeSplitting) { + return compileRouteGraph(source, { + targetFramework, + hmr: true, + routeId: compiledRouteId, + pluginOptions, + webpackCompiler: compiler, + }) + } + const file = routeFile('index') + const compiled = transformWithRouteHmrPlugin( + source, + pluginOptions, + { file, routeId: compiledRouteId }, + compiler, + ) + return { + id: file, + modules: new Map([[file, compiled]]), + resolve: () => undefined, + } + } + const first = compile(code, options.previousRouteId) + const second = + options.updated === undefined && options.previousRouteId === undefined + ? first + : compile(options.updated ?? code) + + const created: Array> = [] + const lazy = new Map Promise>() + let invalidated = false + type HotState = { + data: Record + accepted: Array<(module: unknown) => void> + disposers: Array<(data: Record) => void> + } + const hotStates = new Map() + const hotState = (id: string) => { + let state = hotStates.get(id) + if (!state) { + state = { data: {}, accepted: [], disposers: [] } + hotStates.set(id, state) + } + return state + } + const hotContext = (id: string) => { + const state = hotState(id) + return { + data: state.data, + accept: (callback?: (module: unknown) => void) => { + if (callback) { + state.accepted.push(callback) + } + }, + dispose: (callback: (data: Record) => void) => { + state.disposers.push(callback) + }, + invalidate: () => { + invalidated = true + }, + } + } + + const recording = + (create: (options: Record) => unknown) => + (routeOptions: Record = {}) => { + // A copy: the HMR code writes kept options into the new ones. + created.push({ ...routeOptions }) + return create(routeOptions) + } + const createRoute = (routeOptions: Record) => { + const route = new BaseRoute(routeOptions as never) as unknown as { + isRoot: boolean + } + route.isRoot = false + return route + } + // 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 routerPackage = `@tanstack/${targetFramework}-router` + // The route classes of the framework packages add hooks to the router-core + // ones; route HMR only uses what router-core provides. + const routerStub: Record = { + ...createRouterStub(lazy), + createFileRoute: () => recording(createRoute), + createLazyFileRoute: () => recording(createRoute), + createRootRoute: recording(createRootRoute), + createRootRouteWithContext: () => recording(createRootRoute), + ...stubExports(options.stubs?.[routerPackage]), + } + const load = (compiled: CompiledRouteFile) => + createModuleGraph({ + modules: Object.fromEntries(compiled.modules), + resolve: compiled.resolve, + stubs: { ...options.stubs, [routerPackage]: routerStub }, + importMeta: (id) => ({ + [style === 'webpack' ? 'webpackHot' : 'hot']: hotContext(id), + }), + }) + + const window: { __TSR_ROUTER__?: RouterCore } = {} + vi.stubGlobal('window', window) + try { + const firstGraph = load(first) + const firstModule = await firstGraph.import(first.id) + const isRoot = rootRoutes.has(firstModule.Route as object) + let routeTree: AnyRoute + if (isRoot) { + routeTree = firstModule.Route as AnyRoute + } else { + const rootRoute = new BaseRootRoute({}) + const route = (firstModule.Route as AnyRoute).update({ + id: routeId, + path: routeId, + getParentRoute: () => rootRoute, + } as never) + routeTree = rootRoute.addChildren([route]) as unknown as AnyRoute + } + const router = new RouterCore( + { + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + origin: 'http://localhost', + }, + 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 { + options: Record + } + ).options + const previous = { ...liveOptions() } + + if (style === 'webpack') { + for (const state of hotStates.values()) { + const next: Record = {} + state.disposers.forEach((dispose) => dispose(next)) + state.data = next + state.disposers = [] + } + } + const accepted = new Map( + [...hotStates].map(([id, state]) => [id, state.accepted]), + ) + for (const state of hotStates.values()) { + state.accepted = [] + } + const secondGraph = load(second) + await secondGraph.import(second.id) + // The route module's update first, then the other modules that accept + // their own updates, each with its new module. + const updates = [...accepted].sort( + ([a], [b]) => Number(b === first.id) - Number(a === first.id), + ) + for (const [id, callbacks] of updates) { + if (callbacks.length === 0 || !second.modules.has(id)) { + continue + } + const updatedModule = await secondGraph.import(id) + callbacks.forEach((accept) => accept(updatedModule)) + } + + const live = liveOptions() + return { + /** Id of the route file, as the plugin saw it. */ + file: first.id, + previous, + live, + created, + invalidated, + /** The `window` the modules ran with (the router is `__TSR_ROUTER__`). */ + window, + /** Whether an option of the live route loads lazily from another module. */ + isLazy: (option: string) => lazy.has(live[option]), + /** The value of an option of the live route, once its lazy module loaded. */ + load: (option: string): Promise => { + const loader = lazy.get(live[option]) + return loader ? loader() : Promise.resolve(live[option]) + }, + } + } finally { + vi.unstubAllGlobals() + } +} diff --git a/packages/router-plugin/tests/lazy-tracking.ts b/packages/router-plugin/tests/lazy-tracking.ts new file mode 100644 index 00000000000..d2253afa2d4 --- /dev/null +++ b/packages/router-plugin/tests/lazy-tracking.ts @@ -0,0 +1,283 @@ +/** + * Loads a compiled route with stubs of the router package that track which + * options load lazily (`loadRoute`), and the stubs it links the route + * modules with. Re-exported by `regression-helpers.ts`. + */ +import { + createModuleGraph, + jsxPragma, + moduleLang, +} from '@tanstack/compiler-test-utils/module-linker' +import * as React from 'react' +import { transformWithOxc } from 'vite' +import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' +import { createRouteGraphCompiler, labelRouteModules } from './plugin-host' +import type { CompileOptions, RouteGraph } from './plugin-host' + +/** + * Modules provided by the test, by import specifier: their exports, or the + * source of a JSON module (`.json` specifiers). + */ +export type Stubs = Record + +/** The exports of an object stub; a source stub has none to merge. */ +export function stubExports(stub: ModuleStub | undefined) { + return typeof stub === 'object' ? stub : undefined +} + +/** + * Compiles a route file, evaluates its route module like `loadRoute` (every + * import the route file makes is stubbed by identity functions, unless + * `stubs` gives it) and returns the code of the module its `component` + * option loads lazily. + */ +export async function componentChunk( + code: string, + options: LoadRouteOptions = {}, +) { + const route = await loadRoute(code, { stubUnknownImports: true, ...options }) + return route.chunkOf('component') +} + +/** + * Exports of a router package, as much of them as route modules use: routes + * keep their options, and lazy route options (`lazyRouteComponent`, + * `lazyFn`) load their module when called or preloaded, like the real ones. + * `lazy` collects the loader of every lazy value created. + */ +export function createRouterStub(lazy: Map Promise>) { + const route = (options: unknown) => ({ options }) + return { + createFileRoute: () => route, + createLazyFileRoute: () => route, + createRootRoute: route, + createRootRouteWithContext: () => route, + createRoute: route, + Outlet: () => null, + lazyRouteComponent: ( + importer: () => Promise>, + exportName = 'default', + ) => { + let component: ((props: unknown) => unknown) | undefined + const load = async () => { + component = (await importer())[exportName] + return component + } + const Lazy = Object.assign( + (props: unknown) => { + if (!component) { + throw new Error('a lazy route component rendered before it loaded') + } + return component(props) + }, + { preload: load }, + ) + lazy.set(Lazy, load) + return Lazy + }, + lazyFn: (importer: () => Promise>, key = 'default') => { + const fn = async (...args: Array) => + (await importer())[key](...args) + lazy.set(fn, async () => (await importer())[key]) + return fn + }, + } +} + +const routerPackages = [ + '@tanstack/react-router', + '@tanstack/solid-router', + '@tanstack/vue-router', +] + +/** Stubs of the router packages, merged with the test's own stubs. */ +export function withRouterStubs( + stubs: Stubs, + lazy: Map Promise>, +): Stubs { + const linked: Stubs = { ...stubs } + for (const name of routerPackages) { + linked[name] = { ...createRouterStub(lazy), ...stubExports(stubs[name]) } + } + return linked +} + +/** + * Compiles a module with the React Refresh transform `@vitejs/plugin-react` + * uses, erasing TypeScript like the linker's default transform (see + * `verbatimModuleSyntax` of `createModuleGraph`). + */ +export async function transformWithReactRefresh( + code: string, + id: string, + verbatimModuleSyntax = true, +) { + const result = await transformWithOxc(code, 'module', { + lang: moduleLang(id), + jsx: { runtime: 'classic', ...jsxPragma, refresh: {} }, + typescript: { onlyRemoveTypeImports: verbatimModuleSyntax }, + }) + return result.code +} + +/** + * Records React Refresh registrations the way the refresh runtime receives + * them: `$RefreshReg$(type, name)` for each component the transform + * registers. + */ +export function refreshScope(registered: Map) { + return { + $RefreshReg$: (type: unknown, name: string) => { + registered.set(type, name) + }, + $RefreshSig$: () => (type: unknown) => type, + } +} + +type LoadRouteOptions = CompileOptions & { + /** The route file's other imports, by specifier. */ + stubs?: Stubs + /** + * Compile every module with the React Refresh transform and record what it + * registers in `registered`. + */ + refresh?: boolean + /** + * Link every import that neither `stubs` nor the emitted modules provide + * to a module whose exports are identity functions, for tests that only + * inspect the emitted modules. + */ + stubUnknownImports?: boolean + /** + * Erase TypeScript like a project with `verbatimModuleSyntax` (default + * `true`: unused value imports still load) or without it (see + * `createModuleGraph`). + */ + verbatimModuleSyntax?: boolean +} + +/** Import specifier of the route file in the modules `importFromRoute` adds. */ +export const routeSpecifier = '#route' + +/** A module stub whose exports are identity functions. */ +const identityStub = (_: string, names: Set) => + Object.fromEntries( + [...names].map((name) => [name, (value: unknown) => value]), + ) + +/** + * Compiles a route file like `compileRouteModules`, then evaluates it the + * way an app does: the route module (and what it imports statically) first, + * and each lazy module only when a route option loads it. Imports of the + * emitted modules link to each other; other imports link to `stubs` (the + * router package is stubbed). Route options are read from the evaluated + * `Route`; `load(option)` resolves a lazy option to what its module provides. + */ +export async function loadRoute(code: string, options: LoadRouteOptions = {}) { + return createRouteLoader(options)(code) +} + +/** + * Returns a function that loads a version of the route file like + * `loadRoute`, every version compiled by the same code-splitter plugin + * instance, as a dev server compiles each edit of a route file. + */ +export function createRouteLoader(options: LoadRouteOptions = {}) { + const compile = createRouteGraphCompiler(options) + return (code: string) => linkRoute(code, compile(code), options) +} + +async function linkRoute( + code: string, + graph: RouteGraph, + options: LoadRouteOptions, +) { + const lazy = new Map Promise>() + const registered = new Map() + const moduleGraph = createModuleGraph({ + modules: Object.fromEntries(graph.modules), + stubs: withRouterStubs( + options.refresh + ? { react: React, ...options.stubs } + : (options.stubs ?? {}), + lazy, + ), + resolve: (specifier, importer) => + specifier === routeSpecifier + ? graph.id + : graph.resolve(specifier, importer), + ...(options.stubUnknownImports && { fallbackStub: identityStub }), + verbatimModuleSyntax: options.verbatimModuleSyntax, + ...(options.refresh && { + scope: () => refreshScope(registered), + transform: (code: string, id: string) => + transformWithReactRefresh(code, id, options.verbatimModuleSyntax), + }), + }) + const exports = await moduleGraph.import(graph.id) + const Route = exports.Route as { options: Record } | undefined + if (!Route) { + throw new Error('the route module does not export `Route`') + } + // The module each lazy option loads: what its loader imports, found + // without evaluating it. + const lazyModules = new Map>() + const lazyOptions = new Map>() + for (const option of Object.keys(Route.options)) { + const loader = lazy.get(Route.options[option]) + if (!loader) { + continue + } + const ids = moduleGraph + .probeDynamicImports(loader) + .filter((id) => graph.modules.has(id)) + lazyModules.set(option, ids) + for (const id of ids) { + lazyOptions.set(id, [...(lazyOptions.get(id) ?? []), option]) + } + } + const compiled = labelRouteModules(code, graph, lazyOptions) + let importers = 0 + return { + ...compiled, + /** Exports of the route module. */ + exports, + Route, + options: Route.options, + /** Ids of the modules evaluated so far, in evaluation order. */ + evaluated: moduleGraph.evaluated, + /** Whether a route option loads lazily from another module. */ + isLazy: (option: string) => lazy.has(Route.options[option]), + /** The value of a route option, once its lazy module (if any) loaded. */ + load: (option: string): Promise => { + const value = Route.options[option] + const loader = lazy.get(value) + return loader ? loader() : Promise.resolve(value) + }, + /** + * Code of the module a lazy route option loads (found by what its + * loader imports); fails for an option that is not lazy. + */ + chunkOf: (option: string): string => { + const ids = lazyModules.get(option) + if (!ids || ids.length !== 1) { + throw new Error( + `\`${option}\` does not load one module of the route lazily (it loads ${JSON.stringify(ids ?? [])})`, + ) + } + return graph.modules.get(ids[0]!)! + }, + /** + * Evaluates `source` as another module of the app, next to the route + * module, and returns its exports: it imports the route file as + * `routeSpecifier` (`'#route'`) and gets the instance the route uses. + */ + importFromRoute: (source: string) => { + const id = `/importer-${++importers}.tsx` + moduleGraph.add(id, source) + return moduleGraph.import(id) + }, + /** Names React Refresh registered each component under (`refresh`). */ + registered, + } +} diff --git a/packages/router-plugin/tests/plugin-host.ts b/packages/router-plugin/tests/plugin-host.ts new file mode 100644 index 00000000000..3ae904dd4d7 --- /dev/null +++ b/packages/router-plugin/tests/plugin-host.ts @@ -0,0 +1,773 @@ +/** + * Hosts the router plugin's code-splitter, generator and route HMR plugins + * in process, the way a bundler calls them: compile a route file into its + * route module and split chunks, follow their imports, and inspect an + * emitted module. Re-exported by `regression-helpers.ts`. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { moduleLang } from '@tanstack/compiler-test-utils/module-linker' +import { + freeIdentifiers, + getModuleErrors, +} from '@tanstack/compiler-test-utils/validate-module' +import { parseSync } from 'vite' +import { expect } from 'vitest' +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' +import type { Config, DeletableNodes } from '../src/core/config' +import type { TransformResult, UnpluginOptions } from 'unplugin' + +const harnessDir = path.dirname(fileURLToPath(import.meta.url)) + +/** A framework the router plugin targets. */ +export type TargetFramework = 'react' | 'solid' | 'vue' + +export type CompileOptions = { + /** Name of the route file; it is compiled as `/`. */ + filename?: string + /** + * Module id of the route file as a bundler passes it, instead of + * `/`: e.g. under another project root, or a Windows path + * (`C:/app/…` from Vite, `C:\\app\\…` from webpack and Rspack). The + * route is registered under its forward-slash form. + */ + moduleId?: string + targetFramework?: TargetFramework + /** Compile for the dev server: route HMR and the framework's HMR code. */ + hmr?: boolean + /** + * Route options removed from the output, as Start configures the client + * (`['ssr', 'server', 'headers']`). + */ + deleteNodes?: Array + /** Route id the route file is registered under. Default `/`. */ + routeId?: string + /** + * Router plugin options over the harness's, e.g. `codeSplittingOptions` + * (`defaultBehavior`, `splitBehavior`; merged with `hmr` and + * `deleteNodes`) or `plugin.hmr.style`. + */ + pluginOptions?: Partial + /** + * The Vite command the plugin is configured for. Default `serve` with + * `hmr`, else `build` (which the plugin takes for a production build). + */ + command?: 'serve' | 'build' + /** + * Configure the plugins for webpack instead of Vite: their `webpack` hook + * gets this compiler (see `webpackCompiler`) instead of their Vite + * `configResolved` hook getting a Vite config. + */ + webpackCompiler?: WebpackCompilerStub +} + +/** What the plugins' `webpack(compiler)` hook can read of a compiler. */ +export type WebpackCompilerStub = { + options: { mode: 'development' | 'production'; plugins: Array } +} + +/** + * The React Refresh webpack plugin (`@pmmmwh/react-refresh-webpack-plugin`) + * as webpack's configuration lists it: an instance of `ReactRefreshPlugin`. + */ +class ReactRefreshPlugin { + apply() {} +} + +/** + * A webpack development compiler whose configuration has the React Refresh + * plugin (`refresh`) or no refresh plugin, the way a webpack or Rspack app + * without React Refresh is configured. + */ +export function webpackCompiler(refresh: boolean): WebpackCompilerStub { + return { + options: { + mode: 'development', + plugins: refresh ? [new ReactRefreshPlugin()] : [], + }, + } +} + +/** + * Configures plugins the way a bundler does before any transform: Vite's + * `configResolved` with `vite`, or webpack's plugin hook with a compiler. + */ +function configurePlugins( + plugins: Array, + bundler: { vite: Record } | { webpack: WebpackCompilerStub }, +) { + for (const plugin of plugins) { + if ('webpack' in bundler) { + plugin.webpack?.(bundler.webpack as never) + } else { + sync( + hookHandler(plugin.vite?.configResolved)?.call( + pluginContext, + bundler.vite as never, + ), + 'configResolved', + ) + } + } +} + +/** + * The id the harness compiles a route file under: an absolute path, as + * bundlers pass module ids to plugins. + */ +export function routeModuleId(filename = 'route.tsx') { + return normalizePath(path.resolve('/', filename)) +} + +/** Splits a module id or specifier into its path and its query. */ +function splitQuery(id: string): [string, string | undefined] { + const index = id.indexOf('?') + return index === -1 + ? [id, undefined] + : [id.slice(0, index), id.slice(index + 1)] +} + +/** The handler of a plugin hook given as a function or as `{ handler }`. */ +function hookHandler( + hook: unknown, +): ((...args: Array) => any) | undefined { + if (typeof hook === 'function') { + return hook as (...args: Array) => any + } + if (hook && typeof hook === 'object' && 'handler' in hook) { + return (hook as { handler: (...args: Array) => any }).handler + } + return undefined +} + +/** Whether `value` matches an unplugin hook filter pattern. */ +function matchesPattern(pattern: unknown, value: string): boolean { + if (pattern instanceof RegExp) { + pattern.lastIndex = 0 + return pattern.test(value) + } + if (Array.isArray(pattern)) { + return pattern.some((item) => matchesPattern(item, value)) + } + if (pattern && typeof pattern === 'object') { + const { include, exclude } = pattern as { + include?: unknown + exclude?: unknown + } + if (exclude !== undefined && matchesPattern(exclude, value)) { + return false + } + return include === undefined || matchesPattern(include, value) + } + throw new Error(`unsupported hook filter: ${String(pattern)}`) +} + +function passesFilter(hook: unknown, id: string, code?: string) { + const filter = + hook && typeof hook === 'object' && 'filter' in hook + ? (hook.filter as { id?: unknown; code?: unknown } | undefined) + : undefined + return ( + (filter?.id === undefined || matchesPattern(filter.id, id)) && + (filter?.code === undefined || + code === undefined || + matchesPattern(filter.code, code)) + ) +} + +/** Fails on a hook result a synchronous harness cannot wait for. */ +function sync(result: T, hook: string): T { + if (result instanceof Promise) { + throw new Error(`the harness only drives synchronous ${hook} hooks`) + } + return result +} + +/** The `this` of plugin hooks: warnings go to the console, like Vite's. */ +const pluginContext = { + warn: (message: unknown) => console.warn(message), + error: (message: unknown) => { + throw message instanceof Error ? message : new Error(String(message)) + }, +} + +/** + * Imports and re-exports of a module, static or dynamic (`import()`), by + * specifier, in source order (static ones first). `id` gives the module's + * language. + */ +export function importRequests(code: string, id = 'module.tsx') { + const { module } = parseSync(`module.${moduleLang(id)}`, code, { + sourceType: 'module', + }) + const requests: Array<{ specifier: string; dynamic: boolean }> = [] + for (const statement of module.staticImports) { + requests.push({ specifier: statement.moduleRequest.value, dynamic: false }) + } + for (const statement of module.staticExports) { + for (const entry of statement.entries) { + if (entry.moduleRequest) { + requests.push({ specifier: entry.moduleRequest.value, dynamic: false }) + } + } + } + for (const dynamicImport of module.dynamicImports) { + const source = code.slice( + dynamicImport.moduleRequest.start, + dynamicImport.moduleRequest.end, + ) + // A string literal, or a template literal without substitutions. + const literal = /^(?:(["'])([^"'\\]*)\1|`((?:[^`\\$]|\$(?!\{))*)`)$/.exec( + source, + ) + if (!literal) { + throw new Error(`cannot follow the dynamic import ${source}`) + } + requests.push({ specifier: (literal[2] ?? literal[3])!, dynamic: true }) + } + return requests +} + +/** + * What a bundler gets from compiling a route file with the code-splitter + * plugin: the route module and every module its output imports, statically + * or dynamically, that resolves to the route file or that a plugin resolves. + */ +export type RouteGraph = { + /** Id of the route module. */ + id: string + /** Code of each module, by id, in the order the imports reach them. */ + modules: Map + /** Whether a module is imported statically by some module, or only lazily. */ + importKinds: Map + /** Resolves an import of an emitted module to an emitted module's id. */ + resolve: (specifier: string, importer: string) => string | undefined +} + +export function compileRouteGraph( + code: string, + options: CompileOptions = {}, +): RouteGraph { + return createRouteGraphCompiler(options)(code) +} + +/** + * Creates one code-splitter plugin instance, configured like a bundler does, + * and returns a function that compiles a version of the route file with it + * (`compileRouteGraph`), as a dev server compiles each edit of the file. + */ +export function createRouteGraphCompiler( + options: CompileOptions = {}, +): (code: string) => RouteGraph { + const { + filename = 'route.tsx', + targetFramework = 'react', + hmr = false, + deleteNodes, + routeId = '/', + pluginOptions = {}, + command = hmr ? 'serve' : 'build', + webpackCompiler: compiler, + } = options + const id = options.moduleId ?? routeModuleId(filename) + const context = createRouterPluginContext() + context.routesByFile.set(normalizePath(id), { routeId }) + const created = createRouterCodeSplitterPlugin( + { + target: targetFramework, + autoCodeSplitting: true, + ...pluginOptions, + codeSplittingOptions: { + addHmr: hmr, + deleteNodes, + ...pluginOptions.codeSplittingOptions, + }, + }, + context, + ) + const plugins: Array = Array.isArray(created) + ? created + : [created] + configurePlugins( + plugins, + compiler + ? { webpack: compiler } + : { vite: { root: process.cwd(), command, plugins: [] } }, + ) + + const resolve = (specifier: string, importer: string) => { + for (const plugin of plugins) { + const hook = plugin.resolveId + if (!hookHandler(hook) || !passesFilter(hook, specifier)) { + continue + } + const resolved = sync( + hookHandler(hook)!.call(pluginContext, specifier, importer, {}), + 'resolveId', + ) + if (typeof resolved === 'string') { + return resolved + } + if (resolved && typeof resolved === 'object' && 'id' in resolved) { + return resolved.id as string + } + } + const [request, query] = splitQuery(specifier) + const importerPath = splitQuery(importer)[0] + // Windows ids from webpack and Rspack use backslashes + const paths = importerPath.includes('\\') ? path.win32 : path.posix + let file: string + if (/^(?:\/|[A-Za-z]:[\\/])/.test(request)) { + file = request + } else if (request.startsWith('./') || request.startsWith('../')) { + file = paths.join(paths.dirname(importerPath), request) + } else { + return undefined + } + // Only modules of the route file are compiled; other files are stubs. + if (normalizePath(file) !== normalizePath(id)) { + return undefined + } + return query === undefined ? id : `${id}?${query}` + } + + const load = (code: string, moduleId: string) => { + for (const plugin of plugins) { + const hook = plugin.load + if (!hookHandler(hook) || !passesFilter(hook, moduleId)) { + continue + } + const loaded = sync( + hookHandler(hook)!.call(pluginContext, moduleId), + 'load', + ) as string | { code: string } | null | undefined + if (loaded != null) { + return typeof loaded === 'string' ? loaded : loaded.code + } + } + // Bundlers read a module id with a query from the file it names. + if (splitQuery(moduleId)[0] === id) { + return code + } + throw new Error(`no plugin loads ${moduleId}`) + } + + const transform = (moduleId: string, source: string) => { + let current = source + for (const plugin of plugins) { + const hook = plugin.transform + if ( + !hookHandler(hook) || + !passesFilter(hook, moduleId, current) || + (plugin.transformInclude && !plugin.transformInclude(moduleId)) + ) { + continue + } + const result = sync( + hookHandler(hook)!.call(pluginContext, current, moduleId), + 'transform', + ) as TransformResult | null | undefined + if (result != null) { + current = typeof result === 'string' ? result : result.code + } + } + return current + } + + return (code) => { + const modules = new Map() + const importKinds = new Map() + const queue = [id] + while (queue.length > 0) { + const moduleId = queue.shift()! + if (modules.has(moduleId)) { + continue + } + const output = transform(moduleId, load(code, moduleId)) + modules.set(moduleId, output) + for (const { specifier, dynamic } of importRequests(output, moduleId)) { + const target = resolve(specifier, moduleId) + if (target === undefined) { + continue + } + if (!dynamic) { + importKinds.set(target, 'static') + } else if (!importKinds.has(target)) { + importKinds.set(target, 'dynamic') + } + queue.push(target) + } + } + return { id, modules, importKinds, resolve } + } +} + +/** Route file each `compileRouteModules` result was compiled from. */ +const routeSources = new WeakMap() + +/** + * Compiles a route file through the code-splitter plugin (default groupings, + * unless the route sets its own) and returns every module a bundler would + * load for it, found by following the imports of the output, without + * evaluating them. + * + * The modules are labelled `reference` (the route module), `shared` for a + * module another one imports statically, and `lazy ` for a module only + * imported lazily, in discovery order. Which route option loads which lazy + * module is only known once the route is evaluated: `loadRoute` labels them + * by option. `ids` gives the module id of each label. + */ +export function compileRouteModules( + code: string, + options: CompileOptions = {}, +) { + return labelRouteModules(code, compileRouteGraph(code, options)) +} + +/** + * Labels the modules of a compiled route file (see `compileRouteModules`): + * a lazy module is labelled `lazy ` after the route options whose + * loading imports it (`lazyOptions`, by module id), else `lazy `. + */ +export function labelRouteModules( + code: string, + graph: RouteGraph, + lazyOptions: Map> = new Map(), +) { + const modules: Record = {} + const ids: Record = {} + let unnamed = 0 + for (const [id, output] of graph.modules) { + let label: string + if (id === graph.id) { + label = 'reference' + } else if (graph.importKinds.get(id) === 'dynamic') { + const options = lazyOptions.get(id) + label = options ? `lazy ${options.sort().join('+')}` : `lazy ${++unnamed}` + } else { + label = 'shared' + } + if (label in modules) { + label = `${label} (${id})` + } + modules[label] = output + ids[label] = id + } + routeSources.set(modules, { code, id: graph.id }) + return { modules, ids, graph } +} + +/** + * Ids of the modules that load with the route module: the route module and + * every emitted module it imports statically, directly or through another + * one, in discovery order. (A module that a lazy chunk imports statically is + * not one of them unless the route module reaches it too.) + */ +export function eagerModuleIds({ graph }: { graph: RouteGraph }) { + const eager: Array = [] + const queue = [graph.id] + while (queue.length > 0) { + const id = queue.shift()! + if (eager.includes(id)) { + continue + } + eager.push(id) + for (const { specifier, dynamic } of importRequests( + graph.modules.get(id)!, + id, + )) { + const target = dynamic ? undefined : graph.resolve(specifier, id) + if (target !== undefined && graph.modules.has(target)) { + queue.push(target) + } + } + } + return eager +} + +/** + * Sources that the modules loading with the route module (`eagerModuleIds`) + * import or re-export statically, sorted: what loads with the entry, + * whichever emitted module requests it. + */ +export function eagerImportSources(route: { graph: RouteGraph }) { + return [ + ...new Set( + eagerModuleIds(route).flatMap((id) => + importRequests(route.graph.modules.get(id)!, id) + .filter(({ dynamic }) => !dynamic) + .map(({ specifier }) => specifier), + ), + ), + ].sort() +} + +/** How many times `pattern` occurs across all emitted modules. */ +export function countAcrossModules( + modules: Record, + pattern: RegExp, +) { + return Object.values(modules).reduce( + (count, code) => + count + (code.match(new RegExp(pattern.source, 'g'))?.length ?? 0), + 0, + ) +} + +/** + * Asserts that every module is valid, reporting the errors by module name. + * For the modules of `compileRouteModules`, the globals the route file reads + * itself may be read by its modules too. + */ +export async function expectValidModules(modules: Record) { + const source = routeSources.get(modules) + const filename = source ? `module.${moduleLang(source.id)}` : undefined + const globals = source + ? await freeIdentifiers(source.code, filename) + : undefined + const errors: Record> = {} + for (const [name, code] of Object.entries(modules)) { + errors[name] = await getModuleErrors(code, filename, { globals }) + } + expect(errors).toEqual( + Object.fromEntries(Object.keys(modules).map((name) => [name, []])), + ) +} + +function moduleRecord(code: string, id = 'module.tsx') { + return parseSync(`module.${moduleLang(id)}`, code, { sourceType: 'module' }) + .module +} + +/** Sources a module imports, side-effect imports included, sorted. */ +export function importSources(code: string) { + return [ + ...new Set( + moduleRecord(code).staticImports.map( + (statement) => statement.moduleRequest.value, + ), + ), + ].sort() +} + +/** + * Sources a module imports that name the compiled route file (`id`), with + * or without a query: the modules the code splitter emits for it. + */ +export function routeModuleSources(code: string, id = routeModuleId()) { + return importSources(code).filter((source) => splitQuery(source)[0] === id) +} + +/** + * The values a module imports from `source`, sorted: `default`, `*` for a + * namespace import, or the imported name. Type-only specifiers are skipped. + */ +export function importedNames(code: string, source: string) { + return moduleRecord(code) + .staticImports.filter( + (statement) => statement.moduleRequest.value === source, + ) + .flatMap((statement) => + statement.entries + .filter((entry) => !entry.isType) + .map((entry) => + entry.importName.kind === 'Default' + ? 'default' + : entry.importName.kind === 'NamespaceObject' + ? '*' + : entry.importName.name!, + ), + ) + .sort() +} + +/** + * Value names a module exports (`default` for a default export), sorted. + * `id` gives the module's language (TypeScript without JSX for `.ts`). + */ +export function exportedNames(code: string, id?: string) { + return moduleRecord(code, id) + .staticExports.flatMap((statement) => + statement.entries + .filter((entry) => !entry.isType && entry.exportName.kind !== 'None') + .map((entry) => entry.exportName.name ?? 'default'), + ) + .sort() +} + +/** Absolute, normalized path of a route file in the package's `src/routes`. */ +export function routeFile(name: string) { + return normalizePath(path.join(process.cwd(), `src/routes/${name}.tsx`)) +} + +function runTransform(plugin: UnpluginOptions, code: string, id: string) { + const transform = plugin.transform + if (!transform || typeof transform === 'function') { + throw new Error('Expected object transform') + } + const result = transform.handler.call(pluginContext as never, code, id) as + | TransformResult + | null + | undefined + if (!result) { + return null + } + return typeof result === 'string' ? result : result.code +} + +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' + +/** + * Drives the three code-splitter transforms the way a bundler does, for the + * given route files (absolute path to route id). `configPlugins` are the + * plugins of the resolved Vite config; by default the code splitter itself, + * so its plugin-order check runs. + */ +export async function createCodeSplitterTransforms( + options: Partial, + routes: Record, + configPlugins: Array<{ name: string }> = [{ name: referencePluginName }], +) { + 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: configPlugins, + } 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), + } +} + +/** + * 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`. + */ +export function transformWithRouteHmrPlugin( + code: string, + options: Partial = { target: 'react' }, + route: { file: string; routeId: string } = { + file: routeFile('index'), + routeId: '/', + }, + compiler?: WebpackCompilerStub, +) { + const output = createRouteHmrTransform( + options, + { + [route.file]: route.routeId, + }, + compiler, + )(code, route.file) + if (output === null) { + throw new Error('expected the route HMR plugin to transform the route') + } + return output +} + +/** + * The transform of one route HMR plugin instance (used when automatic code + * splitting is off) for the given route files (absolute path to route id). + * Returns `null` for a module it leaves as is. With `compiler`, the plugin is + * configured for webpack with it first. + */ +export function createRouteHmrTransform( + options: Partial, + routes: Record, + compiler?: WebpackCompilerStub, +) { + const context = createRouterPluginContext() + for (const [file, routeId] of Object.entries(routes)) { + context.routesByFile.set(file, { routeId }) + } + const plugins = createRouterHmrPlugin(options, context) + const plugin = Array.isArray(plugins) ? plugins[0]! : plugins + if (compiler) { + configurePlugins([plugin], { webpack: compiler }) + } + return (code: string, id: string) => runTransform(plugin, code, id) +} diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index b4b1553c0e5..f0ee95235c5 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -2,1066 +2,59 @@ * Helpers shared by the code-splitter and route HMR regression suites: compile * a route file through the code-splitter plugin the way a bundler does, * follow the imports of its output to every module it emits, evaluate those - * modules like a runtime, and inspect an emitted module. + * modules like a runtime, and inspect an emitted module. The plugin host + * (`plugin-host.ts`), `loadRoute` (`lazy-tracking.ts`) and `hotUpdate` + * (`hmr-model.ts`) live in their own modules and are re-exported here. */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { - createModuleGraph, - jsxPragma, - moduleLang, -} from '@tanstack/compiler-test-utils/module-linker' -import { - freeIdentifiers, - getModuleErrors, -} from '@tanstack/compiler-test-utils/validate-module' -import { - BaseRootRoute, - BaseRoute, - RouterCore, - createNonReactiveMutableStore, - createNonReactiveReadonlyStore, -} from '@tanstack/router-core' +import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' +import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import * as React from 'react' 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' +import { expect } from 'vitest' import { build, routeApp } from './build-harness' -import type { Config, DeletableNodes } from '../src/core/config' import type { CodeSplitGroupings } from '../src/core/constants' -import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' -import type { TransformResult, UnpluginOptions } from 'unplugin' -import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' import type { ESTree } from 'vite' +import { + refreshScope, + transformWithReactRefresh, + withRouterStubs, +} from './lazy-tracking' +import type { Stubs } from './lazy-tracking' +import { routeModuleId } from './plugin-host' + +export { + webpackCompiler, + routeModuleId, + importRequests, + compileRouteModules, + eagerModuleIds, + eagerImportSources, + countAcrossModules, + expectValidModules, + importSources, + routeModuleSources, + importedNames, + exportedNames, + routeFile, + createCodeSplitterTransforms, + generatedRouteId, + transformWithRouteHmrPlugin, + createRouteHmrTransform, +} from './plugin-host' +export type { TargetFramework, WebpackCompilerStub } from './plugin-host' +export { + componentChunk, + routeSpecifier, + loadRoute, + createRouteLoader, +} from './lazy-tracking' +export { hotUpdate } from './hmr-model' +export type { HotUpdateOptions } from './hmr-model' 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` -/** - * Modules provided by the test, by import specifier: their exports, or the - * source of a JSON module (`.json` specifiers). - */ -type Stubs = Record - -/** The exports of an object stub; a source stub has none to merge. */ -function stubExports(stub: ModuleStub | undefined) { - return typeof stub === 'object' ? stub : undefined -} - -/** A framework the router plugin targets. */ -export type TargetFramework = 'react' | 'solid' | 'vue' - -type CompileOptions = { - /** Name of the route file; it is compiled as `/`. */ - filename?: string - /** - * Module id of the route file as a bundler passes it, instead of - * `/`: e.g. under another project root, or a Windows path - * (`C:/app/…` from Vite, `C:\\app\\…` from webpack and Rspack). The - * route is registered under its forward-slash form. - */ - moduleId?: string - targetFramework?: TargetFramework - /** Compile for the dev server: route HMR and the framework's HMR code. */ - hmr?: boolean - /** - * Route options removed from the output, as Start configures the client - * (`['ssr', 'server', 'headers']`). - */ - deleteNodes?: Array - /** Route id the route file is registered under. Default `/`. */ - routeId?: string - /** - * Router plugin options over the harness's, e.g. `codeSplittingOptions` - * (`defaultBehavior`, `splitBehavior`; merged with `hmr` and - * `deleteNodes`) or `plugin.hmr.style`. - */ - pluginOptions?: Partial - /** - * The Vite command the plugin is configured for. Default `serve` with - * `hmr`, else `build` (which the plugin takes for a production build). - */ - command?: 'serve' | 'build' - /** - * Configure the plugins for webpack instead of Vite: their `webpack` hook - * gets this compiler (see `webpackCompiler`) instead of their Vite - * `configResolved` hook getting a Vite config. - */ - webpackCompiler?: WebpackCompilerStub -} - -/** What the plugins' `webpack(compiler)` hook can read of a compiler. */ -export type WebpackCompilerStub = { - options: { mode: 'development' | 'production'; plugins: Array } -} - -/** - * The React Refresh webpack plugin (`@pmmmwh/react-refresh-webpack-plugin`) - * as webpack's configuration lists it: an instance of `ReactRefreshPlugin`. - */ -class ReactRefreshPlugin { - apply() {} -} - -/** - * A webpack development compiler whose configuration has the React Refresh - * plugin (`refresh`) or no refresh plugin, the way a webpack or Rspack app - * without React Refresh is configured. - */ -export function webpackCompiler(refresh: boolean): WebpackCompilerStub { - return { - options: { - mode: 'development', - plugins: refresh ? [new ReactRefreshPlugin()] : [], - }, - } -} - -/** - * Configures plugins the way a bundler does before any transform: Vite's - * `configResolved` with `vite`, or webpack's plugin hook with a compiler. - */ -function configurePlugins( - plugins: Array, - bundler: { vite: Record } | { webpack: WebpackCompilerStub }, -) { - for (const plugin of plugins) { - if ('webpack' in bundler) { - plugin.webpack?.(bundler.webpack as never) - } else { - sync( - hookHandler(plugin.vite?.configResolved)?.call( - pluginContext, - bundler.vite as never, - ), - 'configResolved', - ) - } - } -} - -/** - * The id the harness compiles a route file under: an absolute path, as - * bundlers pass module ids to plugins. - */ -export function routeModuleId(filename = 'route.tsx') { - return normalizePath(path.resolve('/', filename)) -} - -/** Splits a module id or specifier into its path and its query. */ -function splitQuery(id: string): [string, string | undefined] { - const index = id.indexOf('?') - return index === -1 - ? [id, undefined] - : [id.slice(0, index), id.slice(index + 1)] -} - -/** The handler of a plugin hook given as a function or as `{ handler }`. */ -function hookHandler( - hook: unknown, -): ((...args: Array) => any) | undefined { - if (typeof hook === 'function') { - return hook as (...args: Array) => any - } - if (hook && typeof hook === 'object' && 'handler' in hook) { - return (hook as { handler: (...args: Array) => any }).handler - } - return undefined -} - -/** Whether `value` matches an unplugin hook filter pattern. */ -function matchesPattern(pattern: unknown, value: string): boolean { - if (pattern instanceof RegExp) { - pattern.lastIndex = 0 - return pattern.test(value) - } - if (Array.isArray(pattern)) { - return pattern.some((item) => matchesPattern(item, value)) - } - if (pattern && typeof pattern === 'object') { - const { include, exclude } = pattern as { - include?: unknown - exclude?: unknown - } - if (exclude !== undefined && matchesPattern(exclude, value)) { - return false - } - return include === undefined || matchesPattern(include, value) - } - throw new Error(`unsupported hook filter: ${String(pattern)}`) -} - -function passesFilter(hook: unknown, id: string, code?: string) { - const filter = - hook && typeof hook === 'object' && 'filter' in hook - ? (hook.filter as { id?: unknown; code?: unknown } | undefined) - : undefined - return ( - (filter?.id === undefined || matchesPattern(filter.id, id)) && - (filter?.code === undefined || - code === undefined || - matchesPattern(filter.code, code)) - ) -} - -/** Fails on a hook result a synchronous harness cannot wait for. */ -function sync(result: T, hook: string): T { - if (result instanceof Promise) { - throw new Error(`the harness only drives synchronous ${hook} hooks`) - } - return result -} - -/** The `this` of plugin hooks: warnings go to the console, like Vite's. */ -const pluginContext = { - warn: (message: unknown) => console.warn(message), - error: (message: unknown) => { - throw message instanceof Error ? message : new Error(String(message)) - }, -} - -/** - * Imports and re-exports of a module, static or dynamic (`import()`), by - * specifier, in source order (static ones first). `id` gives the module's - * language. - */ -export function importRequests(code: string, id = 'module.tsx') { - const { module } = parseSync(`module.${moduleLang(id)}`, code, { - sourceType: 'module', - }) - const requests: Array<{ specifier: string; dynamic: boolean }> = [] - for (const statement of module.staticImports) { - requests.push({ specifier: statement.moduleRequest.value, dynamic: false }) - } - for (const statement of module.staticExports) { - for (const entry of statement.entries) { - if (entry.moduleRequest) { - requests.push({ specifier: entry.moduleRequest.value, dynamic: false }) - } - } - } - for (const dynamicImport of module.dynamicImports) { - const source = code.slice( - dynamicImport.moduleRequest.start, - dynamicImport.moduleRequest.end, - ) - // A string literal, or a template literal without substitutions. - const literal = /^(?:(["'])([^"'\\]*)\1|`((?:[^`\\$]|\$(?!\{))*)`)$/.exec( - source, - ) - if (!literal) { - throw new Error(`cannot follow the dynamic import ${source}`) - } - requests.push({ specifier: (literal[2] ?? literal[3])!, dynamic: true }) - } - return requests -} - -/** - * What a bundler gets from compiling a route file with the code-splitter - * plugin: the route module and every module its output imports, statically - * or dynamically, that resolves to the route file or that a plugin resolves. - */ -type RouteGraph = { - /** Id of the route module. */ - id: string - /** Code of each module, by id, in the order the imports reach them. */ - modules: Map - /** Whether a module is imported statically by some module, or only lazily. */ - importKinds: Map - /** Resolves an import of an emitted module to an emitted module's id. */ - resolve: (specifier: string, importer: string) => string | undefined -} - -function compileRouteGraph( - code: string, - options: CompileOptions = {}, -): RouteGraph { - return createRouteGraphCompiler(options)(code) -} - -/** - * Creates one code-splitter plugin instance, configured like a bundler does, - * and returns a function that compiles a version of the route file with it - * (`compileRouteGraph`), as a dev server compiles each edit of the file. - */ -function createRouteGraphCompiler( - options: CompileOptions = {}, -): (code: string) => RouteGraph { - const { - filename = 'route.tsx', - targetFramework = 'react', - hmr = false, - deleteNodes, - routeId = '/', - pluginOptions = {}, - command = hmr ? 'serve' : 'build', - webpackCompiler: compiler, - } = options - const id = options.moduleId ?? routeModuleId(filename) - const context = createRouterPluginContext() - context.routesByFile.set(normalizePath(id), { routeId }) - const created = createRouterCodeSplitterPlugin( - { - target: targetFramework, - autoCodeSplitting: true, - ...pluginOptions, - codeSplittingOptions: { - addHmr: hmr, - deleteNodes, - ...pluginOptions.codeSplittingOptions, - }, - }, - context, - ) - const plugins: Array = Array.isArray(created) - ? created - : [created] - configurePlugins( - plugins, - compiler - ? { webpack: compiler } - : { vite: { root: process.cwd(), command, plugins: [] } }, - ) - - const resolve = (specifier: string, importer: string) => { - for (const plugin of plugins) { - const hook = plugin.resolveId - if (!hookHandler(hook) || !passesFilter(hook, specifier)) { - continue - } - const resolved = sync( - hookHandler(hook)!.call(pluginContext, specifier, importer, {}), - 'resolveId', - ) - if (typeof resolved === 'string') { - return resolved - } - if (resolved && typeof resolved === 'object' && 'id' in resolved) { - return resolved.id as string - } - } - const [request, query] = splitQuery(specifier) - const importerPath = splitQuery(importer)[0] - // Windows ids from webpack and Rspack use backslashes - const paths = importerPath.includes('\\') ? path.win32 : path.posix - let file: string - if (/^(?:\/|[A-Za-z]:[\\/])/.test(request)) { - file = request - } else if (request.startsWith('./') || request.startsWith('../')) { - file = paths.join(paths.dirname(importerPath), request) - } else { - return undefined - } - // Only modules of the route file are compiled; other files are stubs. - if (normalizePath(file) !== normalizePath(id)) { - return undefined - } - return query === undefined ? id : `${id}?${query}` - } - - const load = (code: string, moduleId: string) => { - for (const plugin of plugins) { - const hook = plugin.load - if (!hookHandler(hook) || !passesFilter(hook, moduleId)) { - continue - } - const loaded = sync( - hookHandler(hook)!.call(pluginContext, moduleId), - 'load', - ) as string | { code: string } | null | undefined - if (loaded != null) { - return typeof loaded === 'string' ? loaded : loaded.code - } - } - // Bundlers read a module id with a query from the file it names. - if (splitQuery(moduleId)[0] === id) { - return code - } - throw new Error(`no plugin loads ${moduleId}`) - } - - const transform = (moduleId: string, source: string) => { - let current = source - for (const plugin of plugins) { - const hook = plugin.transform - if ( - !hookHandler(hook) || - !passesFilter(hook, moduleId, current) || - (plugin.transformInclude && !plugin.transformInclude(moduleId)) - ) { - continue - } - const result = sync( - hookHandler(hook)!.call(pluginContext, current, moduleId), - 'transform', - ) as TransformResult | null | undefined - if (result != null) { - current = typeof result === 'string' ? result : result.code - } - } - return current - } - - return (code) => { - const modules = new Map() - const importKinds = new Map() - const queue = [id] - while (queue.length > 0) { - const moduleId = queue.shift()! - if (modules.has(moduleId)) { - continue - } - const output = transform(moduleId, load(code, moduleId)) - modules.set(moduleId, output) - for (const { specifier, dynamic } of importRequests(output, moduleId)) { - const target = resolve(specifier, moduleId) - if (target === undefined) { - continue - } - if (!dynamic) { - importKinds.set(target, 'static') - } else if (!importKinds.has(target)) { - importKinds.set(target, 'dynamic') - } - queue.push(target) - } - } - return { id, modules, importKinds, resolve } - } -} - -/** Route file each `compileRouteModules` result was compiled from. */ -const routeSources = new WeakMap() - -/** - * Compiles a route file through the code-splitter plugin (default groupings, - * unless the route sets its own) and returns every module a bundler would - * load for it, found by following the imports of the output, without - * evaluating them. - * - * The modules are labelled `reference` (the route module), `shared` for a - * module another one imports statically, and `lazy ` for a module only - * imported lazily, in discovery order. Which route option loads which lazy - * module is only known once the route is evaluated: `loadRoute` labels them - * by option. `ids` gives the module id of each label. - */ -export function compileRouteModules( - code: string, - options: CompileOptions = {}, -) { - return labelRouteModules(code, compileRouteGraph(code, options)) -} - -/** - * Labels the modules of a compiled route file (see `compileRouteModules`): - * a lazy module is labelled `lazy ` after the route options whose - * loading imports it (`lazyOptions`, by module id), else `lazy `. - */ -function labelRouteModules( - code: string, - graph: RouteGraph, - lazyOptions: Map> = new Map(), -) { - const modules: Record = {} - const ids: Record = {} - let unnamed = 0 - for (const [id, output] of graph.modules) { - let label: string - if (id === graph.id) { - label = 'reference' - } else if (graph.importKinds.get(id) === 'dynamic') { - const options = lazyOptions.get(id) - label = options ? `lazy ${options.sort().join('+')}` : `lazy ${++unnamed}` - } else { - label = 'shared' - } - if (label in modules) { - label = `${label} (${id})` - } - modules[label] = output - ids[label] = id - } - routeSources.set(modules, { code, id: graph.id }) - return { modules, ids, graph } -} - -/** - * Ids of the modules that load with the route module: the route module and - * every emitted module it imports statically, directly or through another - * one, in discovery order. (A module that a lazy chunk imports statically is - * not one of them unless the route module reaches it too.) - */ -export function eagerModuleIds({ graph }: { graph: RouteGraph }) { - const eager: Array = [] - const queue = [graph.id] - while (queue.length > 0) { - const id = queue.shift()! - if (eager.includes(id)) { - continue - } - eager.push(id) - for (const { specifier, dynamic } of importRequests( - graph.modules.get(id)!, - id, - )) { - const target = dynamic ? undefined : graph.resolve(specifier, id) - if (target !== undefined && graph.modules.has(target)) { - queue.push(target) - } - } - } - return eager -} - -/** - * Sources that the modules loading with the route module (`eagerModuleIds`) - * import or re-export statically, sorted: what loads with the entry, - * whichever emitted module requests it. - */ -export function eagerImportSources(route: { graph: RouteGraph }) { - return [ - ...new Set( - eagerModuleIds(route).flatMap((id) => - importRequests(route.graph.modules.get(id)!, id) - .filter(({ dynamic }) => !dynamic) - .map(({ specifier }) => specifier), - ), - ), - ].sort() -} - -/** How many times `pattern` occurs across all emitted modules. */ -export function countAcrossModules( - modules: Record, - pattern: RegExp, -) { - return Object.values(modules).reduce( - (count, code) => - count + (code.match(new RegExp(pattern.source, 'g'))?.length ?? 0), - 0, - ) -} - -/** - * Compiles a route file, evaluates its route module like `loadRoute` (every - * import the route file makes is stubbed by identity functions, unless - * `stubs` gives it) and returns the code of the module its `component` - * option loads lazily. - */ -export async function componentChunk( - code: string, - options: LoadRouteOptions = {}, -) { - const route = await loadRoute(code, { stubUnknownImports: true, ...options }) - return route.chunkOf('component') -} - -/** - * Asserts that every module is valid, reporting the errors by module name. - * For the modules of `compileRouteModules`, the globals the route file reads - * itself may be read by its modules too. - */ -export async function expectValidModules(modules: Record) { - const source = routeSources.get(modules) - const filename = source ? `module.${moduleLang(source.id)}` : undefined - const globals = source - ? await freeIdentifiers(source.code, filename) - : undefined - const errors: Record> = {} - for (const [name, code] of Object.entries(modules)) { - errors[name] = await getModuleErrors(code, filename, { globals }) - } - expect(errors).toEqual( - Object.fromEntries(Object.keys(modules).map((name) => [name, []])), - ) -} - -function moduleRecord(code: string, id = 'module.tsx') { - return parseSync(`module.${moduleLang(id)}`, code, { sourceType: 'module' }) - .module -} - -/** Sources a module imports, side-effect imports included, sorted. */ -export function importSources(code: string) { - return [ - ...new Set( - moduleRecord(code).staticImports.map( - (statement) => statement.moduleRequest.value, - ), - ), - ].sort() -} - -/** - * Sources a module imports that name the compiled route file (`id`), with - * or without a query: the modules the code splitter emits for it. - */ -export function routeModuleSources(code: string, id = routeModuleId()) { - return importSources(code).filter((source) => splitQuery(source)[0] === id) -} - -/** - * The values a module imports from `source`, sorted: `default`, `*` for a - * namespace import, or the imported name. Type-only specifiers are skipped. - */ -export function importedNames(code: string, source: string) { - return moduleRecord(code) - .staticImports.filter( - (statement) => statement.moduleRequest.value === source, - ) - .flatMap((statement) => - statement.entries - .filter((entry) => !entry.isType) - .map((entry) => - entry.importName.kind === 'Default' - ? 'default' - : entry.importName.kind === 'NamespaceObject' - ? '*' - : entry.importName.name!, - ), - ) - .sort() -} - -/** - * Value names a module exports (`default` for a default export), sorted. - * `id` gives the module's language (TypeScript without JSX for `.ts`). - */ -export function exportedNames(code: string, id?: string) { - return moduleRecord(code, id) - .staticExports.flatMap((statement) => - statement.entries - .filter((entry) => !entry.isType && entry.exportName.kind !== 'None') - .map((entry) => entry.exportName.name ?? 'default'), - ) - .sort() -} - -/** Absolute, normalized path of a route file in the package's `src/routes`. */ -export function routeFile(name: string) { - return normalizePath(path.join(process.cwd(), `src/routes/${name}.tsx`)) -} - -function runTransform(plugin: UnpluginOptions, code: string, id: string) { - const transform = plugin.transform - if (!transform || typeof transform === 'function') { - throw new Error('Expected object transform') - } - const result = transform.handler.call(pluginContext as never, code, id) as - | TransformResult - | null - | undefined - if (!result) { - return null - } - return typeof result === 'string' ? result : result.code -} - -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' - -/** - * Drives the three code-splitter transforms the way a bundler does, for the - * given route files (absolute path to route id). `configPlugins` are the - * plugins of the resolved Vite config; by default the code splitter itself, - * so its plugin-order check runs. - */ -export async function createCodeSplitterTransforms( - options: Partial, - routes: Record, - configPlugins: Array<{ name: string }> = [{ name: referencePluginName }], -) { - 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: configPlugins, - } 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), - } -} - -/** - * 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`. - */ -export function transformWithRouteHmrPlugin( - code: string, - options: Partial = { target: 'react' }, - route: { file: string; routeId: string } = { - file: routeFile('index'), - routeId: '/', - }, - compiler?: WebpackCompilerStub, -) { - const output = createRouteHmrTransform( - options, - { - [route.file]: route.routeId, - }, - compiler, - )(code, route.file) - if (output === null) { - throw new Error('expected the route HMR plugin to transform the route') - } - return output -} - -/** - * The transform of one route HMR plugin instance (used when automatic code - * splitting is off) for the given route files (absolute path to route id). - * Returns `null` for a module it leaves as is. With `compiler`, the plugin is - * configured for webpack with it first. - */ -export function createRouteHmrTransform( - options: Partial, - routes: Record, - compiler?: WebpackCompilerStub, -) { - const context = createRouterPluginContext() - for (const [file, routeId] of Object.entries(routes)) { - context.routesByFile.set(file, { routeId }) - } - const plugins = createRouterHmrPlugin(options, context) - const plugin = Array.isArray(plugins) ? plugins[0]! : plugins - if (compiler) { - configurePlugins([plugin], { webpack: compiler }) - } - return (code: string, id: string) => runTransform(plugin, code, id) -} - -/** - * Exports of a router package, as much of them as route modules use: routes - * keep their options, and lazy route options (`lazyRouteComponent`, - * `lazyFn`) load their module when called or preloaded, like the real ones. - * `lazy` collects the loader of every lazy value created. - */ -function createRouterStub(lazy: Map Promise>) { - const route = (options: unknown) => ({ options }) - return { - createFileRoute: () => route, - createLazyFileRoute: () => route, - createRootRoute: route, - createRootRouteWithContext: () => route, - createRoute: route, - Outlet: () => null, - lazyRouteComponent: ( - importer: () => Promise>, - exportName = 'default', - ) => { - let component: ((props: unknown) => unknown) | undefined - const load = async () => { - component = (await importer())[exportName] - return component - } - const Lazy = Object.assign( - (props: unknown) => { - if (!component) { - throw new Error('a lazy route component rendered before it loaded') - } - return component(props) - }, - { preload: load }, - ) - lazy.set(Lazy, load) - return Lazy - }, - lazyFn: (importer: () => Promise>, key = 'default') => { - const fn = async (...args: Array) => - (await importer())[key](...args) - lazy.set(fn, async () => (await importer())[key]) - return fn - }, - } -} - -const routerPackages = [ - '@tanstack/react-router', - '@tanstack/solid-router', - '@tanstack/vue-router', -] - -/** Stubs of the router packages, merged with the test's own stubs. */ -function withRouterStubs( - stubs: Stubs, - lazy: Map Promise>, -): Stubs { - const linked: Stubs = { ...stubs } - for (const name of routerPackages) { - linked[name] = { ...createRouterStub(lazy), ...stubExports(stubs[name]) } - } - return linked -} - -/** - * Compiles a module with the React Refresh transform `@vitejs/plugin-react` - * uses, erasing TypeScript like the linker's default transform (see - * `verbatimModuleSyntax` of `createModuleGraph`). - */ -async function transformWithReactRefresh( - code: string, - id: string, - verbatimModuleSyntax = true, -) { - const result = await transformWithOxc(code, 'module', { - lang: moduleLang(id), - jsx: { runtime: 'classic', ...jsxPragma, refresh: {} }, - typescript: { onlyRemoveTypeImports: verbatimModuleSyntax }, - }) - return result.code -} - -/** - * Records React Refresh registrations the way the refresh runtime receives - * them: `$RefreshReg$(type, name)` for each component the transform - * registers. - */ -function refreshScope(registered: Map) { - return { - $RefreshReg$: (type: unknown, name: string) => { - registered.set(type, name) - }, - $RefreshSig$: () => (type: unknown) => type, - } -} - -type LoadRouteOptions = CompileOptions & { - /** The route file's other imports, by specifier. */ - stubs?: Stubs - /** - * Compile every module with the React Refresh transform and record what it - * registers in `registered`. - */ - refresh?: boolean - /** - * Link every import that neither `stubs` nor the emitted modules provide - * to a module whose exports are identity functions, for tests that only - * inspect the emitted modules. - */ - stubUnknownImports?: boolean - /** - * Erase TypeScript like a project with `verbatimModuleSyntax` (default - * `true`: unused value imports still load) or without it (see - * `createModuleGraph`). - */ - verbatimModuleSyntax?: boolean -} - -/** Import specifier of the route file in the modules `importFromRoute` adds. */ -export const routeSpecifier = '#route' - -/** A module stub whose exports are identity functions. */ -const identityStub = (_: string, names: Set) => - Object.fromEntries( - [...names].map((name) => [name, (value: unknown) => value]), - ) - -/** - * Compiles a route file like `compileRouteModules`, then evaluates it the - * way an app does: the route module (and what it imports statically) first, - * and each lazy module only when a route option loads it. Imports of the - * emitted modules link to each other; other imports link to `stubs` (the - * router package is stubbed). Route options are read from the evaluated - * `Route`; `load(option)` resolves a lazy option to what its module provides. - */ -export async function loadRoute(code: string, options: LoadRouteOptions = {}) { - return createRouteLoader(options)(code) -} - -/** - * Returns a function that loads a version of the route file like - * `loadRoute`, every version compiled by the same code-splitter plugin - * instance, as a dev server compiles each edit of a route file. - */ -export function createRouteLoader(options: LoadRouteOptions = {}) { - const compile = createRouteGraphCompiler(options) - return (code: string) => linkRoute(code, compile(code), options) -} - -async function linkRoute( - code: string, - graph: RouteGraph, - options: LoadRouteOptions, -) { - const lazy = new Map Promise>() - const registered = new Map() - const moduleGraph = createModuleGraph({ - modules: Object.fromEntries(graph.modules), - stubs: withRouterStubs( - options.refresh - ? { react: React, ...options.stubs } - : (options.stubs ?? {}), - lazy, - ), - resolve: (specifier, importer) => - specifier === routeSpecifier - ? graph.id - : graph.resolve(specifier, importer), - ...(options.stubUnknownImports && { fallbackStub: identityStub }), - verbatimModuleSyntax: options.verbatimModuleSyntax, - ...(options.refresh && { - scope: () => refreshScope(registered), - transform: (code: string, id: string) => - transformWithReactRefresh(code, id, options.verbatimModuleSyntax), - }), - }) - const exports = await moduleGraph.import(graph.id) - const Route = exports.Route as { options: Record } | undefined - if (!Route) { - throw new Error('the route module does not export `Route`') - } - // The module each lazy option loads: what its loader imports, found - // without evaluating it. - const lazyModules = new Map>() - const lazyOptions = new Map>() - for (const option of Object.keys(Route.options)) { - const loader = lazy.get(Route.options[option]) - if (!loader) { - continue - } - const ids = moduleGraph - .probeDynamicImports(loader) - .filter((id) => graph.modules.has(id)) - lazyModules.set(option, ids) - for (const id of ids) { - lazyOptions.set(id, [...(lazyOptions.get(id) ?? []), option]) - } - } - const compiled = labelRouteModules(code, graph, lazyOptions) - let importers = 0 - return { - ...compiled, - /** Exports of the route module. */ - exports, - Route, - options: Route.options, - /** Ids of the modules evaluated so far, in evaluation order. */ - evaluated: moduleGraph.evaluated, - /** Whether a route option loads lazily from another module. */ - isLazy: (option: string) => lazy.has(Route.options[option]), - /** The value of a route option, once its lazy module (if any) loaded. */ - load: (option: string): Promise => { - const value = Route.options[option] - const loader = lazy.get(value) - return loader ? loader() : Promise.resolve(value) - }, - /** - * Code of the module a lazy route option loads (found by what its - * loader imports); fails for an option that is not lazy. - */ - chunkOf: (option: string): string => { - const ids = lazyModules.get(option) - if (!ids || ids.length !== 1) { - throw new Error( - `\`${option}\` does not load one module of the route lazily (it loads ${JSON.stringify(ids ?? [])})`, - ) - } - return graph.modules.get(ids[0]!)! - }, - /** - * Evaluates `source` as another module of the app, next to the route - * module, and returns its exports: it imports the route file as - * `routeSpecifier` (`'#route'`) and gets the instance the route uses. - */ - importFromRoute: (source: string) => { - const id = `/importer-${++importers}.tsx` - moduleGraph.add(id, source) - return moduleGraph.import(id) - }, - /** Names React Refresh registered each component under (`refresh`). */ - registered, - } -} - /** * Evaluates an emitted module like a runtime would: JSX becomes plain calls * that render to text, and imports of every form (named, default, namespace, @@ -1182,279 +175,6 @@ export async function expectRegisteredRouteOption( return name! } -const getStoreConfig: GetStoreConfig = () => ({ - createMutableStore: createNonReactiveMutableStore, - createReadonlyStore: createNonReactiveReadonlyStore, - batch: (fn) => fn(), -}) - -export type HotUpdateOptions = { - /** The bundler HMR API the route is compiled for and run with. Default `vite`. */ - style?: 'vite' | 'webpack' - /** - * Compile with automatic code splitting (the code splitter with HMR, its - * modules linked like `loadRoute`) instead of the route HMR plugin alone. - */ - codeSplitting?: boolean - /** Default `react`. */ - targetFramework?: TargetFramework - /** - * 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. */ - updated?: string - /** - * Route id the route file was registered under when the first version - * was compiled, when the route tree registers it under another one since - * (`routeId`), e.g. after a route configuration change. Default `routeId`. - */ - previousRouteId?: string - /** The route file's other imports, by specifier. */ - stubs?: Stubs -} - -/** A version of the route file as the bundler compiled it. */ -type CompiledRouteFile = Pick - -/** - * Runs the route HMR code the router plugin emits, on its own: compiles a - * route file for the dev server, loads the route module (one hot context per - * module, its `data` kept across the update), mounts its `Route` in a route - * tree as the generated route tree does and creates a router with it - * (`window.__TSR_ROUTER__`), then loads the modules of the edited file - * (`updated`) and gives each module that accepted its own updates its new - * version, per the bundler's hot API (Vite: its accept callbacks receive the - * new module; webpack: the module runs again with the data its dispose - * handlers wrote). - * - * Which modules a bundler updates, and what the page shows after an update, - * are not modelled here: tests of those run a real dev server and a real - * page (`route-hmr-page.ts`). - * - * Returns the options of the live route before (`previous`) and after - * (`live`) the update, a copy of the options each module load created a - * route with (`created`), whether a module asked the bundler for a full - * reload (`invalidated`), the `window` the modules ran with, and - * `load(option)`, which resolves a lazy option of the live route to what its - * module provides. - */ -export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { - const { - style = 'vite', - codeSplitting = false, - targetFramework = 'react', - routeId = '/', - } = options - const pluginOptions: Partial = { - target: targetFramework, - plugin: { hmr: { style } }, - } - const compiler = - style === 'webpack' - ? webpackCompiler(targetFramework === 'react') - : undefined - const compile = ( - source: string, - compiledRouteId = routeId, - ): CompiledRouteFile => { - if (codeSplitting) { - return compileRouteGraph(source, { - targetFramework, - hmr: true, - routeId: compiledRouteId, - pluginOptions, - webpackCompiler: compiler, - }) - } - const file = routeFile('index') - const compiled = transformWithRouteHmrPlugin( - source, - pluginOptions, - { file, routeId: compiledRouteId }, - compiler, - ) - return { - id: file, - modules: new Map([[file, compiled]]), - resolve: () => undefined, - } - } - const first = compile(code, options.previousRouteId) - const second = - options.updated === undefined && options.previousRouteId === undefined - ? first - : compile(options.updated ?? code) - - const created: Array> = [] - const lazy = new Map Promise>() - let invalidated = false - type HotState = { - data: Record - accepted: Array<(module: unknown) => void> - disposers: Array<(data: Record) => void> - } - const hotStates = new Map() - const hotState = (id: string) => { - let state = hotStates.get(id) - if (!state) { - state = { data: {}, accepted: [], disposers: [] } - hotStates.set(id, state) - } - return state - } - const hotContext = (id: string) => { - const state = hotState(id) - return { - data: state.data, - accept: (callback?: (module: unknown) => void) => { - if (callback) { - state.accepted.push(callback) - } - }, - dispose: (callback: (data: Record) => void) => { - state.disposers.push(callback) - }, - invalidate: () => { - invalidated = true - }, - } - } - - const recording = - (create: (options: Record) => unknown) => - (routeOptions: Record = {}) => { - // A copy: the HMR code writes kept options into the new ones. - created.push({ ...routeOptions }) - return create(routeOptions) - } - const createRoute = (routeOptions: Record) => { - const route = new BaseRoute(routeOptions as never) as unknown as { - isRoot: boolean - } - route.isRoot = false - return route - } - // 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 routerPackage = `@tanstack/${targetFramework}-router` - // The route classes of the framework packages add hooks to the router-core - // ones; route HMR only uses what router-core provides. - const routerStub: Record = { - ...createRouterStub(lazy), - createFileRoute: () => recording(createRoute), - createLazyFileRoute: () => recording(createRoute), - createRootRoute: recording(createRootRoute), - createRootRouteWithContext: () => recording(createRootRoute), - ...stubExports(options.stubs?.[routerPackage]), - } - const load = (compiled: CompiledRouteFile) => - createModuleGraph({ - modules: Object.fromEntries(compiled.modules), - resolve: compiled.resolve, - stubs: { ...options.stubs, [routerPackage]: routerStub }, - importMeta: (id) => ({ - [style === 'webpack' ? 'webpackHot' : 'hot']: hotContext(id), - }), - }) - - const window: { __TSR_ROUTER__?: RouterCore } = {} - vi.stubGlobal('window', window) - try { - const firstGraph = load(first) - const firstModule = await firstGraph.import(first.id) - const isRoot = rootRoutes.has(firstModule.Route as object) - let routeTree: AnyRoute - if (isRoot) { - routeTree = firstModule.Route as AnyRoute - } else { - const rootRoute = new BaseRootRoute({}) - const route = (firstModule.Route as AnyRoute).update({ - id: routeId, - path: routeId, - getParentRoute: () => rootRoute, - } as never) - routeTree = rootRoute.addChildren([route]) as unknown as AnyRoute - } - const router = new RouterCore( - { - routeTree, - history: createMemoryHistory({ initialEntries: ['/'] }), - isServer: false, - origin: 'http://localhost', - }, - 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 { - options: Record - } - ).options - const previous = { ...liveOptions() } - - if (style === 'webpack') { - for (const state of hotStates.values()) { - const next: Record = {} - state.disposers.forEach((dispose) => dispose(next)) - state.data = next - state.disposers = [] - } - } - const accepted = new Map( - [...hotStates].map(([id, state]) => [id, state.accepted]), - ) - for (const state of hotStates.values()) { - state.accepted = [] - } - const secondGraph = load(second) - await secondGraph.import(second.id) - // The route module's update first, then the other modules that accept - // their own updates, each with its new module. - const updates = [...accepted].sort( - ([a], [b]) => Number(b === first.id) - Number(a === first.id), - ) - for (const [id, callbacks] of updates) { - if (callbacks.length === 0 || !second.modules.has(id)) { - continue - } - const updatedModule = await secondGraph.import(id) - callbacks.forEach((accept) => accept(updatedModule)) - } - - const live = liveOptions() - return { - /** Id of the route file, as the plugin saw it. */ - file: first.id, - previous, - live, - created, - invalidated, - /** The `window` the modules ran with (the router is `__TSR_ROUTER__`). */ - window, - /** Whether an option of the live route loads lazily from another module. */ - isLazy: (option: string) => lazy.has(live[option]), - /** The value of an option of the live route, once its lazy module loaded. */ - load: (option: string): Promise => { - const loader = lazy.get(live[option]) - return loader ? loader() : Promise.resolve(live[option]) - }, - } - } finally { - vi.unstubAllGlobals() - } -} - /** * Builds a small app with the real Vite plugin (code splitting on, default * groupings unless `groupings` is given): `routes/index.tsx` holds `route`, diff --git a/packages/start-plugin-core/tests/diagnostics.ts b/packages/start-plugin-core/tests/diagnostics.ts new file mode 100644 index 00000000000..149a3daa1a9 --- /dev/null +++ b/packages/start-plugin-core/tests/diagnostics.ts @@ -0,0 +1,131 @@ +/** + * What the Start compiler reports: compile error messages, and the checks + * that a misplaced server fn is rejected or kept off the client. + * Re-exported by `regression-helpers.ts`. + */ +import { stripVTControlCharacters } from 'node:util' +import { expect } from 'vitest' +import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' +import type { ServerFn } from '../src/start-compiler/types' +import { + callReportedServerFns, + compileFor, + outputs, +} from './regression-helpers' +import type { Output } from './regression-helpers' +import { importSources } from './imports' + +/** + * The message of a compile error without its code frame. A code frame quotes + * the source lines, so matching the whole message would also match the input + * code, whatever the error. + */ +export function compileErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + return stripVTControlCharacters(message) + .split('\n') + .flatMap((line) => { + // A quoted source line: ` 12 | code` or `> 12 | code`. + if (/^\s*>?\s*\d+\s*\|/.test(line)) { + return [] + } + // A marker line, which may end with the message: ` | ^^^ message`. + const marker = /^\s*\|\s*\^*\s*(.*)$/.exec(line) + return (marker ? marker[1]! : line).trim() || [] + }) + .join('\n') +} + +/** Server-only module the server fns of the misplacement tests read. */ +export const serverOnlyModule = './db.server' + +/** + * A clear compile error rejecting a createServerFn that is not assigned to a + * top-level variable, matched against the message without its code frame + * (`compileErrorMessage`). + */ +export const misplacedServerFnRejection = + /createServerFn\b[^]*\b(top[- ]level|module[- ]?(scope|level)|nested|assigned to a (top[- ]level )?variable)/i + +/** What compiling a module for one output gave: its code, or an error. */ +export type OutputResult = + | { code: string | null; serverFns: Record } + | { error: unknown } + +/** + * Each output, on its own, either rejects the code with a clear + * createServerFn error (not the internal statement-list crash), or is a + * valid module: the client is free of the misplaced handler (it neither + * imports the server-only module, `serverOnlyModule`, nor contains the + * handler body, `db.inner()`), the SSR caller may keep the handler in place + * or call it through the provider, and the provider exports and runs every + * server fn the callers that compiled report, the misplaced one included. + * One output rejecting the code does not excuse another. + */ +export async function expectOutputsFreeOfHandler( + code: string, + results: Record, +) { + const compiled: Partial> = {} + let serverFns: Record = {} + for (const output of outputs) { + const result = results[output] + if ('error' in result) { + const message = compileErrorMessage(result.error) + expect(message, output).toMatch(misplacedServerFnRejection) + expect(message, output).not.toMatch( + /Expected createServerFn declaration in a statement list/, + ) + continue + } + compiled[output] = result.code ?? code + if (output !== 'provider') { + serverFns = { ...serverFns, ...result.serverFns } + } + } + if (compiled.client !== undefined) { + expect(importSources(compiled.client), 'client').not.toContain( + serverOnlyModule, + ) + expect(compiled.client, 'client').not.toContain('db.inner()') + } + const errors: Partial>> = {} + for (const [output, module] of Object.entries(compiled)) { + errors[output as Output] = await getModuleErrors(module) + } + expect(errors).toEqual( + Object.fromEntries(Object.keys(compiled).map((output) => [output, []])), + ) + if ( + compiled.provider !== undefined && + (compiled.client !== undefined || compiled.ssr !== undefined) + ) { + const called = await callReportedServerFns( + { ssr: compiled.ssr, provider: compiled.provider }, + serverFns, + { + [serverOnlyModule]: { db: { inner: () => 'inner', top: () => 'top' } }, + }, + ) + expect(called).toContain('inner') + } +} + +/** + * Compiles every output of `code`, each on its own (an output may reject the + * code), and checks `expectOutputsFreeOfHandler`. + */ +export async function expectNoClientHandler(code: string) { + const results = {} as Record + for (const output of outputs) { + results[output] = await compileFor(output, code).catch( + (error: unknown) => ({ error }), + ) + } + await expectOutputsFreeOfHandler(code, results) +} + +export const serverOnlyError = + 'throws: createServerOnlyFn() functions can only be called on the server!' +export const clientOnlyError = + 'throws: createClientOnlyFn() functions can only be called on the client!' diff --git a/packages/start-plugin-core/tests/imports.ts b/packages/start-plugin-core/tests/imports.ts new file mode 100644 index 00000000000..16e086cbf0f --- /dev/null +++ b/packages/start-plugin-core/tests/imports.ts @@ -0,0 +1,160 @@ +/** + * Reads the imports of a compiled module (static, dynamic and re-export + * requests) from its AST. Re-exported by `regression-helpers.ts`. + */ +import { parseSync } from 'vite' +import type { ESTree } from 'vite' + +export type Node = { type: string; [key: string]: unknown } + +export function isNode(value: unknown): value is Node { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Node).type === 'string' + ) +} + +/** The static value of a module specifier expression, if any. */ +export function staticSpecifier(node: unknown) { + if (!isNode(node)) { + return undefined + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value + } + if ( + node.type === 'TemplateLiteral' && + (node.expressions as Array).length === 0 + ) { + const [quasi] = node.quasis as Array<{ value: { cooked: string } }> + return quasi!.value.cooked + } + return undefined +} + +export function walk(node: unknown, visit: (node: Node) => void) { + if (Array.isArray(node)) { + for (const child of node) { + walk(child, visit) + } + return + } + if (!isNode(node)) { + return + } + visit(node) + for (const key in node) { + if (key !== 'type') { + walk(node[key], visit) + } + } +} + +/** Name an import or export specifier refers to. */ +function specifierName(node: unknown) { + if (!isNode(node)) { + return undefined + } + return node.type === 'Identifier' + ? (node.name as string) + : (node.value as string) +} + +interface ModuleRequests { + /** Source of each import and re-export declaration, in source order. */ + declarations: Array + /** Specifiers of static imports and re-exports, unique, in source order. */ + static: Array + /** Static specifiers of dynamic `import()` calls, in source order. */ + dynamic: Array + /** Names imported or re-exported from each static specifier. */ + names: Map> +} + +/** + * Parses a module as TypeScript with JSX, or else as TypeScript (where + * `value` is a type assertion). + */ +export function parseModule(code: string): ESTree.Program { + const tsx = parseSync('module.tsx', code, { sourceType: 'module' }) + if (tsx.errors.length === 0) { + return tsx.program + } + const ts = parseSync('module.ts', code, { sourceType: 'module' }) + if (ts.errors.length === 0) { + return ts.program + } + throw new Error(`Cannot parse module: ${tsx.errors[0]!.message}\n${code}`) +} + +/** The modules a JavaScript or TypeScript (JSX) module requests. */ +export function moduleRequests(code: string) { + const program = parseModule(code) + const requests: ModuleRequests = { + declarations: [], + static: [], + dynamic: [], + names: new Map(), + } + const declare = (specifier: string) => { + requests.declarations.push(specifier) + request(specifier) + } + const request = (specifier: string, name?: string) => { + if (!requests.names.has(specifier)) { + requests.static.push(specifier) + requests.names.set(specifier, new Set()) + } + if (name !== undefined) { + requests.names.get(specifier)!.add(name) + } + } + walk(program, (node) => { + switch (node.type) { + case 'ImportDeclaration': { + const source = staticSpecifier(node.source)! + declare(source) + for (const specifier of node.specifiers as Array) { + if (specifier.type === 'ImportDefaultSpecifier') { + request(source, 'default') + } else if (specifier.type === 'ImportSpecifier') { + request(source, specifierName(specifier.imported)) + } + } + break + } + case 'ExportNamedDeclaration': { + const source = staticSpecifier(node.source) + if (source !== undefined) { + declare(source) + for (const specifier of node.specifiers as Array) { + request(source, specifierName(specifier.local)) + } + } + break + } + case 'ExportAllDeclaration': + declare(staticSpecifier(node.source)!) + break + case 'ImportExpression': { + const source = staticSpecifier(node.source) + if (source !== undefined) { + requests.dynamic.push(source) + } + break + } + } + }) + return requests +} + +/** + * Project-local sources of a module's import and re-export declarations + * (one per declaration), sorted. + */ +export function importSources(code: string) { + return moduleRequests(code) + .declarations.filter((source) => !source.startsWith('@tanstack/')) + .sort() +} diff --git a/packages/start-plugin-core/tests/regression-helpers.ts b/packages/start-plugin-core/tests/regression-helpers.ts index 337c9fc2f1b..adbc969bc5b 100644 --- a/packages/start-plugin-core/tests/regression-helpers.ts +++ b/packages/start-plugin-core/tests/regression-helpers.ts @@ -3,13 +3,14 @@ * into its client, SSR caller and server-function provider outputs, split * `` children into chunks, and evaluate compiled modules (linked as * real ES modules, `@tanstack/compiler-test-utils/module-linker`) against a - * Start's runtime (`./start-runtime`). + * Start's runtime (`./start-runtime`). Import inspection (`imports.ts`), + * diagnostics (`diagnostics.ts`), source maps (`source-maps.ts`) and the + * `` chunk rendering (`render.ts`) live in their own modules and + * are re-exported here. */ -import { stripVTControlCharacters } from 'node:util' import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' import { parseSync } from 'vite' import { expect } from 'vitest' -import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' import { StartCompiler, detectKindsInCode, @@ -22,10 +23,7 @@ import { createSsrRpc, startClientCore, } from './start-runtime' -import { - freeIdentifiers, - getModuleErrors, -} from '@tanstack/compiler-test-utils/validate-module' +import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import type { ModuleGraphOptions, ModuleStub, @@ -37,157 +35,37 @@ import type { StartCompilerPlugin, StartCompilerTransformResult, } from '../src/types' -import type { ESTree } from 'vite' - -export type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' - -type Node = { type: string; [key: string]: unknown } - -function isNode(value: unknown): value is Node { - return ( - typeof value === 'object' && - value !== null && - typeof (value as Node).type === 'string' - ) -} - -/** The static value of a module specifier expression, if any. */ -function staticSpecifier(node: unknown) { - if (!isNode(node)) { - return undefined - } - if (node.type === 'Literal' && typeof node.value === 'string') { - return node.value - } - if ( - node.type === 'TemplateLiteral' && - (node.expressions as Array).length === 0 - ) { - const [quasi] = node.quasis as Array<{ value: { cooked: string } }> - return quasi!.value.cooked - } - return undefined -} -function walk(node: unknown, visit: (node: Node) => void) { - if (Array.isArray(node)) { - for (const child of node) { - walk(child, visit) - } - return - } - if (!isNode(node)) { - return - } - visit(node) - for (const key in node) { - if (key !== 'type') { - walk(node[key], visit) - } - } -} - -/** Name an import or export specifier refers to. */ -function specifierName(node: unknown) { - if (!isNode(node)) { - return undefined - } - return node.type === 'Identifier' - ? (node.name as string) - : (node.value as string) -} - -interface ModuleRequests { - /** Source of each import and re-export declaration, in source order. */ - declarations: Array - /** Specifiers of static imports and re-exports, unique, in source order. */ - static: Array - /** Static specifiers of dynamic `import()` calls, in source order. */ - dynamic: Array - /** Names imported or re-exported from each static specifier. */ - names: Map> -} - -/** - * Parses a module as TypeScript with JSX, or else as TypeScript (where - * `value` is a type assertion). - */ -function parseModule(code: string): ESTree.Program { - const tsx = parseSync('module.tsx', code, { sourceType: 'module' }) - if (tsx.errors.length === 0) { - return tsx.program - } - const ts = parseSync('module.ts', code, { sourceType: 'module' }) - if (ts.errors.length === 0) { - return ts.program - } - throw new Error(`Cannot parse module: ${tsx.errors[0]!.message}\n${code}`) -} +export { importSources } from './imports' +export { + compileErrorMessage, + serverOnlyModule, + misplacedServerFnRejection, + expectOutputsFreeOfHandler, + expectNoClientHandler, + serverOnlyError, + clientOnlyError, +} from './diagnostics' +export type { OutputResult } from './diagnostics' +export { + getChunkIds, + loadChunk, + compileHydrate, + compileFirstChunk, + chunkExportName, + renderChunk, + hydrateStubs, + hydrateParentStubs, + loadClientModule, + expectRenderedThroughChunks, + renderBoundaries, +} from './render' -/** The modules a JavaScript or TypeScript (JSX) module requests. */ -function moduleRequests(code: string) { - const program = parseModule(code) - const requests: ModuleRequests = { - declarations: [], - static: [], - dynamic: [], - names: new Map(), - } - const declare = (specifier: string) => { - requests.declarations.push(specifier) - request(specifier) - } - const request = (specifier: string, name?: string) => { - if (!requests.names.has(specifier)) { - requests.static.push(specifier) - requests.names.set(specifier, new Set()) - } - if (name !== undefined) { - requests.names.get(specifier)!.add(name) - } - } - walk(program, (node) => { - switch (node.type) { - case 'ImportDeclaration': { - const source = staticSpecifier(node.source)! - declare(source) - for (const specifier of node.specifiers as Array) { - if (specifier.type === 'ImportDefaultSpecifier') { - request(source, 'default') - } else if (specifier.type === 'ImportSpecifier') { - request(source, specifierName(specifier.imported)) - } - } - break - } - case 'ExportNamedDeclaration': { - const source = staticSpecifier(node.source) - if (source !== undefined) { - declare(source) - for (const specifier of node.specifiers as Array) { - request(source, specifierName(specifier.local)) - } - } - break - } - case 'ExportAllDeclaration': - declare(staticSpecifier(node.source)!) - break - case 'ImportExpression': { - const source = staticSpecifier(node.source) - if (source !== undefined) { - requests.dynamic.push(source) - } - break - } - } - }) - return requests -} +export type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' export type Output = 'client' | 'ssr' | 'provider' export const outputs: Array = ['client', 'ssr', 'provider'] -const moduleId = '/test/src/module.tsx' +export const moduleId = '/test/src/module.tsx' /** The framework a module is compiled for: its `@tanstack/-*` packages. */ export type Framework = CompileStartFrameworkOptions @@ -385,16 +263,6 @@ export { startClientCore } from './start-runtime' // The `compilerTransforms` test transform lives in the seam module. export { renderThingTransform } from './compiler-transforms-seam' -/** - * Project-local sources of a module's import and re-export declarations - * (one per declaration), sorted. - */ -export function importSources(code: string) { - return moduleRequests(code) - .declarations.filter((source) => !source.startsWith('@tanstack/')) - .sort() -} - /** * Where an evaluated module runs: in the browser (`client`, the default) or * in the server bundle (`server`). @@ -510,7 +378,7 @@ const __jsx = (type, props, ...children) => { * (default `react`), and whose JSX renders through `runtime` (default: * `jsxToText`). */ -function createTestModuleGraph( +export function createTestModuleGraph( options: ModuleGraphOptions & { framework?: Framework; env?: RuntimeEnv }, ) { const { framework = 'react', env, ...graphOptions } = options @@ -702,116 +570,6 @@ export async function callReportedServerFns( return results } -/** - * The message of a compile error without its code frame. A code frame quotes - * the source lines, so matching the whole message would also match the input - * code, whatever the error. - */ -export function compileErrorMessage(error: unknown) { - const message = error instanceof Error ? error.message : String(error) - return stripVTControlCharacters(message) - .split('\n') - .flatMap((line) => { - // A quoted source line: ` 12 | code` or `> 12 | code`. - if (/^\s*>?\s*\d+\s*\|/.test(line)) { - return [] - } - // A marker line, which may end with the message: ` | ^^^ message`. - const marker = /^\s*\|\s*\^*\s*(.*)$/.exec(line) - return (marker ? marker[1]! : line).trim() || [] - }) - .join('\n') -} - -/** Server-only module the server fns of the misplacement tests read. */ -export const serverOnlyModule = './db.server' - -/** - * A clear compile error rejecting a createServerFn that is not assigned to a - * top-level variable, matched against the message without its code frame - * (`compileErrorMessage`). - */ -export const misplacedServerFnRejection = - /createServerFn\b[^]*\b(top[- ]level|module[- ]?(scope|level)|nested|assigned to a (top[- ]level )?variable)/i - -/** What compiling a module for one output gave: its code, or an error. */ -export type OutputResult = - | { code: string | null; serverFns: Record } - | { error: unknown } - -/** - * Each output, on its own, either rejects the code with a clear - * createServerFn error (not the internal statement-list crash), or is a - * valid module: the client is free of the misplaced handler (it neither - * imports the server-only module, `serverOnlyModule`, nor contains the - * handler body, `db.inner()`), the SSR caller may keep the handler in place - * or call it through the provider, and the provider exports and runs every - * server fn the callers that compiled report, the misplaced one included. - * One output rejecting the code does not excuse another. - */ -export async function expectOutputsFreeOfHandler( - code: string, - results: Record, -) { - const compiled: Partial> = {} - let serverFns: Record = {} - for (const output of outputs) { - const result = results[output] - if ('error' in result) { - const message = compileErrorMessage(result.error) - expect(message, output).toMatch(misplacedServerFnRejection) - expect(message, output).not.toMatch( - /Expected createServerFn declaration in a statement list/, - ) - continue - } - compiled[output] = result.code ?? code - if (output !== 'provider') { - serverFns = { ...serverFns, ...result.serverFns } - } - } - if (compiled.client !== undefined) { - expect(importSources(compiled.client), 'client').not.toContain( - serverOnlyModule, - ) - expect(compiled.client, 'client').not.toContain('db.inner()') - } - const errors: Partial>> = {} - for (const [output, module] of Object.entries(compiled)) { - errors[output as Output] = await getModuleErrors(module) - } - expect(errors).toEqual( - Object.fromEntries(Object.keys(compiled).map((output) => [output, []])), - ) - if ( - compiled.provider !== undefined && - (compiled.client !== undefined || compiled.ssr !== undefined) - ) { - const called = await callReportedServerFns( - { ssr: compiled.ssr, provider: compiled.provider }, - serverFns, - { - [serverOnlyModule]: { db: { inner: () => 'inner', top: () => 'top' } }, - }, - ) - expect(called).toContain('inner') - } -} - -/** - * Compiles every output of `code`, each on its own (an output may reject the - * code), and checks `expectOutputsFreeOfHandler`. - */ -export async function expectNoClientHandler(code: string) { - const results = {} as Record - for (const output of outputs) { - results[output] = await compileFor(output, code).catch( - (error: unknown) => ({ error }), - ) - } - await expectOutputsFreeOfHandler(code, results) -} - /** * Calls a server fn the way the app does, from where `read` finds it in the * evaluated callers (e.g. `(module) => module.api.read`): the client gives an @@ -945,567 +703,3 @@ export async function settleAsync(run: () => unknown) { return `throws: ${(error as Error).message}` } } - -export const serverOnlyError = - 'throws: createServerOnlyFn() functions can only be called on the server!' -export const clientOnlyError = - 'throws: createClientOnlyFn() functions can only be called on the client!' - -type HydratePlugin = ReturnType - -/** Ids of the virtual modules the Hydrate plugin serves (its host contract). */ -const hydrateVirtualModuleIds = - createHydrateCompilerPlugin().virtualModuleIdPattern! - -/** - * The `` chunk ids a compiled module imports, in source order: the - * modules it imports dynamically that the Hydrate plugin serves. - */ -export function getChunkIds(code: string) { - return moduleRequests(code).dynamic.filter((id) => { - hydrateVirtualModuleIds.lastIndex = 0 - return hydrateVirtualModuleIds.test(id) - }) -} - -/** The name of a JSX element, as written (`Hydrate`, `Start.Hydrate`). */ -function jsxElementName(name: unknown): string | undefined { - if (!isNode(name)) { - return undefined - } - if (name.type === 'JSXIdentifier') { - return name.name as string - } - if (name.type === 'JSXMemberExpression') { - const object = jsxElementName(name.object) - return object === undefined - ? undefined - : `${object}.${(name.property as Node).name as string}` - } - return undefined -} - -/** - * The JSX element names that refer to the `Hydrate` component of a - * `@tanstack/*-start` package in a module: its named imports and the - * `Hydrate` member of its namespace imports. - */ -function hydrateElementNames(program: unknown) { - const names = new Set() - walk(program, (node) => { - if ( - node.type !== 'ImportDeclaration' || - !/^@tanstack\/[\w-]+-start$/.test(staticSpecifier(node.source) ?? '') - ) { - return - } - for (const specifier of node.specifiers as Array) { - const local = (specifier.local as Node).name as string - if (specifier.type === 'ImportNamespaceSpecifier') { - names.add(`${local}.Hydrate`) - } else if ( - specifier.type === 'ImportSpecifier' && - ((specifier.imported as Node).name ?? - (specifier.imported as Node).value) === 'Hydrate' - ) { - names.add(local) - } - } - }) - return names -} - -/** The `` elements of a module, in source order. */ -function hydrateElements(code: string) { - const program = parseModule(code) - const names = hydrateElementNames(program) - const elements: Array = [] - walk(program, (node) => { - if ( - node.type === 'JSXElement' && - names.has(jsxElementName((node.openingElement as Node).name) ?? '') - ) { - elements.push(node) - } - }) - return elements -} - -/** Loads a `` chunk the way the bundler loads the virtual module. */ -export function loadChunk( - plugin: HydratePlugin, - env: 'client' | 'server', - id: string, -) { - return loadChunkResult(plugin, env, id)?.code ?? null -} - -function loadChunkResult( - plugin: HydratePlugin, - env: 'client' | 'server', - id: string, -): StartCompilerTransformResult | null { - return ( - plugin.loadVirtualModule?.({ - id, - root: '/test', - env, - envName: env === 'client' ? 'client' : 'ssr', - }) ?? null - ) -} - -const base64 = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' - -/** The original (0-based) line and column of each source map segment. */ -function originalPositions(mappings: string) { - const positions: Array<[line: number, column: number]> = [] - let line = 0 - let column = 0 - for (const segment of mappings.split(/[;,]/)) { - const values: Array = [] - let value = 0 - let shift = 0 - for (const char of segment) { - const digit = base64.indexOf(char) - value += (digit & 31) << shift - if (digit & 32) { - shift += 5 - } else { - values.push(value & 1 ? -(value >>> 1) : value >>> 1) - value = 0 - shift = 0 - } - } - if (values.length >= 4) { - line += values[2]! - column += values[3]! - positions.push([line, column]) - } - } - return positions -} - -/** - * Where in `source` the children a chunk renders come from: the start of the - * outermost `` element of `source` that contains the code the - * chunk maps back to (through its source map), or `Infinity`. - */ -function chunkBoundaryStart( - chunk: StartCompilerTransformResult, - source: string, - boundaries: Array<{ start: number; end: number }>, -) { - const mappings = chunk.map?.mappings - if (typeof mappings !== 'string') { - return Infinity - } - const lineStarts = [0] - for (let index = 0; index < source.length; index++) { - if (source[index] === '\n') { - lineStarts.push(index + 1) - } - } - let start = Infinity - for (const [line, column] of originalPositions(mappings)) { - const offset = (lineStarts[line] ?? Infinity) + column - const boundary = boundaries.find( - (range) => range.start <= offset && offset < range.end, - ) - if (boundary) { - start = Math.min(start, boundary.start) - } - } - return start -} - -/** - * Compiles a module with `` and loads the split chunks it imports - * (`chunks`, as the plugin serves them, before the bundler compiles them; ids - * in `chunkIds`), ordered like their boundaries in the source module (found - * through each chunk's source map). A module the compiler leaves untouched is - * its own parent, without chunks. - */ -export async function compileHydrate( - env: 'client' | 'server', - code: string, - options: { id?: string; framework?: Framework } = {}, -) { - const plugin = createHydrateCompilerPlugin() - const { compile } = createStartCompiler({ - env, - compilerPlugins: [plugin], - framework: options.framework, - }) - const parent = (await compile(code, options.id)) ?? code - const loaded = getChunkIds(parent).map((id) => { - const chunk = loadChunkResult(plugin, env, id) - if (chunk === null) { - throw new Error(`expected virtual module ${id} to load`) - } - return { id, chunk } - }) - // Only the outermost boundaries: a chunk maps to the boundary whose children - // it renders, not to a boundary nested in them. - const boundaries = hydrateElements(code) - .map((element) => ({ - start: element.start as number, - end: element.end as number, - })) - .filter( - (range, _, ranges) => - !ranges.some( - (other) => - other !== range && - other.start <= range.start && - range.end <= other.end, - ), - ) - const ordered = loaded - .map(({ id, chunk }) => ({ - id, - chunk, - start: chunkBoundaryStart(chunk, code, boundaries), - })) - .sort((a, b) => a.start - b.start) - return { - parent, - chunks: ordered.map(({ chunk }) => chunk.code), - chunkIds: ordered.map(({ id }) => id), - plugin, - } -} - -/** - * Loads the first chunk a parent module imports and compiles it for the - * client under its virtual module id, like the bundler does. - */ -export async function compileFirstChunk(plugin: HydratePlugin, parent: string) { - const id = getChunkIds(parent)[0]! - const { compile, serverFns } = createStartCompiler({ - env: 'client', - compilerPlugins: [plugin], - }) - const code = await compile(loadChunk(plugin, 'client', id)!, id) - return { code, serverFns } -} - -/** - * The export of the chunk `chunkId` that the client `parent` renders: the - * name it gives `lazyRouteComponent(load, name)` (default `default`) with the - * `load` that imports the chunk. The parent is evaluated (its imports linked - * to `stubs` over the parent-module stubs, others to no-op functions) and - * creates its lazy components then (a lazy component created in render would - * remount on every render). - */ -export async function chunkExportName( - parent: string, - chunkId: string, - options: { framework?: Framework; stubs?: Record } = {}, -) { - const framework = options.framework ?? 'react' - const routerPackage = `@tanstack/${framework}-router` - const lazy: Array<{ - load: () => Promise<{ chunkId?: string }> - name: string - }> = [] - const chunkIds = new Set(getChunkIds(parent)) - const parentId = chunkId.split('?')[0]! - const graph = createTestModuleGraph({ - modules: { [parentId]: parent }, - stubs: { - ...hydrateStubs(framework), - ...options.stubs, - [routerPackage]: { - ...(options.stubs?.[routerPackage] as Record), - lazyRouteComponent: ( - load: () => Promise<{ chunkId?: string }>, - name = 'default', - ) => { - lazy.push({ load, name }) - return () => null - }, - }, - }, - fallbackStub: (_, names) => - Object.fromEntries([...names].map((name) => [name, () => undefined])), - framework, - // Each chunk is a module naming its id. - load: (id) => - chunkIds.has(id) ? `export const chunkId = ${JSON.stringify(id)}` : null, - }) - await graph.import(parentId) - const names = new Set() - for (const { load, name } of lazy) { - if ((await load()).chunkId === chunkId) { - names.add(name) - } - } - if (names.size !== 1) { - throw new Error( - `expected the parent to render one export of ${chunkId}, got ${[...names].join(', ') || 'none'}`, - ) - } - return [...names][0]! -} - -/** - * Renders, with the given props, the `` chunk `chunk` (the code of - * the chunk `chunkId`, evaluated with `stubs`): the export of it that the - * client `parent` renders (`chunkExportName`). - */ -export async function renderChunk( - parent: string, - chunkId: string, - chunk: string, - props: Record = {}, - stubs: Record = {}, -) { - const name = await chunkExportName(parent, chunkId) - const component = (await evaluateModule(chunk, stubs))[name] - if (typeof component !== 'function') { - throw new Error(`expected the chunk to export a component as ${name}`) - } - return component(props) -} - -/** - * Parent-module stubs of a framework (default `react`): `` marks - * where its children render, lazy chunks render their props. - */ -export function hydrateStubs( - framework: Framework = 'react', -): Record> { - return { - [`@tanstack/${framework}-start`]: { - Hydrate: (props: { children: unknown }) => `[${props.children ?? ''}]`, - }, - [`@tanstack/${framework}-start/hydration`]: { - idle: () => 'idle', - visible: () => 'visible', - interaction: () => 'interaction', - load: () => 'load', - media: () => 'media', - never: () => 'never', - }, - [`@tanstack/${framework}-router`]: { - lazyRouteComponent: () => (props: Record) => - `lazy(${Object.keys(props) - .filter((name) => name !== 'children') - .sort() - .map((name) => `${name}=${String(props[name])}`) - .join(',')})`, - }, - } -} - -/** The React parent-module stubs (`hydrateStubs('react')`). */ -export const hydrateParentStubs = hydrateStubs('react') - -/** - * Compiles `code` for the client (React unless `framework` says otherwise) - * and evaluates it once its `` chunks have loaded, like the browser - * does: the parent and the chunks are one linked module graph (each chunk - * compiled like the bundler compiles the virtual module), - * `lazyRouteComponent(load, exportName)` loads its chunk through `load()` and - * renders the export it names, and its `preload()` loads the chunk too. - * `beforeChunksLoad` runs app code between the parent's evaluation and the - * chunks'. Returns the evaluated parent, the ids of the chunks it imports - * (`chunkIds`, in source order), `lazyChunks` (every lazy chunk component and - * whether it rendered) and `preloaded` (the chunk each `preload()` call - * loaded, by id, evaluated, and the component of it the lazy component - * renders, in call order; loading the chunks here is not a preload). The parent and every chunk must be valid modules; they may - * read the globals the source reads. With `chunksFromSource`, the chunks - * load the way Rsbuild loads them: given the source file's code, from a - * Hydrate plugin that did not compile the parent (e.g. another build - * worker). - */ -export async function loadClientModule( - code: string, - options: { - stubs?: Record - beforeChunksLoad?: (module: Record) => void - runtime?: string - /** Id the module is compiled under. */ - id?: string - /** Default `react`. */ - framework?: Framework - chunksFromSource?: boolean - } = {}, -) { - const id = options.id ?? moduleId - const framework = options.framework ?? 'react' - const { parent, chunks, plugin } = await compileHydrate('client', code, { - id, - framework, - }) - const lazyChunks: Array<{ name: string; rendered: boolean }> = [] - const loaders: Array<{ - name: string - load: () => Promise> - component?: (props: unknown) => unknown - }> = [] - const preloaded: Array<{ - id: string - module: Record - component: (props: Record) => any - }> = [] - const loadedChunkIds: Array = [] - /** Id of the chunk an evaluated chunk namespace is. */ - const chunkIdOf = async (namespace: unknown) => { - for (const chunkId of loadedChunkIds) { - if ((await graph.import(chunkId)) === namespace) { - return chunkId - } - } - throw new Error('preload() loaded a module that is not a chunk') - } - const routerPackage = `@tanstack/${framework}-router` - const userStubs = options.stubs ?? {} - const stubs: Record = { - ...hydrateStubs(framework), - ...userStubs, - [routerPackage]: { - ...(userStubs[routerPackage] as Record), - lazyRouteComponent: ( - load: () => Promise>, - name = 'default', - ) => { - const lazyChunk = { name, rendered: false } - const loader: (typeof loaders)[number] = { name, load } - lazyChunks.push(lazyChunk) - loaders.push(loader) - return Object.assign( - (props: unknown) => { - lazyChunk.rendered = true - if (!loader.component) { - throw new Error(`lazy chunk ${name} rendered before it loaded`) - } - return loader.component(props) - }, - { - preload: async () => { - const namespace = await load() - preloaded.push({ - id: await chunkIdOf(namespace), - module: namespace, - component: namespace[name], - }) - return namespace - }, - }, - ) - }, - }, - } - // The globals the source reads may be read by its modules too. - const globals = await freeIdentifiers(code) - expect(await getModuleErrors(parent, undefined, { globals })).toEqual([]) - const chunkPlugin = options.chunksFromSource - ? createHydrateCompilerPlugin() - : plugin - const { compile } = createStartCompiler({ - env: 'client', - compilerPlugins: [chunkPlugin], - framework, - }) - const graph = createTestModuleGraph({ - modules: { [id]: parent }, - stubs, - runtime: options.runtime, - framework, - // The bundler compiles each virtual module it loads like any other module. - load: async (chunkId) => { - // The linker also probes ids with extensions appended, which are no - // modules of this graph. - if (chunkId.split('?')[0] !== id.split('?')[0]) { - return null - } - const chunk = options.chunksFromSource - ? (chunkPlugin.loadVirtualModule?.({ - id: chunkId, - code, - root: '/test', - env: 'client', - envName: 'client', - })?.code ?? null) - : loadChunk(plugin, 'client', chunkId) - if (chunk === null) { - return null - } - loadedChunkIds.push(chunkId) - return (await compile(chunk, chunkId)) ?? chunk - }, - }) - const module = await graph.import(id) - options.beforeChunksLoad?.(module) - for (const chunk of chunks) { - expect(await getModuleErrors(chunk, undefined, { globals })).toEqual([]) - } - // Loading a chunk may create the lazy components of nested boundaries, - // which the iteration reaches as they are appended. - for (const loader of loaders) { - loader.component = (await loader.load())[loader.name] - } - return { module, chunkIds: getChunkIds(parent), lazyChunks, preloaded } -} - -/** - * The children were split and rendered through their chunks: the client - * created at least one lazy chunk component (`loadClientModule`), and every - * one rendered. - */ -export function expectRenderedThroughChunks( - lazyChunks: Array<{ rendered: boolean }>, -) { - expect(lazyChunks.length).toBeGreaterThan(0) - expect(lazyChunks.filter((chunk) => !chunk.rendered)).toEqual([]) -} - -/** - * Renders `code` compiled for `env` and records the boundary id (`h`) each - * `` renders with: on the server the id in its markup, on the client - * the id it hydrates with (chunks loaded, `loadClientModule`). `stubs` are - * linked over the parent-module stubs (`hydrateStubs`); the recording - * `Hydrate` is added to the `@tanstack/-start` stub. - */ -export async function renderBoundaries( - env: 'client' | 'server', - code: string, - render: (module: Record) => unknown, - options: { - id?: string - framework?: Framework - stubs?: Record - runtime?: string - } = {}, -) { - const { stubs: userStubs = {}, runtime, ...compileOptions } = options - const framework = options.framework ?? 'react' - const ids: Array = [] - const Hydrate = (props: { children?: unknown; h?: unknown }) => { - ids.push(props.h) - return `[${props.children ?? ''}]` - } - const startPackage = `@tanstack/${framework}-start` - const stubs = { - ...hydrateStubs(framework), - ...userStubs, - [startPackage]: { - ...(userStubs[startPackage] as Record | undefined), - Hydrate, - }, - } - const module = - env === 'server' - ? await evaluateModule( - (await compileHydrate('server', code, compileOptions)).parent, - stubs, - { framework, runtime }, - ) - : (await loadClientModule(code, { ...compileOptions, stubs, runtime })) - .module - const html = render(module) - return { html, ids } -} diff --git a/packages/start-plugin-core/tests/render.ts b/packages/start-plugin-core/tests/render.ts new file mode 100644 index 00000000000..84635100aa1 --- /dev/null +++ b/packages/start-plugin-core/tests/render.ts @@ -0,0 +1,523 @@ +/** + * Splits `` children into chunks with the Start compiler and the + * hydrate plugin, loads and renders the chunks, and renders a page's + * boundaries through them. Re-exported by `regression-helpers.ts`. + */ +import { expect } from 'vitest' +import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' +import { + freeIdentifiers, + getModuleErrors, +} from '@tanstack/compiler-test-utils/validate-module' +import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' +import type { StartCompilerTransformResult } from '../src/types' +import { + isNode, + moduleRequests, + parseModule, + staticSpecifier, + walk, +} from './imports' +import type { Node } from './imports' +import { + createStartCompiler, + createTestModuleGraph, + evaluateModule, + moduleId, +} from './regression-helpers' +import type { Framework } from './regression-helpers' +import { chunkBoundaryStart } from './source-maps' + +type HydratePlugin = ReturnType + +/** Ids of the virtual modules the Hydrate plugin serves (its host contract). */ +const hydrateVirtualModuleIds = + createHydrateCompilerPlugin().virtualModuleIdPattern! + +/** + * The `` chunk ids a compiled module imports, in source order: the + * modules it imports dynamically that the Hydrate plugin serves. + */ +export function getChunkIds(code: string) { + return moduleRequests(code).dynamic.filter((id) => { + hydrateVirtualModuleIds.lastIndex = 0 + return hydrateVirtualModuleIds.test(id) + }) +} + +/** The name of a JSX element, as written (`Hydrate`, `Start.Hydrate`). */ +function jsxElementName(name: unknown): string | undefined { + if (!isNode(name)) { + return undefined + } + if (name.type === 'JSXIdentifier') { + return name.name as string + } + if (name.type === 'JSXMemberExpression') { + const object = jsxElementName(name.object) + return object === undefined + ? undefined + : `${object}.${(name.property as Node).name as string}` + } + return undefined +} + +/** + * The JSX element names that refer to the `Hydrate` component of a + * `@tanstack/*-start` package in a module: its named imports and the + * `Hydrate` member of its namespace imports. + */ +function hydrateElementNames(program: unknown) { + const names = new Set() + walk(program, (node) => { + if ( + node.type !== 'ImportDeclaration' || + !/^@tanstack\/[\w-]+-start$/.test(staticSpecifier(node.source) ?? '') + ) { + return + } + for (const specifier of node.specifiers as Array) { + const local = (specifier.local as Node).name as string + if (specifier.type === 'ImportNamespaceSpecifier') { + names.add(`${local}.Hydrate`) + } else if ( + specifier.type === 'ImportSpecifier' && + ((specifier.imported as Node).name ?? + (specifier.imported as Node).value) === 'Hydrate' + ) { + names.add(local) + } + } + }) + return names +} + +/** The `` elements of a module, in source order. */ +function hydrateElements(code: string) { + const program = parseModule(code) + const names = hydrateElementNames(program) + const elements: Array = [] + walk(program, (node) => { + if ( + node.type === 'JSXElement' && + names.has(jsxElementName((node.openingElement as Node).name) ?? '') + ) { + elements.push(node) + } + }) + return elements +} + +/** Loads a `` chunk the way the bundler loads the virtual module. */ +export function loadChunk( + plugin: HydratePlugin, + env: 'client' | 'server', + id: string, +) { + return loadChunkResult(plugin, env, id)?.code ?? null +} + +function loadChunkResult( + plugin: HydratePlugin, + env: 'client' | 'server', + id: string, +): StartCompilerTransformResult | null { + return ( + plugin.loadVirtualModule?.({ + id, + root: '/test', + env, + envName: env === 'client' ? 'client' : 'ssr', + }) ?? null + ) +} + +/** + * Compiles a module with `` and loads the split chunks it imports + * (`chunks`, as the plugin serves them, before the bundler compiles them; ids + * in `chunkIds`), ordered like their boundaries in the source module (found + * through each chunk's source map). A module the compiler leaves untouched is + * its own parent, without chunks. + */ +export async function compileHydrate( + env: 'client' | 'server', + code: string, + options: { id?: string; framework?: Framework } = {}, +) { + const plugin = createHydrateCompilerPlugin() + const { compile } = createStartCompiler({ + env, + compilerPlugins: [plugin], + framework: options.framework, + }) + const parent = (await compile(code, options.id)) ?? code + const loaded = getChunkIds(parent).map((id) => { + const chunk = loadChunkResult(plugin, env, id) + if (chunk === null) { + throw new Error(`expected virtual module ${id} to load`) + } + return { id, chunk } + }) + // Only the outermost boundaries: a chunk maps to the boundary whose children + // it renders, not to a boundary nested in them. + const boundaries = hydrateElements(code) + .map((element) => ({ + start: element.start as number, + end: element.end as number, + })) + .filter( + (range, _, ranges) => + !ranges.some( + (other) => + other !== range && + other.start <= range.start && + range.end <= other.end, + ), + ) + const ordered = loaded + .map(({ id, chunk }) => ({ + id, + chunk, + start: chunkBoundaryStart(chunk, code, boundaries), + })) + .sort((a, b) => a.start - b.start) + return { + parent, + chunks: ordered.map(({ chunk }) => chunk.code), + chunkIds: ordered.map(({ id }) => id), + plugin, + } +} + +/** + * Loads the first chunk a parent module imports and compiles it for the + * client under its virtual module id, like the bundler does. + */ +export async function compileFirstChunk(plugin: HydratePlugin, parent: string) { + const id = getChunkIds(parent)[0]! + const { compile, serverFns } = createStartCompiler({ + env: 'client', + compilerPlugins: [plugin], + }) + const code = await compile(loadChunk(plugin, 'client', id)!, id) + return { code, serverFns } +} + +/** + * The export of the chunk `chunkId` that the client `parent` renders: the + * name it gives `lazyRouteComponent(load, name)` (default `default`) with the + * `load` that imports the chunk. The parent is evaluated (its imports linked + * to `stubs` over the parent-module stubs, others to no-op functions) and + * creates its lazy components then (a lazy component created in render would + * remount on every render). + */ +export async function chunkExportName( + parent: string, + chunkId: string, + options: { framework?: Framework; stubs?: Record } = {}, +) { + const framework = options.framework ?? 'react' + const routerPackage = `@tanstack/${framework}-router` + const lazy: Array<{ + load: () => Promise<{ chunkId?: string }> + name: string + }> = [] + const chunkIds = new Set(getChunkIds(parent)) + const parentId = chunkId.split('?')[0]! + const graph = createTestModuleGraph({ + modules: { [parentId]: parent }, + stubs: { + ...hydrateStubs(framework), + ...options.stubs, + [routerPackage]: { + ...(options.stubs?.[routerPackage] as Record), + lazyRouteComponent: ( + load: () => Promise<{ chunkId?: string }>, + name = 'default', + ) => { + lazy.push({ load, name }) + return () => null + }, + }, + }, + fallbackStub: (_, names) => + Object.fromEntries([...names].map((name) => [name, () => undefined])), + framework, + // Each chunk is a module naming its id. + load: (id) => + chunkIds.has(id) ? `export const chunkId = ${JSON.stringify(id)}` : null, + }) + await graph.import(parentId) + const names = new Set() + for (const { load, name } of lazy) { + if ((await load()).chunkId === chunkId) { + names.add(name) + } + } + if (names.size !== 1) { + throw new Error( + `expected the parent to render one export of ${chunkId}, got ${[...names].join(', ') || 'none'}`, + ) + } + return [...names][0]! +} + +/** + * Renders, with the given props, the `` chunk `chunk` (the code of + * the chunk `chunkId`, evaluated with `stubs`): the export of it that the + * client `parent` renders (`chunkExportName`). + */ +export async function renderChunk( + parent: string, + chunkId: string, + chunk: string, + props: Record = {}, + stubs: Record = {}, +) { + const name = await chunkExportName(parent, chunkId) + const component = (await evaluateModule(chunk, stubs))[name] + if (typeof component !== 'function') { + throw new Error(`expected the chunk to export a component as ${name}`) + } + return component(props) +} + +/** + * Parent-module stubs of a framework (default `react`): `` marks + * where its children render, lazy chunks render their props. + */ +export function hydrateStubs( + framework: Framework = 'react', +): Record> { + return { + [`@tanstack/${framework}-start`]: { + Hydrate: (props: { children: unknown }) => `[${props.children ?? ''}]`, + }, + [`@tanstack/${framework}-start/hydration`]: { + idle: () => 'idle', + visible: () => 'visible', + interaction: () => 'interaction', + load: () => 'load', + media: () => 'media', + never: () => 'never', + }, + [`@tanstack/${framework}-router`]: { + lazyRouteComponent: () => (props: Record) => + `lazy(${Object.keys(props) + .filter((name) => name !== 'children') + .sort() + .map((name) => `${name}=${String(props[name])}`) + .join(',')})`, + }, + } +} + +/** The React parent-module stubs (`hydrateStubs('react')`). */ +export const hydrateParentStubs = hydrateStubs('react') + +/** + * Compiles `code` for the client (React unless `framework` says otherwise) + * and evaluates it once its `` chunks have loaded, like the browser + * does: the parent and the chunks are one linked module graph (each chunk + * compiled like the bundler compiles the virtual module), + * `lazyRouteComponent(load, exportName)` loads its chunk through `load()` and + * renders the export it names, and its `preload()` loads the chunk too. + * `beforeChunksLoad` runs app code between the parent's evaluation and the + * chunks'. Returns the evaluated parent, the ids of the chunks it imports + * (`chunkIds`, in source order), `lazyChunks` (every lazy chunk component and + * whether it rendered) and `preloaded` (the chunk each `preload()` call + * loaded, by id, evaluated, and the component of it the lazy component + * renders, in call order; loading the chunks here is not a preload). The parent and every chunk must be valid modules; they may + * read the globals the source reads. With `chunksFromSource`, the chunks + * load the way Rsbuild loads them: given the source file's code, from a + * Hydrate plugin that did not compile the parent (e.g. another build + * worker). + */ +export async function loadClientModule( + code: string, + options: { + stubs?: Record + beforeChunksLoad?: (module: Record) => void + runtime?: string + /** Id the module is compiled under. */ + id?: string + /** Default `react`. */ + framework?: Framework + chunksFromSource?: boolean + } = {}, +) { + const id = options.id ?? moduleId + const framework = options.framework ?? 'react' + const { parent, chunks, plugin } = await compileHydrate('client', code, { + id, + framework, + }) + const lazyChunks: Array<{ name: string; rendered: boolean }> = [] + const loaders: Array<{ + name: string + load: () => Promise> + component?: (props: unknown) => unknown + }> = [] + const preloaded: Array<{ + id: string + module: Record + component: (props: Record) => any + }> = [] + const loadedChunkIds: Array = [] + /** Id of the chunk an evaluated chunk namespace is. */ + const chunkIdOf = async (namespace: unknown) => { + for (const chunkId of loadedChunkIds) { + if ((await graph.import(chunkId)) === namespace) { + return chunkId + } + } + throw new Error('preload() loaded a module that is not a chunk') + } + const routerPackage = `@tanstack/${framework}-router` + const userStubs = options.stubs ?? {} + const stubs: Record = { + ...hydrateStubs(framework), + ...userStubs, + [routerPackage]: { + ...(userStubs[routerPackage] as Record), + lazyRouteComponent: ( + load: () => Promise>, + name = 'default', + ) => { + const lazyChunk = { name, rendered: false } + const loader: (typeof loaders)[number] = { name, load } + lazyChunks.push(lazyChunk) + loaders.push(loader) + return Object.assign( + (props: unknown) => { + lazyChunk.rendered = true + if (!loader.component) { + throw new Error(`lazy chunk ${name} rendered before it loaded`) + } + return loader.component(props) + }, + { + preload: async () => { + const namespace = await load() + preloaded.push({ + id: await chunkIdOf(namespace), + module: namespace, + component: namespace[name], + }) + return namespace + }, + }, + ) + }, + }, + } + // The globals the source reads may be read by its modules too. + const globals = await freeIdentifiers(code) + expect(await getModuleErrors(parent, undefined, { globals })).toEqual([]) + const chunkPlugin = options.chunksFromSource + ? createHydrateCompilerPlugin() + : plugin + const { compile } = createStartCompiler({ + env: 'client', + compilerPlugins: [chunkPlugin], + framework, + }) + const graph = createTestModuleGraph({ + modules: { [id]: parent }, + stubs, + runtime: options.runtime, + framework, + // The bundler compiles each virtual module it loads like any other module. + load: async (chunkId) => { + // The linker also probes ids with extensions appended, which are no + // modules of this graph. + if (chunkId.split('?')[0] !== id.split('?')[0]) { + return null + } + const chunk = options.chunksFromSource + ? (chunkPlugin.loadVirtualModule?.({ + id: chunkId, + code, + root: '/test', + env: 'client', + envName: 'client', + })?.code ?? null) + : loadChunk(plugin, 'client', chunkId) + if (chunk === null) { + return null + } + loadedChunkIds.push(chunkId) + return (await compile(chunk, chunkId)) ?? chunk + }, + }) + const module = await graph.import(id) + options.beforeChunksLoad?.(module) + for (const chunk of chunks) { + expect(await getModuleErrors(chunk, undefined, { globals })).toEqual([]) + } + // Loading a chunk may create the lazy components of nested boundaries, + // which the iteration reaches as they are appended. + for (const loader of loaders) { + loader.component = (await loader.load())[loader.name] + } + return { module, chunkIds: getChunkIds(parent), lazyChunks, preloaded } +} + +/** + * The children were split and rendered through their chunks: the client + * created at least one lazy chunk component (`loadClientModule`), and every + * one rendered. + */ +export function expectRenderedThroughChunks( + lazyChunks: Array<{ rendered: boolean }>, +) { + expect(lazyChunks.length).toBeGreaterThan(0) + expect(lazyChunks.filter((chunk) => !chunk.rendered)).toEqual([]) +} + +/** + * Renders `code` compiled for `env` and records the boundary id (`h`) each + * `` renders with: on the server the id in its markup, on the client + * the id it hydrates with (chunks loaded, `loadClientModule`). `stubs` are + * linked over the parent-module stubs (`hydrateStubs`); the recording + * `Hydrate` is added to the `@tanstack/-start` stub. + */ +export async function renderBoundaries( + env: 'client' | 'server', + code: string, + render: (module: Record) => unknown, + options: { + id?: string + framework?: Framework + stubs?: Record + runtime?: string + } = {}, +) { + const { stubs: userStubs = {}, runtime, ...compileOptions } = options + const framework = options.framework ?? 'react' + const ids: Array = [] + const Hydrate = (props: { children?: unknown; h?: unknown }) => { + ids.push(props.h) + return `[${props.children ?? ''}]` + } + const startPackage = `@tanstack/${framework}-start` + const stubs = { + ...hydrateStubs(framework), + ...userStubs, + [startPackage]: { + ...(userStubs[startPackage] as Record | undefined), + Hydrate, + }, + } + const module = + env === 'server' + ? await evaluateModule( + (await compileHydrate('server', code, compileOptions)).parent, + stubs, + { framework, runtime }, + ) + : (await loadClientModule(code, { ...compileOptions, stubs, runtime })) + .module + const html = render(module) + return { html, ids } +} diff --git a/packages/start-plugin-core/tests/source-maps.ts b/packages/start-plugin-core/tests/source-maps.ts new file mode 100644 index 00000000000..9a40c3feafc --- /dev/null +++ b/packages/start-plugin-core/tests/source-maps.ts @@ -0,0 +1,70 @@ +/** + * Reads the source map of a compiled module: the original position of a + * generated one. Re-exported by `regression-helpers.ts`. + */ +import type { StartCompilerTransformResult } from '../src/types' + +const base64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +/** The original (0-based) line and column of each source map segment. */ +function originalPositions(mappings: string) { + const positions: Array<[line: number, column: number]> = [] + let line = 0 + let column = 0 + for (const segment of mappings.split(/[;,]/)) { + const values: Array = [] + let value = 0 + let shift = 0 + for (const char of segment) { + const digit = base64.indexOf(char) + value += (digit & 31) << shift + if (digit & 32) { + shift += 5 + } else { + values.push(value & 1 ? -(value >>> 1) : value >>> 1) + value = 0 + shift = 0 + } + } + if (values.length >= 4) { + line += values[2]! + column += values[3]! + positions.push([line, column]) + } + } + return positions +} + +/** + * Where in `source` the children a chunk renders come from: the start of the + * outermost `` element of `source` that contains the code the + * chunk maps back to (through its source map), or `Infinity`. + */ +export function chunkBoundaryStart( + chunk: StartCompilerTransformResult, + source: string, + boundaries: Array<{ start: number; end: number }>, +) { + const mappings = chunk.map?.mappings + if (typeof mappings !== 'string') { + return Infinity + } + const lineStarts = [0] + for (let index = 0; index < source.length; index++) { + if (source[index] === '\n') { + lineStarts.push(index + 1) + } + } + let start = Infinity + for (const [line, column] of originalPositions(mappings)) { + const offset = (lineStarts[line] ?? Infinity) + column + const boundary = boundaries.find( + (range) => range.start <= offset && offset < range.end, + ) + if (boundary) { + start = Math.min(start, boundary.start) + } + } + return start +} From adb638c01a8c576de29b9a3fea20ad1d023748a2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 19:13:03 +0200 Subject: [PATCH 0415/1425] test(compiler): Node compile cache and a report-only budget reporter Under Vitest, the compiler packages set NODE_COMPILE_CACHE for their test workers and the child Node runs those start (no outcome change). compiler-test-utils gets a Vitest reporter that compares a tier's run with its budget (test-budgets.json, the B3 table: target and hard limit per package and tier, dev at default workers, ci at one worker; at one worker, each slow-tier file against the 15 s per-file limit). It only reports for now. router-plugin, start-plugin-core, compiler-test-utils and react-start-rsc use it; router-generator does not depend on compiler-test-utils. Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/package.json | 1 + .../src/budget-reporter.ts | 118 ++++++++++++++++++ .../compiler-test-utils/test-budgets.json | 26 ++++ packages/compiler-test-utils/vite.config.ts | 13 ++ packages/react-start-rsc/vite.config.ts | 1 + packages/router-plugin/vite.config.ts | 13 ++ packages/start-plugin-core/vite.config.ts | 13 ++ 7 files changed, 185 insertions(+) create mode 100644 packages/compiler-test-utils/src/budget-reporter.ts create mode 100644 packages/compiler-test-utils/test-budgets.json diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index 427b094859c..3f07a1f5baf 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -6,6 +6,7 @@ "type": "module", "exports": { "./browser": "./src/browser.ts", + "./budget-reporter": "./src/budget-reporter.ts", "./build-harness": "./src/build-harness.ts", "./dev-server": "./src/dev-server.ts", "./module-linker": "./src/module-linker.ts", diff --git a/packages/compiler-test-utils/src/budget-reporter.ts b/packages/compiler-test-utils/src/budget-reporter.ts new file mode 100644 index 00000000000..be235939a3b --- /dev/null +++ b/packages/compiler-test-utils/src/budget-reporter.ts @@ -0,0 +1,118 @@ +/** + * A Vitest reporter that compares the duration of a compiler test tier with + * its budget (`test-budgets.json`): the run's duration against the tier's + * target and hard limit, and, at one worker, each slow-tier file against the + * per-file hard limit. Report-only: it prints the comparison and never fails + * the run. + * + * The tier is the Vitest project: `integration` is the slow tier, any other + * project the fast tier. The machine is `ci` at one worker, `dev` otherwise. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Reporter, TestModule, Vitest } from 'vitest/node' + +type Limits = [target: number, hard: number] + +interface Budgets { + slowFileHardCi: number + packages: Record< + string, + Record<'unit' | 'integration', { dev?: Limits; ci?: Limits }> + > +} + +const budgets: Budgets = JSON.parse( + readFileSync( + fileURLToPath(new URL('../test-budgets.json', import.meta.url)), + 'utf8', + ), +) + +function packageName(root: string) { + return ( + JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) as { + name: string + } + ).name +} + +function verdict(seconds: number, [target, hard]: Limits) { + if (seconds > hard) { + return `over the hard limit (${hard} s)` + } + if (seconds > target) { + return `over target (${target} s), under the hard limit (${hard} s)` + } + return `within target (${target} s)` +} + +/** The time a test module took: environment, setup, import and its tests. */ +function moduleSeconds(testModule: TestModule) { + const d = testModule.diagnostic() + return ( + (d.environmentSetupDuration + + d.prepareDuration + + d.collectDuration + + d.setupDuration + + d.duration) / + 1000 + ) +} + +export default class BudgetReporter implements Reporter { + private vitest!: Vitest + private start = 0 + + onInit(vitest: Vitest) { + this.vitest = vitest + } + + onTestRunStart() { + this.start = performance.now() + } + + onTestRunEnd(testModules: ReadonlyArray) { + const seconds = (performance.now() - this.start) / 1000 + const name = packageName(this.vitest.config.root) + const packageBudgets = budgets.packages[name] + if (!packageBudgets || testModules.length === 0) { + return + } + const tiers = new Set( + testModules.map((m) => + m.project.name === 'integration' ? 'integration' : 'unit', + ), + ) + const machine = this.vitest.config.maxWorkers <= 1 ? 'ci' : 'dev' + const lines: Array = [] + if (tiers.size === 1) { + const tier = [...tiers][0] as 'unit' | 'integration' + const limits = packageBudgets[tier][machine] + lines.push( + limits + ? `${name} ${tier}: ${seconds.toFixed(1)} s at ${this.vitest.config.maxWorkers} worker(s), ${verdict(seconds, limits)} [${machine}]` + : `${name} ${tier}: ${seconds.toFixed(1)} s at ${this.vitest.config.maxWorkers} worker(s), no ${machine} budget`, + ) + } else { + lines.push( + `${name}: ${seconds.toFixed(1)} s for both tiers; run one tier to compare it with its budget`, + ) + } + if (machine === 'ci') { + for (const testModule of testModules) { + const fileSeconds = moduleSeconds(testModule) + if ( + testModule.project.name === 'integration' && + fileSeconds > budgets.slowFileHardCi + ) { + lines.push( + ` ${path.relative(this.vitest.config.root, testModule.moduleId)}: ${fileSeconds.toFixed(1)} s, over the per-file hard limit (${budgets.slowFileHardCi} s)`, + ) + } + } + } + this.vitest.logger.log(`\nTest budget (report only)\n${lines.join('\n')}`) + } +} diff --git a/packages/compiler-test-utils/test-budgets.json b/packages/compiler-test-utils/test-budgets.json new file mode 100644 index 00000000000..9dab6b0cc1a --- /dev/null +++ b/packages/compiler-test-utils/test-budgets.json @@ -0,0 +1,26 @@ +{ + "$comment": "Vitest durations in seconds (Nx overhead not included) of the compiler test tiers. dev: default workers on a developer machine; ci: 1 worker on a 2-vCPU agent. Each entry is [target, hard]. slowFileHardCi: hard limit of any slow-tier file at 1 worker. Read by src/budget-reporter.ts; report-only for now.", + "slowFileHardCi": 15, + "packages": { + "@tanstack/router-plugin": { + "unit": { "dev": [4, 8], "ci": [10, 20] }, + "integration": { "ci": [60, 120] } + }, + "@tanstack/start-plugin-core": { + "unit": { "dev": [4, 8], "ci": [12, 20] }, + "integration": { "ci": [45, 90] } + }, + "@tanstack/router-generator": { + "unit": { "dev": [3, 6], "ci": [6, 10] }, + "integration": { "ci": [20, 40] } + }, + "@tanstack/react-start-rsc": { + "unit": { "dev": [2, 4], "ci": [4, 8] }, + "integration": { "ci": [30, 60] } + }, + "@tanstack/compiler-test-utils": { + "unit": { "dev": [2, 4], "ci": [4, 8] }, + "integration": { "ci": [10, 20] } + } + } +} diff --git a/packages/compiler-test-utils/vite.config.ts b/packages/compiler-test-utils/vite.config.ts index 4dc3b70f449..faa3536853c 100644 --- a/packages/compiler-test-utils/vite.config.ts +++ b/packages/compiler-test-utils/vite.config.ts @@ -1,11 +1,24 @@ +import path from 'node:path' +import os from 'node:os' import { configDefaults, defineConfig } from 'vitest/config' import packageJson from './package.json' +// Under Vitest: Node's compile cache for the test workers and the child +// Node runs they start (the harness passes this environment on). No outcome +// change; less time compiling the bundlers, jsdom and the built apps again. +if (process.env.VITEST) { + process.env.NODE_COMPILE_CACHE ??= path.join( + os.tmpdir(), + 'node-compile-cache', + ) +} + export default defineConfig({ test: { name: packageJson.name, dir: './tests', watch: false, + reporters: ['default', '@tanstack/compiler-test-utils/budget-reporter'], projects: [ { // Fast tier (`test:unit`): in process; a Vite dev server in diff --git a/packages/react-start-rsc/vite.config.ts b/packages/react-start-rsc/vite.config.ts index c8dfcf917bd..6db91feb775 100644 --- a/packages/react-start-rsc/vite.config.ts +++ b/packages/react-start-rsc/vite.config.ts @@ -22,6 +22,7 @@ const config = defineConfig({ include: ['**/*.{test-d,test,spec}.?(c|m)[jt]s?(x)'], name: packageJson.name, watch: false, + reporters: ['default', '@tanstack/compiler-test-utils/budget-reporter'], environment: 'jsdom', setupFiles: ['./tests/setup.ts'], }, diff --git a/packages/router-plugin/vite.config.ts b/packages/router-plugin/vite.config.ts index b20d04db66c..78ce6c9008d 100644 --- a/packages/router-plugin/vite.config.ts +++ b/packages/router-plugin/vite.config.ts @@ -1,12 +1,25 @@ +import path from 'node:path' +import os from 'node:os' import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import packageJson from './package.json' +// Under Vitest: Node's compile cache for the test workers and the child +// Node runs they start (the harness passes this environment on). No outcome +// change; less time compiling the bundlers, jsdom and the built apps again. +if (process.env.VITEST) { + process.env.NODE_COMPILE_CACHE ??= path.join( + os.tmpdir(), + 'node-compile-cache', + ) +} + const config = defineConfig({ test: { name: packageJson.name, dir: './tests', watch: false, + reporters: ['default', '@tanstack/compiler-test-utils/budget-reporter'], projects: [ { // Fast tier (`test:unit`): compiles and evaluates in process; a Vite diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index bf58d2ebaae..84d6fa394f8 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -1,12 +1,25 @@ +import path from 'node:path' +import os from 'node:os' import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import packageJson from './package.json' +// Under Vitest: Node's compile cache for the test workers and the child +// Node runs they start (the harness passes this environment on). No outcome +// change; less time compiling the bundlers, jsdom and the built apps again. +if (process.env.VITEST) { + process.env.NODE_COMPILE_CACHE ??= path.join( + os.tmpdir(), + 'node-compile-cache', + ) +} + const config = defineConfig({ test: { name: packageJson.name, dir: './tests', watch: false, + reporters: ['default', '@tanstack/compiler-test-utils/budget-reporter'], projects: [ { // Fast tier (`test:unit`): compiles and evaluates in process; a Vite From 7acb00fb1d9ce08b36c8feb1c29983a62aab3920 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 19:13:10 +0200 Subject: [PATCH 0416/1425] ci(compiler-platforms): fast tier plus a browser-free integration smoke set The Unit tests step now runs the fast tier only (test:unit no longer includes the Chromium tests, which these runners cannot launch). A new Integration smoke step runs test:integration:smoke of router-plugin (a Vite and an Rsbuild production build of a split route) and start-plugin-core (a Vite and an Rsbuild import-protection build). The browser tests stay in test:integration on Linux x64 in the main PR workflow. The workflow also runs on compiler-test-utils changes, which both suites import. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/compiler-platforms.yml | 15 ++++++++++++--- packages/router-plugin/package.json | 2 +- packages/start-plugin-core/package.json | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/compiler-platforms.yml b/.github/workflows/compiler-platforms.yml index 8d70443196b..8f05ebc5539 100644 --- a/.github/workflows/compiler-platforms.yml +++ b/.github/workflows/compiler-platforms.yml @@ -2,8 +2,11 @@ name: Compiler Platforms # The main PR workflow only runs on Linux x64 (glibc). The compilers load a # native Yuku binary per platform and handle platform-specific paths, so run -# their unit tests and a Vite and Rsbuild production build on the other -# platforms we ship to. +# their fast test tier (test:unit), a small integration smoke set without a +# browser (test:integration:smoke: real Vite and Rsbuild builds) and a Vite and +# Rsbuild production build on the other platforms we ship to. The rest of the +# slow tier (test:integration, including the Chromium tests) runs on Linux x64 +# in the main PR workflow. on: pull_request: @@ -12,6 +15,7 @@ on: - 'packages/router-generator/**' - 'packages/router-plugin/**' - 'packages/start-plugin-core/**' + - 'packages/compiler-test-utils/**' - 'pnpm-lock.yaml' - '.github/workflows/compiler-platforms.yml' schedule: @@ -89,11 +93,16 @@ jobs: --filter "@tanstack/router-plugin..." --filter "@tanstack/start-plugin-core..." --filter "tanstack-react-start-e2e-basic..." - - name: Unit tests + - name: Unit tests (fast tier) run: >- pnpm nx run-many --targets=test:unit --projects=@tanstack/router-generator,@tanstack/router-plugin,@tanstack/start-plugin-core --skip-nx-cache --outputStyle=static + - name: Integration smoke (no browser) + run: >- + pnpm nx run-many --targets=test:integration:smoke + --projects=@tanstack/router-plugin,@tanstack/start-plugin-core + --skip-nx-cache --outputStyle=static - name: Production builds run: >- pnpm nx run-many --targets=build:vite:ssr,build:rsbuild:ssr diff --git a/packages/router-plugin/package.json b/packages/router-plugin/package.json index 521a162dfea..ab305bc3f56 100644 --- a/packages/router-plugin/package.json +++ b/packages/router-plugin/package.json @@ -30,7 +30,7 @@ "test:unit": "vitest --project unit", "test:unit:dev": "vitest --project unit --watch", "test:integration": "vitest --project integration", - "test:integration:smoke": "vitest --project integration integration/build-production.test.ts -t \"vite a production react client bundle has no dev-only code|autoCodeSplitting: true. has no dev-only code\"", + "test:integration:smoke": "vitest --project integration integration/build-production.test.ts -t \"vite.*a production react client bundle has no dev-only code|autoCodeSplitting: true. has no dev-only code\"", "test:eslint": "eslint ./src", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index bdc4b3d4c32..a61894bbf15 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -30,7 +30,7 @@ "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit && pnpm test:integration", "test:unit": "vitest --project unit", "test:integration": "vitest --project integration", - "test:integration:smoke": "vitest --project integration integration/importProtection/build.test.ts -t \"build a denied re-export barrel the client uses fails the build\"", + "test:integration:smoke": "vitest --project integration integration/importProtection/build.test.ts -t \"build.*a denied re-export barrel the client uses fails the build\"", "test:eslint": "eslint ./src", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", From 54979943329cc801f8d7274a3a5fd97c5dfd1353 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 19:13:10 +0200 Subject: [PATCH 0417/1425] docs(compiler): test tiers in AGENTS.md and CONTRIBUTING.md; point split suites at their other half Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CONTRIBUTING.md | 1 + packages/router-plugin/AGENTS.md | 1 + .../tests/integration/code-splitter-dead-code.test.ts | 2 ++ .../tests/integration/code-splitter-determinism.test.ts | 2 ++ .../tests/integration/code-splitter-imports-exports.test.ts | 2 ++ .../tests/integration/code-splitter-module-state.test.ts | 2 ++ .../tests/integration/code-splitter-plugin-pipeline.test.ts | 2 ++ .../integration/known-bugs-code-splitter-client-builds.test.ts | 2 ++ .../integration/known-bugs-code-splitter-page-code.test.ts | 2 ++ .../integration/known-bugs-code-splitter-ssr-builds.test.ts | 2 ++ .../tests/integration/known-bugs-route-hmr.test.ts | 2 ++ .../router-plugin/tests/integration/plugin-options.test.ts | 2 ++ .../router-plugin/tests/integration/regression-helpers.test.ts | 2 ++ packages/start-plugin-core/AGENTS.md | 3 ++- .../tests/integration/importProtection/known-bugs.test.ts | 2 ++ .../tests/integration/known-bugs-start-compiler.test.ts | 2 ++ .../tests/integration/rsbuild/normalized-client-build.test.ts | 2 ++ 18 files changed, 33 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 320eda4098b..110c5f544ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ CI=1 NX_DAEMON=false pnpm nx run : --outputStyle=stream --skipR ``` - Run one Nx command at a time. For a ~20-second startup/graph stall, stop, run `pnpm nx reset`, and retry once, then escalate. Do not apply that timeout to running tests. -- For code changes, run affected packages' `test:unit`, `test:types`, and `test:eslint` where available, including affected consumers; use `test:e2e` for browser/app behavior and `test:build` for exports/build changes. Root `pnpm test` includes the full e2e suite. Published-code changes require `pnpm changeset`. +- For code changes, run affected packages' `test:unit`, `test:integration`, `test:types`, and `test:eslint` where available, including affected consumers; use `test:e2e` for browser/app behavior and `test:build` for exports/build changes. Root `pnpm test` includes the full e2e suite. Published-code changes require `pnpm changeset`. ## Runtime rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b6e808af1f..12ba6656d1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,7 @@ Run the checks relevant to your changes while developing: - `pnpm test:eslint` checks linting for affected packages. - `pnpm test:types` checks types for affected packages. - `pnpm test:unit` runs unit tests for affected packages. +- `pnpm test:integration` runs the slow tier of the compiler packages (`router-plugin`, `start-plugin-core`, `compiler-test-utils`): tests that start real builds, dev servers or browsers. - `pnpm test:build` checks builds for affected packages. - `pnpm test:e2e` runs end-to-end tests and requires the Playwright browsers. diff --git a/packages/router-plugin/AGENTS.md b/packages/router-plugin/AGENTS.md index 6a68037a1ae..0d6f25296b9 100644 --- a/packages/router-plugin/AGENTS.md +++ b/packages/router-plugin/AGENTS.md @@ -4,6 +4,7 @@ - Every option configured for splitting must end up split. When a pattern the user can change prevents it, the option keeps working in the entry and the build warns once, naming the file and the option. When the limitation is ours, it is a bug to fix, not a warning. - `handleRouteUpdate` is stringified and injected into app modules. Keep runtime dependencies inside the function or explicitly emit them; extracting a helper to plugin module scope breaks the generated function. `tests/handle-route-update.test.ts` executes that emitted code: it compiles edited route files with the route HMR plugin and evaluates them next to a live router. - Route HMR rebuilds indexes, clears `resolvePathCache`, replaces lazy-chunk ownership, and refreshes active routes while preserving component identities (React, Solid and Vue). Keep these steps coordinated when adding derived state. +- Tests run in two tiers. `test:unit` (fast tier, the inner loop) runs `tests/**` in process; a Vite dev server in middleware mode is allowed. `test:integration` (slow tier) runs `tests/integration/**`: every test that starts a real build (`build`, `buildAndRun`), an Rsbuild dev server or watch compiler, a child Node process or a browser page (`openRouteHmrPage`). Put such tests under `tests/integration/`; a file with tests of both kinds is split by test into files of the same name in both directories. Run both tiers before handing back. - Test code-splitting changes by behaviour: compile the route and load the emitted modules with `loadRoute` from `tests/regression-helpers.ts` (built on the module linker from `@tanstack/compiler-test-utils`), and assert what users see (rendered output, loader results, init counts). Outcomes the bundler decides (dead code, chunk contents, `sideEffects` / `@__PURE__` hints) are asserted in a real build with `build` from `tests/build-harness.ts`, per bundler. Do not add output snapshots. - Run behavioural tests for React, Solid and Vue through the `targetFramework` option of the test helpers. - Test what the page shows after a route file edit with `openRouteHmrPage` (`tests/route-hmr-page.ts`): a real dev server with the framework's plugin, a real browser page and the real HMR client. Never copy bundler, HMR-client or React Refresh logic into tests; `hotUpdate` only runs our route HMR code against the bundler's hot API. diff --git a/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts index 6ecdbb0ba75..846a11c4d2e 100644 --- a/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-dead-code.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../code-splitter-dead-code.test.ts (fast tier). import { describe, expect, it } from 'vitest' import { build, routeApp } from '../build-harness' import { head } from '../regression-helpers' diff --git a/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts b/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts index 350b4984a00..41360f0f592 100644 --- a/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-determinism.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../code-splitter-determinism.test.ts (fast tier). /** * Code splitting does not depend on where the project lives or on how the * platform spells paths: the same route compiles to the same modules under diff --git a/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts b/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts index a3a9b207f89..e1854198c17 100644 --- a/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-imports-exports.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../code-splitter-imports-exports.test.ts (fast tier). import { describe, expect, it } from 'vitest' import { build, clientMountEntry, routeApp } from '../build-harness' import { head } from '../regression-helpers' diff --git a/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts b/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts index 1adb5ef950e..7e038f9b705 100644 --- a/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-module-state.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../code-splitter-module-state.test.ts (fast tier). import { describe, expect, it } from 'vitest' import { buildAndRun, head } from '../regression-helpers' diff --git a/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts b/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts index 0474619801a..fe36b6d8eb9 100644 --- a/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/integration/code-splitter-plugin-pipeline.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../code-splitter-plugin-pipeline.test.ts (fast tier). import { BuildError } from '@tanstack/compiler-test-utils/build-harness' import { expect, it } from 'vitest' import { build, routeApp } from '../build-harness' diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts index fab29dd8e43..35d4e5fb74d 100644 --- a/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../known-bugs-code-splitter.test.ts (fast tier). /** * Known code-splitter bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts index 30c187a2fc4..ad52cf4b0c7 100644 --- a/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-page-code.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../known-bugs-code-splitter.test.ts (fast tier). /** * Known code-splitter bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts index 77dbf07e295..362b276bf74 100644 --- a/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-ssr-builds.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../known-bugs-code-splitter.test.ts (fast tier). /** * Known code-splitter bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. diff --git a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts index d884c1e35c1..82c93f00279 100644 --- a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../known-bugs-route-hmr.test.ts (fast tier). /** * Known route HMR and React Refresh bugs. Each test asserts correct behaviour * for a bug on main and is marked .fails; remove .fails when the bug is fixed. diff --git a/packages/router-plugin/tests/integration/plugin-options.test.ts b/packages/router-plugin/tests/integration/plugin-options.test.ts index 6681431b96f..04cf2d269ba 100644 --- a/packages/router-plugin/tests/integration/plugin-options.test.ts +++ b/packages/router-plugin/tests/integration/plugin-options.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../plugin-options.test.ts (fast tier). /** * Router plugin options and entry points, through real builds: what each * option changes in the output or on disk. diff --git a/packages/router-plugin/tests/integration/regression-helpers.test.ts b/packages/router-plugin/tests/integration/regression-helpers.test.ts index 79a7f708f60..c37c20f7583 100644 --- a/packages/router-plugin/tests/integration/regression-helpers.test.ts +++ b/packages/router-plugin/tests/integration/regression-helpers.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../regression-helpers.test.ts (fast tier). /** * Controls for the regression harness itself: module discovery, lazy * evaluation and output validation (module linking and module validation: diff --git a/packages/start-plugin-core/AGENTS.md b/packages/start-plugin-core/AGENTS.md index cd73bbf3074..76e9b460ef4 100644 --- a/packages/start-plugin-core/AGENTS.md +++ b/packages/start-plugin-core/AGENTS.md @@ -4,4 +4,5 @@ - For import-protection changes, read the [shared internals](src/import-protection/INTERNALS.md) and [Vite lifecycle](src/vite/import-protection-plugin/INTERNALS.md). Preserve adapter-specific deferral and survival checks; development has no build-time tree-shaking, and Vite's error/mock modes differ. Cover actual leaks and safe boundaries in cold/warm development and builds. - Compiler output must match runtime contracts in `start-client-core`, `start-fn-stubs`, and `start-server-core`. For `createServerFn`, cover client, SSR caller, and server-provider output separately in [`tests/createServerFn/createServerFn.test.ts`](tests/createServerFn/createServerFn.test.ts). - Use a built Start e2e app to validate generated imports and runtime behavior together; compiler snapshots alone do not exercise that integration. -- The shared compiler serves both Vite and Rsbuild. For shared compiler changes, check the relevant adapter suites in `tests/vite/` and `tests/rsbuild/`, plus affected Start e2e modes for build, server-function, or HMR behavior. +- The shared compiler serves both Vite and Rsbuild. For shared compiler changes, check the relevant adapter suites in `tests/vite/`, `tests/rsbuild/` and `tests/integration/rsbuild/`, plus affected Start e2e modes for build, server-function, or HMR behavior. +- Tests run in two tiers. `test:unit` (fast tier, the inner loop) runs `tests/**` in process and depends on `^build` only. `test:integration` (slow tier) runs `tests/integration/**`: every test that starts a real build (`buildStart`), a dev server that links the built `@tanstack/react-start` (`startDevServer`, `startProtectedDevServer`, `linkStartRuntime`), an Rsbuild dev server, a child Node process or a browser page; its Nx target builds `@tanstack/react-start` first. Put such tests under `tests/integration/`; a file with tests of both kinds is split by test into files of the same name in both directories. Run both tiers before handing back. diff --git a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts index 97de98b81fa..ced8f7bc21a 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../../importProtection/known-bugs.test.ts (fast tier). /** * Known import-protection bugs. Each test asserts correct behaviour for a bug * on main and is marked .fails; remove .fails when the bug is fixed. diff --git a/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts index dfbd0162948..b4a7e7b4e73 100644 --- a/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-start-compiler.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../known-bugs-start-compiler.test.ts (fast tier). /** * Known Start compiler bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. diff --git a/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts index 425e2e97688..3be9c921b70 100644 --- a/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/integration/rsbuild/normalized-client-build.test.ts @@ -1,3 +1,5 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../../rsbuild/normalized-client-build.test.ts (fast tier). import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { promisify } from 'node:util' From 5b477be247dd819c2f5d3f300a52c4fa943a70c6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 19:48:18 +0200 Subject: [PATCH 0418/1425] test(router-plugin): split the client-build pins of known-bugs-code-splitter in two known-bugs-code-splitter-client-builds.test.ts took 21 s at one worker, over the 15 s per-file limit of the slow tier. Its server-code, symlink, source-map and production describes move unchanged to known-bugs-code-splitter-client-output.test.ts (now 10 s and 12 s). Co-Authored-By: Claude Opus 5.5 --- ...n-bugs-code-splitter-client-builds.test.ts | 267 +---------------- ...n-bugs-code-splitter-client-output.test.ts | 282 ++++++++++++++++++ 2 files changed, 283 insertions(+), 266 deletions(-) create mode 100644 packages/router-plugin/tests/integration/known-bugs-code-splitter-client-output.test.ts diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts index 35d4e5fb74d..7c6e06bfef5 100644 --- a/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-builds.test.ts @@ -4,7 +4,7 @@ * Known code-splitter bugs. Each test asserts correct behaviour for a bug on * main and is marked .fails; remove .fails when the bug is fixed. */ -import { BuildError } from '@tanstack/compiler-test-utils/build-harness' + import { describe, expect, test } from 'vitest' import { build, routeApp } from '../build-harness' import { head } from '../regression-helpers' @@ -190,268 +190,3 @@ export const Route = createFileRoute('/')({ component: Page }) 60_000, ) }) - -describe('server-only route options on the client', () => { - /** Start's client configuration of the code splitter. */ - const deleteNodes = ['ssr', 'server', 'headers'] - - /** A client build with Start's `deleteNodes`, reading a server secret. */ - async function secretPlacement(bundler: Bundler, option: string) { - const app = await build({ - bundler, - env: 'client', - pluginOptions: { codeSplittingOptions: { deleteNodes } }, - files: routeApp( - `${head}import { readSecret } from '../db.server' -export const Route = createFileRoute('/')({ - ${option}, - component: () =>

    page

    , -}) -`, - { - env: 'client', - files: { - 'db.server.ts': `export function readSecret() { - return (globalThis as any).MARK_SERVER_SECRET -} -`, - }, - }, - ), - }) - return app.where('MARK_SERVER_SECRET') - } - - // Control for the build-level canary below. - test.each(clientBundlers)( - 'a client build has no server code of an `ssr` property (%s)', - async (bundler) => { - expect(await secretPlacement(bundler, 'ssr: () => !!readSecret()')).toBe( - 'nowhere', - ) - }, - 60_000, - ) - - // Bug: build-level canary of the method pin above (same root cause). - // Impact: the server code of an `ssr()` method reaches the client bundle. - test.fails.each(clientBundlers)( - 'a client build has no server code of an `ssr()` method (%s)', - async (bundler) => { - expect( - await secretPlacement(bundler, 'ssr() { return !!readSecret() }'), - ).toBe('nowhere') - }, - 60_000, - ) -}) - -describe('symlinked route files', () => { - /** - * A client build with `deleteNodes: ['server']` of a route file reached - * through `routes/index.tsx`, a symlink when `linked`; returns where the - * server handler's code is, or `refused` when the build fails naming the - * route file. - */ - async function serverCodePlacement(bundler: Bundler, linked: boolean) { - const route = `${head}import { readSecret } from '../db.server' -export const Route = createFileRoute('/')({ - server: { handlers: { GET: () => readSecret() } }, - component: () =>

    page

    , -}) -` - const files = routeApp(route, { - env: 'client', - files: { - 'db.server.ts': `export function readSecret() { - return (globalThis as any).MARK_SERVER_SECRET -} -`, - }, - }) - if (linked) { - delete files['routes/index.tsx'] - files['linked/index.tsx'] = route - } - try { - const app = await build({ - bundler, - env: 'client', - pluginOptions: { codeSplittingOptions: { deleteNodes: ['server'] } }, - files, - symlinks: linked ? { 'routes/index.tsx': 'linked/index.tsx' } : {}, - }) - return app.where('MARK_SERVER_SECRET') - } catch (error) { - // A refusal names the route file - if ( - error instanceof BuildError && - error.errors.join('\n').includes('index.tsx') - ) { - return 'refused' - } - throw error - } - } - - // Control for the pin below (same app, a regular route file). - test.each(clientBundlers)( - 'a client build has no server handler code (%s)', - async (bundler) => { - expect(await serverCodePlacement(bundler, false)).toBe('nowhere') - }, - 60_000, - ) - - // Bug: the code splitter looks route files up by the path the generator - // scanned (the symlink), but bundlers resolve modules to their real path, - // so a symlinked route file is never compiled: nothing is split or - // removed, silently. Failing the build is a valid fix too. Source: #8514. - // Impact: with symlinked sources (Bazel, Nix, linked checkouts), Start's - // client bundle ships `server.handlers` and their server-only imports. - test.fails.each(clientBundlers)( - 'a client build of a symlinked route file has no server handler code (%s)', - async (bundler) => { - expect(['nowhere', 'refused']).toContain( - await serverCodePlacement(bundler, true), - ) - }, - 60_000, - ) -}) - -describe('source maps', () => { - /** - * Source files named by the source maps of a client build's chunks that - * hold the split component (`MARK_PAGE`) or a binding the route module - * shares with it (`MARK_SHARED`). - */ - async function splitChunkSources(bundler: Bundler) { - const app = await build({ - bundler, - env: 'client', - minify: false, - sourcemap: true, - files: routeApp( - `${head}const shared = { label: 'MARK_SHARED' } -function Page() { - return

    MARK_PAGE {shared.label}

    -} -export const Route = createFileRoute('/')({ - loader: () => shared, - component: Page, -}) -`, - { env: 'client' }, - ), - }) - const chunks = app.chunks.filter( - (chunk) => - chunk.code.includes('MARK_PAGE') || chunk.code.includes('MARK_SHARED'), - ) - return [...new Set(chunks.flatMap((chunk) => chunk.map?.sources ?? []))] - } - - /** Whether the sources name the route file, and none a virtual module. */ - function namesRouteFile(sources: Array) { - return { - routeFile: sources.some((source) => source.includes('routes/index.tsx')), - virtualModules: sources.filter((source) => /[?&]tsr-/.test(source)), - } - } - - // Control for the pin below: Rspack and webpack name modules by their - // resource path. - test.each(['rspack', 'webpack'] as const)( - 'split code maps to the route file (%s)', - async (bundler) => { - expect(namesRouteFile(await splitChunkSources(bundler))).toEqual({ - routeFile: true, - virtualModules: [], - }) - }, - 60_000, - ) - - // Bug: the split chunk and shared modules are compiled with their module - // id as the source file name, query included (`routes/index.tsx?tsr-split= - // component`), and Vite keeps the source names of the maps plugins return. - // Source: gap CC-S2 (plausible cause of #6667, #3828, #2991). - // Impact: devtools and error trackers show split code under a separate - // `index.tsx?tsr-split=…` file, so breakpoints set in the route file do - // not hit in split code. - test.fails( - 'split code maps to the route file (vite)', - async () => { - expect(namesRouteFile(await splitChunkSources('vite'))).toEqual({ - routeFile: true, - virtualModules: [], - }) - }, - 60_000, - ) -}) - -describe('production builds', () => { - /** - * A production client build of a route the code splitter warns about - * (today: an exported component it cannot split), with `NODE_ENV` as - * given (`null`: unset); returns whether the build warned, naming the - * route file, and where the app's absolute path (which the warning names) - * is in the bundle. - */ - async function warningPlacement(bundler: Bundler, nodeEnv?: null) { - const app = await build({ - bundler, - env: 'client', - nodeEnv, - files: routeApp( - `${head}export function Page() { - return

    page

    -} -export const Route = createFileRoute('/')({ - component: Page, - errorComponent: () =>

    error

    , -}) -`, - { env: 'client' }, - ), - }) - return { - warned: [...app.warnings, ...app.consoleMessages].some((message) => - message.includes('routes/index.tsx'), - ), - appPath: app.where(app.root), - } - } - - // Control for the pin below: with `NODE_ENV=production` exported. Its - // first assertion is a precondition of both tests, not a requirement: - // without a warning, the bundle has nothing to leak, so once the code - // splitter splits this route, use another route it warns about. - test.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no development warning (%s)', - async (bundler) => { - const { warned, appPath } = await warningPlacement(bundler) - expect(warned, 'precondition: the route makes the build warn').toBe(true) - expect(appPath).toBe('nowhere') - }, - 60_000, - ) - - // Bug: the code splitter's `console.warn` (today: "will not be - // code-split") is injected into the route module unless the build - // process's `NODE_ENV` is `production`, instead of following the - // bundler's mode as the rest of the code splitter does. webpack and Rspack - // set `NODE_ENV` only inside the bundle. Source: gap CC-P2. - // Impact: webpack/Rspack production bundles built from a shell without - // `NODE_ENV` log a development warning in every user's console, with the - // developer's absolute file path. - test.fails.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no development warning when NODE_ENV is unset (%s)', - async (bundler) => { - expect((await warningPlacement(bundler, null)).appPath).toBe('nowhere') - }, - 60_000, - ) -}) diff --git a/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-output.test.ts b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-output.test.ts new file mode 100644 index 00000000000..2412211edce --- /dev/null +++ b/packages/router-plugin/tests/integration/known-bugs-code-splitter-client-output.test.ts @@ -0,0 +1,282 @@ +// Slow-tier half of this suite: the tests that need a real tool. Its +// in-process tests are in ../known-bugs-code-splitter.test.ts (fast tier). +/** + * Known code-splitter bugs. Each test asserts correct behaviour for a bug on + * main and is marked .fails; remove .fails when the bug is fixed. + */ +import { BuildError } from '@tanstack/compiler-test-utils/build-harness' +import { describe, expect, test } from 'vitest' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' +import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' + +/** + * Bundlers whose router plugin splits routes (the esbuild adapter does not; + * pinned in build-harness.test.ts). + */ +const clientBundlers: Array = ['vite', 'rspack', 'webpack'] + +describe('server-only route options on the client', () => { + /** Start's client configuration of the code splitter. */ + const deleteNodes = ['ssr', 'server', 'headers'] + + /** A client build with Start's `deleteNodes`, reading a server secret. */ + async function secretPlacement(bundler: Bundler, option: string) { + const app = await build({ + bundler, + env: 'client', + pluginOptions: { codeSplittingOptions: { deleteNodes } }, + files: routeApp( + `${head}import { readSecret } from '../db.server' +export const Route = createFileRoute('/')({ + ${option}, + component: () =>

    page

    , +}) +`, + { + env: 'client', + files: { + 'db.server.ts': `export function readSecret() { + return (globalThis as any).MARK_SERVER_SECRET +} +`, + }, + }, + ), + }) + return app.where('MARK_SERVER_SECRET') + } + + // Control for the build-level canary below. + test.each(clientBundlers)( + 'a client build has no server code of an `ssr` property (%s)', + async (bundler) => { + expect(await secretPlacement(bundler, 'ssr: () => !!readSecret()')).toBe( + 'nowhere', + ) + }, + 60_000, + ) + + // Bug: build-level canary of the method pin above (same root cause). + // Impact: the server code of an `ssr()` method reaches the client bundle. + test.fails.each(clientBundlers)( + 'a client build has no server code of an `ssr()` method (%s)', + async (bundler) => { + expect( + await secretPlacement(bundler, 'ssr() { return !!readSecret() }'), + ).toBe('nowhere') + }, + 60_000, + ) +}) + +describe('symlinked route files', () => { + /** + * A client build with `deleteNodes: ['server']` of a route file reached + * through `routes/index.tsx`, a symlink when `linked`; returns where the + * server handler's code is, or `refused` when the build fails naming the + * route file. + */ + async function serverCodePlacement(bundler: Bundler, linked: boolean) { + const route = `${head}import { readSecret } from '../db.server' +export const Route = createFileRoute('/')({ + server: { handlers: { GET: () => readSecret() } }, + component: () =>

    page

    , +}) +` + const files = routeApp(route, { + env: 'client', + files: { + 'db.server.ts': `export function readSecret() { + return (globalThis as any).MARK_SERVER_SECRET +} +`, + }, + }) + if (linked) { + delete files['routes/index.tsx'] + files['linked/index.tsx'] = route + } + try { + const app = await build({ + bundler, + env: 'client', + pluginOptions: { codeSplittingOptions: { deleteNodes: ['server'] } }, + files, + symlinks: linked ? { 'routes/index.tsx': 'linked/index.tsx' } : {}, + }) + return app.where('MARK_SERVER_SECRET') + } catch (error) { + // A refusal names the route file + if ( + error instanceof BuildError && + error.errors.join('\n').includes('index.tsx') + ) { + return 'refused' + } + throw error + } + } + + // Control for the pin below (same app, a regular route file). + test.each(clientBundlers)( + 'a client build has no server handler code (%s)', + async (bundler) => { + expect(await serverCodePlacement(bundler, false)).toBe('nowhere') + }, + 60_000, + ) + + // Bug: the code splitter looks route files up by the path the generator + // scanned (the symlink), but bundlers resolve modules to their real path, + // so a symlinked route file is never compiled: nothing is split or + // removed, silently. Failing the build is a valid fix too. Source: #8514. + // Impact: with symlinked sources (Bazel, Nix, linked checkouts), Start's + // client bundle ships `server.handlers` and their server-only imports. + test.fails.each(clientBundlers)( + 'a client build of a symlinked route file has no server handler code (%s)', + async (bundler) => { + expect(['nowhere', 'refused']).toContain( + await serverCodePlacement(bundler, true), + ) + }, + 60_000, + ) +}) + +describe('source maps', () => { + /** + * Source files named by the source maps of a client build's chunks that + * hold the split component (`MARK_PAGE`) or a binding the route module + * shares with it (`MARK_SHARED`). + */ + async function splitChunkSources(bundler: Bundler) { + const app = await build({ + bundler, + env: 'client', + minify: false, + sourcemap: true, + files: routeApp( + `${head}const shared = { label: 'MARK_SHARED' } +function Page() { + return

    MARK_PAGE {shared.label}

    +} +export const Route = createFileRoute('/')({ + loader: () => shared, + component: Page, +}) +`, + { env: 'client' }, + ), + }) + const chunks = app.chunks.filter( + (chunk) => + chunk.code.includes('MARK_PAGE') || chunk.code.includes('MARK_SHARED'), + ) + return [...new Set(chunks.flatMap((chunk) => chunk.map?.sources ?? []))] + } + + /** Whether the sources name the route file, and none a virtual module. */ + function namesRouteFile(sources: Array) { + return { + routeFile: sources.some((source) => source.includes('routes/index.tsx')), + virtualModules: sources.filter((source) => /[?&]tsr-/.test(source)), + } + } + + // Control for the pin below: Rspack and webpack name modules by their + // resource path. + test.each(['rspack', 'webpack'] as const)( + 'split code maps to the route file (%s)', + async (bundler) => { + expect(namesRouteFile(await splitChunkSources(bundler))).toEqual({ + routeFile: true, + virtualModules: [], + }) + }, + 60_000, + ) + + // Bug: the split chunk and shared modules are compiled with their module + // id as the source file name, query included (`routes/index.tsx?tsr-split= + // component`), and Vite keeps the source names of the maps plugins return. + // Source: gap CC-S2 (plausible cause of #6667, #3828, #2991). + // Impact: devtools and error trackers show split code under a separate + // `index.tsx?tsr-split=…` file, so breakpoints set in the route file do + // not hit in split code. + test.fails( + 'split code maps to the route file (vite)', + async () => { + expect(namesRouteFile(await splitChunkSources('vite'))).toEqual({ + routeFile: true, + virtualModules: [], + }) + }, + 60_000, + ) +}) + +describe('production builds', () => { + /** + * A production client build of a route the code splitter warns about + * (today: an exported component it cannot split), with `NODE_ENV` as + * given (`null`: unset); returns whether the build warned, naming the + * route file, and where the app's absolute path (which the warning names) + * is in the bundle. + */ + async function warningPlacement(bundler: Bundler, nodeEnv?: null) { + const app = await build({ + bundler, + env: 'client', + nodeEnv, + files: routeApp( + `${head}export function Page() { + return

    page

    +} +export const Route = createFileRoute('/')({ + component: Page, + errorComponent: () =>

    error

    , +}) +`, + { env: 'client' }, + ), + }) + return { + warned: [...app.warnings, ...app.consoleMessages].some((message) => + message.includes('routes/index.tsx'), + ), + appPath: app.where(app.root), + } + } + + // Control for the pin below: with `NODE_ENV=production` exported. Its + // first assertion is a precondition of both tests, not a requirement: + // without a warning, the bundle has nothing to leak, so once the code + // splitter splits this route, use another route it warns about. + test.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no development warning (%s)', + async (bundler) => { + const { warned, appPath } = await warningPlacement(bundler) + expect(warned, 'precondition: the route makes the build warn').toBe(true) + expect(appPath).toBe('nowhere') + }, + 60_000, + ) + + // Bug: the code splitter's `console.warn` (today: "will not be + // code-split") is injected into the route module unless the build + // process's `NODE_ENV` is `production`, instead of following the + // bundler's mode as the rest of the code splitter does. webpack and Rspack + // set `NODE_ENV` only inside the bundle. Source: gap CC-P2. + // Impact: webpack/Rspack production bundles built from a shell without + // `NODE_ENV` log a development warning in every user's console, with the + // developer's absolute file path. + test.fails.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no development warning when NODE_ENV is unset (%s)', + async (bundler) => { + expect((await warningPlacement(bundler, null)).appPath).toBe('nowhere') + }, + 60_000, + ) +}) From 5057dad4497858eb2218c969f965ab7044154407 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:19:33 +0200 Subject: [PATCH 0419/1425] build(compiler-tests): add approved test-only dependencies FIX-PLAN prerequisites P1 / P1-V2 (DECISIONS "2026-10-18 approvals", "P1-V2 dependencies"). None of these resolved from the package before. - router-plugin (dev): @rsbuild/plugin-solid, @rsbuild/plugin-babel, @rsbuild/plugin-vue, @rsbuild/plugin-vue-jsx (A1 Rsbuild adapters), react-error-boundary (HMR-19), @emotion/react (split F13) - router-generator (dev): @tanstack/vue-router (workspace:*), vue (catalog), @vitejs/plugin-vue (A2a; vue/compiler-sfc ships with vue) - start-plugin-core (dev): @mdx-js/rollup, @mdx-js/loader (ip S22/S23), vite-tsconfig-paths (V2-04) - compiler-test-utils: @jridgewell/trace-mapping (A12), in "dependencies" like the package's other helper deps Versions reuse the workspace's existing specifiers where present. No Nx project-graph cycle; one vite copy still serves ctu/spc/rp/rg. Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/package.json | 1 + packages/router-generator/package.json | 3 + packages/router-plugin/package.json | 6 + packages/start-plugin-core/package.json | 3 + pnpm-lock.yaml | 1226 ++++++++++++++++++++- 5 files changed, 1220 insertions(+), 19 deletions(-) diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index 3f07a1f5baf..bdee7f091fb 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -25,6 +25,7 @@ "node": "^22.18.0 || >=24.0.0" }, "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", "@playwright/test": "catalog:", "@rsbuild/core": "catalog:", "esbuild": "^0.28.2", diff --git a/packages/router-generator/package.json b/packages/router-generator/package.json index 6c91b9b59ca..bfc731a7e4e 100644 --- a/packages/router-generator/package.json +++ b/packages/router-generator/package.json @@ -76,10 +76,13 @@ }, "devDependencies": { "@tanstack/react-router": "workspace:*", + "@tanstack/vue-router": "workspace:*", "@types/node": ">=20", "@types/react": "catalog:", + "@vitejs/plugin-vue": "^6.0.5", "react": "catalog:", "vite": "catalog:", + "vue": "catalog:", "@tanstack/vite-config": "catalog:", "vitest": "catalog:", "@arethetypeswrong/cli": "catalog:", diff --git a/packages/router-plugin/package.json b/packages/router-plugin/package.json index ab305bc3f56..c31beba058a 100644 --- a/packages/router-plugin/package.json +++ b/packages/router-plugin/package.json @@ -131,7 +131,12 @@ }, "devDependencies": { "@rsbuild/core": "catalog:", + "@rsbuild/plugin-babel": "^1.1.2", "@rsbuild/plugin-react": "^2.0.0", + "@rsbuild/plugin-solid": "^1.1.1", + "@rsbuild/plugin-vue": "^1.2.7", + "@rsbuild/plugin-vue-jsx": "^2.0.0", + "@emotion/react": "^11.14.0", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/node": ">=20", @@ -143,6 +148,7 @@ "jsdom": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "react-error-boundary": "^6.1.6", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@arethetypeswrong/cli": "catalog:", diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index a61894bbf15..6e85012bfb5 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -136,6 +136,8 @@ "zod": "catalog:" }, "devDependencies": { + "@mdx-js/loader": "^3.1.1", + "@mdx-js/rollup": "^3.1.1", "@rsbuild/core": "catalog:", "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.20.5", @@ -148,6 +150,7 @@ "@vitejs/plugin-react": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "vite-tsconfig-paths": "^6.1.1", "vitest": "catalog:", "@arethetypeswrong/cli": "catalog:", "publint": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e3b610140c..3df6320b2ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13926,6 +13926,9 @@ importers: packages/compiler-test-utils: dependencies: + '@jridgewell/trace-mapping': + specifier: ^0.3.31 + version: 0.3.31 '@playwright/test': specifier: ^1.63.0 version: 1.63.0 @@ -14536,7 +14539,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14732,13 +14735,19 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + '@tanstack/vue-router': + specifier: workspace:* + version: link:../vue-router '@types/node': specifier: 25.0.9 version: 25.0.9 '@types/react': specifier: ^19.2.8 version: 19.2.9 + '@vitejs/plugin-vue': + specifier: ^6.0.5 + version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) publint: specifier: 'catalog:' version: 0.3.17 @@ -14754,6 +14763,9 @@ importers: vitest: specifier: 'catalog:' version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vue: + specifier: 'catalog:' + version: 3.5.25(@typescript/typescript6@6.0.2) packages/router-plugin: dependencies: @@ -14797,15 +14809,30 @@ importers: '@babel/preset-typescript': specifier: ^7.28.5 version: 7.28.5(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.9)(react@19.2.3)(supports-color@10.2.2) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7(supports-color@10.2.2))(rolldown@1.2.13)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@rsbuild/core': specifier: 'catalog:' version: 2.2.5 + '@rsbuild/plugin-babel': + specifier: ^1.1.2 + version: 1.1.2(@rsbuild/core@2.2.5)(supports-color@10.2.2) '@rsbuild/plugin-react': specifier: ^2.0.0 version: 2.0.0(@rsbuild/core@2.2.5)(@rspack/core@2.2.7(@swc/helpers@0.5.23)) + '@rsbuild/plugin-solid': + specifier: ^1.1.1 + version: 1.1.1(@babel/core@7.29.7(supports-color@10.2.2))(@rsbuild/core@2.2.5)(solid-js@1.9.12)(supports-color@10.2.2) + '@rsbuild/plugin-vue': + specifier: ^1.2.7 + version: 1.2.7(@rsbuild/core@2.2.5)(@rspack/core@2.2.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.25)(vue@3.5.25(@typescript/typescript6@6.0.2)) + '@rsbuild/plugin-vue-jsx': + specifier: ^2.0.0 + version: 2.0.0(@babel/core@7.29.7(supports-color@10.2.2))(@rsbuild/core@2.2.5)(supports-color@10.2.2) '@tanstack/compiler-test-utils': specifier: workspace:* version: link:../compiler-test-utils @@ -14860,6 +14887,9 @@ importers: react-dom: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) + react-error-boundary: + specifier: ^6.1.6 + version: 6.1.6(@types/react@19.2.9)(react@19.2.3) rimraf: specifier: 'catalog:' version: 6.1.2 @@ -14969,7 +14999,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15061,7 +15091,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15079,7 +15109,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: 'catalog:' - version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) @@ -15330,7 +15360,7 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15413,6 +15443,12 @@ importers: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.4 + '@mdx-js/loader': + specifier: ^3.1.1 + version: 3.1.1(supports-color@10.2.2)(webpack@5.110.3) + '@mdx-js/rollup': + specifier: ^3.1.1 + version: 3.1.1(rollup@4.63.1)(supports-color@10.2.2) '@rsbuild/core': specifier: 'catalog:' version: 2.2.5 @@ -15424,7 +15460,7 @@ importers: version: link:../react-router '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/babel__code-frame': specifier: ^7.0.6 version: 7.0.6 @@ -15455,6 +15491,9 @@ importers: vite: specifier: ^8.3.4 version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite-tsconfig-paths: + specifier: ^6.1.1 + version: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' version: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@25.0.9)(@vitest/ui@5.0.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.15.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) @@ -15741,13 +15780,13 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@types/node': specifier: 25.0.9 version: 25.0.9 '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) publint: specifier: 'catalog:' version: 0.3.17 @@ -15778,13 +15817,13 @@ importers: version: 0.18.4 '@tanstack/vite-config': specifier: 'catalog:' - version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) '@tanstack/vue-router': specifier: workspace:* version: link:../vue-router '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 4.2.0(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 4.2.0(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) publint: specifier: 'catalog:' version: 0.3.17 @@ -18930,6 +18969,22 @@ packages: engines: {node: '>=18'} hasBin: true + '@mdx-js/loader@3.1.1': + resolution: {integrity: sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==} + peerDependencies: + webpack: '>=5' + peerDependenciesMeta: + webpack: + optional: true + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@mdx-js/rollup@3.1.1': + resolution: {integrity: sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw==} + peerDependencies: + rollup: '>=2' + '@microsoft/api-extractor-model@7.29.6': resolution: {integrity: sha512-gC0KGtrZvxzf/Rt9oMYD2dHvtN/1KPEYsrQPyMKhLHnlVuO/f4AFN3E4toqZzD2pt4LhkKoYmL2H9tX3yCOyRw==} @@ -22058,6 +22113,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -22067,6 +22125,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -22088,6 +22149,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/html-minifier-terser@6.1.0': resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} @@ -22106,9 +22170,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} @@ -22182,6 +22255,12 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.5.14': resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} @@ -22508,6 +22587,9 @@ packages: resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} hasBin: true + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -23159,6 +23241,10 @@ packages: resolution: {integrity: sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==} engines: {node: '>=18'} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -23233,6 +23319,9 @@ packages: solid-js: optional: true + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -23509,6 +23598,9 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} @@ -23536,6 +23628,18 @@ packages: char-spinner@1.0.1: resolution: {integrity: sha512-acv43vqJ0+N0rD+Uw3pDHSxP30FHrywu2NO6/wBaHChJIizpDeBUd6NjqhNhy9LGaEAhZAXn46QzmlAvIWd16g==} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -23639,6 +23743,9 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -23679,6 +23786,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -24046,6 +24156,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -24210,6 +24323,9 @@ packages: dettle@1.0.5: resolution: {integrity: sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.2.1: resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} @@ -24450,6 +24566,12 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild-plugin-solid@0.6.0: resolution: {integrity: sha512-V1FvDALwLDX6K0XNYM9CMRAnMzA0+Ecu55qBUT9q/eAJh1KIDsTMFoOzMSgyHqbOfvrVfO3Mws3z7TW2GVnIZA==} peerDependencies: @@ -24734,6 +24856,24 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.1: + resolution: {integrity: sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -24781,6 +24921,9 @@ packages: exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -25200,6 +25343,15 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hdr-histogram-js@3.0.1: resolution: {integrity: sha512-l3GSdZL1Jr1C0kyb461tUjEdrRPZr8Qry7jByltf5JGrA0xvqOSrxRBfcrJqqV/AMEtqqhHhC6w8HW0gn76tRQ==} engines: {node: '>=14'} @@ -25452,6 +25604,9 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -25486,6 +25641,12 @@ packages: iron-webcrypto@2.0.0: resolution: {integrity: sha512-rtffZKDUHciZElM8mjFCufBC7nVhCxHYyWHESqs89OioEDz4parOofd8/uhrejh/INhQFfYQfByS22LlezR9sQ==} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -25521,6 +25682,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -25538,6 +25702,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-html@2.0.0: resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==} engines: {node: '>=8'} @@ -26153,6 +26320,9 @@ packages: long@5.3.1: resolution: {integrity: sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -26207,6 +26377,10 @@ packages: resolution: {integrity: sha512-K6K2NgKnTXimT3779/4KxSvobxOtMmx1LBZ3NwRxT/MDIR3Br/fQ4Q+WCX5QxjyUR8zg5+RV9Tbf2c5pAWTD2A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + markdown-link-extractor@4.0.3: resolution: {integrity: sha512-aEltJiQ4/oC0h6Jbw/uuATGSHZPkcH8DIunNH1A0e+GSFkvZ6BbBkdvBTVfIV8r6HapCU3yTd0eFdi3ZeM1eAQ==} @@ -26230,6 +26404,33 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -26274,6 +26475,93 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -26856,6 +27144,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-gitignore@2.0.0: resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} engines: {node: '>=14'} @@ -27206,6 +27497,9 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -27312,6 +27606,15 @@ packages: peerDependencies: react: ^19.2.3 + react-error-boundary@6.1.6: + resolution: {integrity: sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A==} + peerDependencies: + '@types/react': ^19.2.8 + react: ^19.2.3 + peerDependenciesMeta: + '@types/react': + optional: true + react-hot-toast@2.5.1: resolution: {integrity: sha512-54Gq1ZD1JbmAb4psp9bvFHjS7lje+8ubboUmvKZkCsQBLH6AOpZ9JemfRvIdHcfb9AZXRaFLrb3qUobGYDJhFQ==} engines: {node: '>=10'} @@ -27445,6 +27748,20 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redaxios@0.5.1: resolution: {integrity: sha512-FSD2AmfdbkYwl7KDExYQlVvIrFz6Yd83pGfaGjBzM9F6rpq8g652Q4Yq5QD4c+nf4g2AgeElv1y+8ajUPiOYMg==} @@ -27479,6 +27796,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + reinterval@1.1.0: resolution: {integrity: sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==} @@ -27486,6 +27806,15 @@ packages: resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} engines: {node: '>= 0.10'} + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remeda@2.21.3: resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==} @@ -28052,6 +28381,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawn-command@0.0.2: resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} @@ -28179,6 +28511,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -28218,6 +28553,12 @@ packages: peerDependencies: webpack: ^5.27.0 + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + style-to-object@1.0.8: resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} @@ -28494,10 +28835,16 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -28690,6 +29037,27 @@ packages: resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} engines: {node: '>=20'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -28926,6 +29294,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-dts@4.2.3: resolution: {integrity: sha512-O5NalzHANQRwVw1xj8KQun3Bv8OSDAlNJXrnqoAz10BOuW8FVvY5g4ygj+DlJZL5mtSPuMu9vd3OfrdW5d4k6w==} engines: {node: ^14.18.0 || >=16.0.0} @@ -29534,6 +29908,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.4.1': {} @@ -32340,7 +32717,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -32362,7 +32739,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping@0.3.9': dependencies: @@ -32544,6 +32921,55 @@ snapshots: - encoding - supports-color + '@mdx-js/loader@3.1.1(supports-color@10.2.2)(webpack@5.110.3)': + dependencies: + '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) + source-map: 0.7.6 + optionalDependencies: + webpack: 5.110.3(@swc/core@1.15.33(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.29)(sharp@0.35.4(@types/node@25.0.9))(svgo@4.0.0)(webpack-cli@5.1.4) + transitivePeerDependencies: + - supports-color + + '@mdx-js/mdx@3.1.1(supports-color@10.2.2)': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdx': 2.0.14 + acorn: 8.18.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.1 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6(supports-color@10.2.2) + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.18.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0(supports-color@10.2.2) + remark-mdx: 3.1.1(supports-color@10.2.2) + remark-parse: 11.0.0(supports-color@10.2.2) + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@mdx-js/rollup@3.1.1(rollup@4.63.1)(supports-color@10.2.2)': + dependencies: + '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) + '@rollup/pluginutils': 5.1.4(rollup@4.63.1) + rollup: 4.63.1 + source-map: 0.7.6 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + '@microsoft/api-extractor-model@7.29.6(@types/node@25.0.9)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -34594,6 +35020,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@rsbuild/plugin-babel@1.1.2(@rsbuild/core@2.2.5)(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@types/babel__core': 7.20.5 + reduce-configs: 1.1.1 + optionalDependencies: + '@rsbuild/core': 2.2.5 + transitivePeerDependencies: + - supports-color + '@rsbuild/plugin-react@2.0.0(@rsbuild/core@2.0.14)(@rspack/core@2.2.7(@swc/helpers@0.5.23))': dependencies: '@rspack/plugin-react-refresh': 2.0.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(react-refresh@0.18.0) @@ -34643,6 +35082,18 @@ snapshots: - solid-js - supports-color + '@rsbuild/plugin-solid@1.1.1(@babel/core@7.29.7(supports-color@10.2.2))(@rsbuild/core@2.2.5)(solid-js@1.9.12)(supports-color@10.2.2)': + dependencies: + '@rsbuild/plugin-babel': 1.1.2(@rsbuild/core@2.2.5)(supports-color@10.2.2) + babel-preset-solid: 1.9.10(@babel/core@7.29.7(supports-color@10.2.2))(solid-js@1.9.12) + solid-refresh: 0.7.8(solid-js@1.9.12) + optionalDependencies: + '@rsbuild/core': 2.2.5 + transitivePeerDependencies: + - '@babel/core' + - solid-js + - supports-color + '@rsbuild/plugin-tailwindcss@2.0.3(@rsbuild/core@2.0.14)(@rspack/core@2.2.7(@swc/helpers@0.5.23))(webpack@5.110.3)': dependencies: '@tailwindcss/webpack': 4.3.1(@rspack/core@2.2.7(@swc/helpers@0.5.23))(webpack@5.110.3) @@ -34663,6 +35114,17 @@ snapshots: - '@babel/core' - supports-color + '@rsbuild/plugin-vue-jsx@2.0.0(@babel/core@7.29.7(supports-color@10.2.2))(@rsbuild/core@2.2.5)(supports-color@10.2.2)': + dependencies: + '@rsbuild/plugin-babel': 1.1.2(@rsbuild/core@2.2.5)(supports-color@10.2.2) + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-vue-jsx-hmr: 1.0.0(supports-color@10.2.2) + optionalDependencies: + '@rsbuild/core': 2.2.5 + transitivePeerDependencies: + - '@babel/core' + - supports-color + '@rsbuild/plugin-vue@1.2.7(@rsbuild/core@2.1.1)(@rspack/core@2.2.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.25)(vue@3.5.25(@typescript/typescript6@6.0.2))': dependencies: rspack-vue-loader: 17.5.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.25)(vue@3.5.25(@typescript/typescript6@6.0.2)) @@ -34673,6 +35135,16 @@ snapshots: - '@vue/compiler-sfc' - vue + '@rsbuild/plugin-vue@1.2.7(@rsbuild/core@2.2.5)(@rspack/core@2.2.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.25)(vue@3.5.25(@typescript/typescript6@6.0.2))': + dependencies: + rspack-vue-loader: 17.5.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.25)(vue@3.5.25(@typescript/typescript6@6.0.2)) + optionalDependencies: + '@rsbuild/core': 2.2.5 + transitivePeerDependencies: + - '@rspack/core' + - '@vue/compiler-sfc' + - vue + '@rspack/binding-darwin-arm64@2.0.8': optional: true @@ -35719,6 +36191,18 @@ snapshots: - supports-color - typescript + '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': + dependencies: + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-dts: 4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite-plugin-externalize-deps: 0.10.0(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + vite-tsconfig-paths: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + - typescript + '@tanstack/vite-config@0.5.2(@types/node@25.0.9)(supports-color@10.2.2)(typescript@5.9.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))': dependencies: vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) @@ -35953,6 +36437,10 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -35965,6 +36453,10 @@ snapshots: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.7': {} '@types/estree@1.0.8': {} @@ -35998,6 +36490,10 @@ snapshots: '@types/express-serve-static-core': 5.0.6 '@types/serve-static': 2.2.0 + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/html-minifier-terser@6.1.0': {} '@types/http-errors@2.0.4': {} @@ -36017,8 +36513,16 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node-forge@1.3.14': dependencies: '@types/node': 25.0.9 @@ -36091,6 +36595,10 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/ws@8.5.14': dependencies: '@types/node': 25.0.9 @@ -36686,6 +37194,8 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.2 + '@ungap/structured-clone@1.4.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -36806,6 +37316,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue-jsx@4.2.0(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@rolldown/pluginutils': 1.0.1 + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vue: 3.5.25(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -37097,12 +37618,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/helper-module-imports': 7.27.1(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/template': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + '@vue/babel-helper-vue-transform-on': 1.5.0 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@vue/shared': 3.5.25 + optionalDependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/helper-module-imports': 7.27.1(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/template': 7.29.7 '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 '@vue/babel-helper-vue-transform-on': 2.0.1 @@ -37140,6 +37677,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.8 + '@vue/compiler-sfc': 3.5.25 + transitivePeerDependencies: + - supports-color + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 @@ -37481,6 +38029,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn@8.14.1: {} acorn@8.15.0: {} @@ -37659,6 +38211,8 @@ snapshots: ast-module-types@6.0.1: {} + astring@1.9.0: {} + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -37821,6 +38375,8 @@ snapshots: optionalDependencies: solid-js: 1.9.12 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -38086,6 +38642,8 @@ snapshots: caniuse-lite@1.0.30001760: {} + ccount@2.0.1: {} + chai@6.3.0: {} chalk@3.0.0: @@ -38107,6 +38665,14 @@ snapshots: char-spinner@1.0.1: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} cheerio-select@2.1.0: @@ -38233,6 +38799,8 @@ snapshots: cluster-key-slot@1.1.1: optional: true + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -38266,6 +38834,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} commander@11.1.0: {} @@ -38611,6 +39181,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + dedent@1.5.1(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -38764,6 +39338,10 @@ snapshots: dettle@1.0.5: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dexie@4.2.1: {} diff@9.0.0: {} @@ -38994,6 +39572,20 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.18.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + esbuild-plugin-solid@0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@7.2.0): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -39614,6 +40206,35 @@ snapshots: estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -39721,6 +40342,8 @@ snapshots: exsolve@1.1.1: {} + extend@3.0.2: {} + extendable-error@0.1.7: {} fast-check@3.23.2: @@ -40186,6 +40809,51 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-estree@3.1.3(supports-color@10.2.2): + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.6(supports-color@10.2.2): + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hdr-histogram-js@3.0.1: dependencies: '@assemblyscript/loader': 0.19.23 @@ -40466,6 +41134,8 @@ snapshots: inline-style-parser@0.2.4: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -40543,6 +41213,13 @@ snapshots: dependencies: uint8array-extras: 1.5.0 + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -40580,6 +41257,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -40590,6 +41269,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-html@2.0.0: dependencies: html-tags: 3.3.1 @@ -41172,6 +41853,8 @@ snapshots: long@5.3.1: {} + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -41216,6 +41899,8 @@ snapshots: map-obj@5.0.2: {} + markdown-extensions@2.0.0: {} + markdown-link-extractor@4.0.3: dependencies: html-link-extractor: 1.0.5 @@ -41238,6 +41923,107 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-from-markdown@2.1.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3(supports-color@10.2.2) + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1(supports-color@10.2.2): + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0(supports-color@10.2.2): + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0(supports-color@10.2.2): + dependencies: + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1(supports-color@10.2.2): + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.2.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.0.28: {} mdn-data@2.27.1: {} @@ -41271,6 +42057,218 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3(supports-color@10.2.2): + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@10.2.2) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -42026,6 +43024,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-gitignore@2.0.0: {} parse-imports@2.2.1: @@ -42369,6 +43377,8 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + property-information@7.2.0: {} + proto-list@1.2.4: {} protobufjs@7.4.0: @@ -42539,6 +43549,12 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-error-boundary@6.1.6(@types/react@19.2.9)(react@19.2.3): + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.9 + react-hot-toast@2.5.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: csstype: 3.1.3 @@ -42691,6 +43707,35 @@ snapshots: dependencies: resolve: 1.22.11 + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + redaxios@0.5.1: {} redent@3.0.0: @@ -42725,10 +43770,42 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-recma@1.0.0(supports-color@10.2.2): + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + reinterval@1.1.0: {} relateurl@0.2.7: {} + remark-mdx@3.1.1(supports-color@10.2.2): + dependencies: + mdast-util-mdx: 3.0.0(supports-color@10.2.2) + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + remeda@2.21.3: dependencies: type-fest: 4.41.0 @@ -42870,7 +43947,6 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.63.1 '@rollup/rollup-win32-x64-msvc': 4.63.1 fsevents: 2.3.3 - optional: true rou3@0.7.12: {} @@ -43383,6 +44459,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + spawn-command@0.0.2: {} spawndamnit@3.0.1: @@ -43513,6 +44591,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -43545,6 +44628,14 @@ snapshots: dependencies: webpack: 5.97.1(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack-cli@5.1.4) + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + style-to-object@1.0.8: dependencies: inline-style-parser: 0.2.4 @@ -43813,8 +44904,12 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + triple-beam@1.4.1: {} + trough@2.2.0: {} + ts-api-utils@2.1.0(@typescript/typescript6@6.0.2): dependencies: typescript: '@typescript/typescript6@6.0.2' @@ -43993,6 +45088,43 @@ snapshots: unicorn-magic@0.4.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} universalify@0.2.0: {} @@ -44156,6 +45288,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) @@ -44213,6 +45355,25 @@ snapshots: - rollup - supports-color + vite-plugin-dts@4.2.3(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) + '@rollup/pluginutils': 5.1.4(rollup@4.63.1) + '@volar/typescript': 2.4.26(@typescript/typescript6@6.0.2) + '@vue/language-core': 2.1.6(@typescript/typescript6@6.0.2) + compare-versions: 6.1.1 + debug: 4.4.3(supports-color@7.2.0) + kolorist: 1.8.0 + local-pkg: 0.5.1 + magic-string: 0.30.21 + typescript: '@typescript/typescript6@6.0.2' + optionalDependencies: + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-dts@4.2.3(@types/node@25.0.9)(supports-color@10.2.2)(typescript@5.9.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.47.7(@types/node@25.0.9) @@ -44255,6 +45416,21 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.10(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.12) + merge-anything: 5.1.7 + solid-js: 1.9.12 + solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@7.2.0) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + optionalDependencies: + '@testing-library/jest-dom': 6.6.3 + transitivePeerDependencies: + - supports-color + vite-plugin-solid@2.11.10(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.4))(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -44335,6 +45511,16 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + globrex: 0.1.2 + tsconfck: 3.1.4(@typescript/typescript6@6.0.2) + vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + - typescript + vite-tsconfig-paths@6.1.1(supports-color@10.2.2)(typescript@5.9.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.105.1)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -45225,3 +46411,5 @@ snapshots: immer: 10.1.1 react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) + + zwitch@2.0.4: {} From 656dd5bf1758883c8d74bb1236785bec53cb7506 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:37:20 +0200 Subject: [PATCH 0420/1425] test(compiler-test-utils): link built workspace packages into test apps `@tanstack/compiler-test-utils/workspace-packages` generalises the `startRuntimeLinks()` of the Start build harness: `workspacePackage(name)` is the root of the built workspace package (packages/*, checked for its built entry; the error names the Nx build target to depend on), `workspacePackageLinks(names)` gives `AppOptions.symlinks` entries for an app's node_modules, and `linkWorkspacePackages(root, names)` creates them for an app made without the harnesses. A test app under a package's tests directory resolves that package's own dependencies (e.g. the routers) without links; the helper is for packages the owner cannot depend on (`@tanstack/react-start` from start-plugin-core). Contract test in tests/integration (links `@tanstack/history`, which the package's `test:integration` target now builds, and imports it through the link like an installed app). Co-Authored-By: Claude Fable 5.1 --- packages/compiler-test-utils/package.json | 16 +++ .../src/workspace-packages.ts | 124 ++++++++++++++++++ .../integration/workspace-packages.test.ts | 49 +++++++ 3 files changed, 189 insertions(+) create mode 100644 packages/compiler-test-utils/src/workspace-packages.ts create mode 100644 packages/compiler-test-utils/tests/integration/workspace-packages.test.ts diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index 3f07a1f5baf..bff1c40a7b6 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -13,6 +13,7 @@ "./rsbuild-dev-server": "./src/rsbuild-dev-server.ts", "./run-node": "./src/run-node.ts", "./validate-module": "./src/validate-module.ts", + "./workspace-packages": "./src/workspace-packages.ts", "./package.json": "./package.json" }, "scripts": { @@ -21,6 +22,21 @@ "test:types": "tsc", "test:eslint": "eslint ./src ./tests" }, + "nx": { + "targets": { + "test:integration": { + "dependsOn": [ + "^build", + { + "projects": [ + "@tanstack/history" + ], + "target": "build" + } + ] + } + } + }, "engines": { "node": "^22.18.0 || >=24.0.0" }, diff --git a/packages/compiler-test-utils/src/workspace-packages.ts b/packages/compiler-test-utils/src/workspace-packages.ts new file mode 100644 index 00000000000..62a9b329115 --- /dev/null +++ b/packages/compiler-test-utils/src/workspace-packages.ts @@ -0,0 +1,124 @@ +/** + * Built workspace packages for the test apps: the real `@tanstack/*` + * packages of this repository (`packages/`), as an installed app gets + * them. A test app under a package's `tests/` directory resolves that + * package's own dependencies through Node resolution (e.g. the routers, + * which are dependencies of the compiler packages); a package the owner + * cannot depend on (e.g. `@tanstack/react-start`, which depends on + * `@tanstack/start-plugin-core`) is linked into the app's `node_modules` + * instead (`workspacePackageLinks`, `linkWorkspacePackages`). + * + * Every function checks that the package is built (its entry exists in + * `dist`): the tests run through Nx, whose targets build the packages the + * project depends on (`^build`); a package outside the project's + * dependencies needs a task-level `dependsOn` on its `build` target. + */ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { mkdir, symlink } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** The repository's `packages/` directory. */ +const packagesDir = fileURLToPath(new URL('../..', import.meta.url)) + +/** Absolute root of each workspace package by name (`packages/*`). */ +let index: Map | undefined + +function workspacePackages(): Map { + if (!index) { + index = new Map() + for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue + } + const root = path.join(packagesDir, entry.name) + const manifest = path.join(root, 'package.json') + if (!existsSync(manifest)) { + continue + } + const { name } = JSON.parse(readFileSync(manifest, 'utf8')) as { + name?: string + } + if (name) { + index.set(name, root) + } + } + } + return index +} + +/** The first file of a package's `exports` (or its `module`/`main`). */ +function entryFile(manifest: Record): string | undefined { + const first = (value: unknown): string | undefined => { + if (typeof value === 'string') { + return value + } + if (value && typeof value === 'object') { + for (const inner of Object.values(value)) { + const found = first(inner) + if (found) { + return found + } + } + } + return undefined + } + return ( + (manifest.module as string | undefined) ?? + (manifest.main as string | undefined) ?? + first(manifest.exports) + ) +} + +/** + * Absolute root of the built workspace package `name` (e.g. + * `@tanstack/react-start` -> `packages/react-start`). Throws when the + * package does not exist or is not built. + */ +export function workspacePackage(name: string): string { + const root = workspacePackages().get(name) + if (!root) { + throw new Error( + `${name} is no workspace package (none of ${packagesDir} has that name)`, + ) + } + const manifest = JSON.parse( + readFileSync(path.join(root, 'package.json'), 'utf8'), + ) as Record + const entry = entryFile(manifest) + if (!entry || !existsSync(path.join(root, entry))) { + throw new Error( + `${name} is not built (no ${entry ?? 'entry'} in ${root}): run the tests through Nx, with a target that depends on ${name}:build`, + ) + } + return root +} + +/** + * Symbolic links (`AppOptions.symlinks` of the build and dev-server + * harnesses) that give an app the built workspace packages `names`: + * `node_modules/` is the workspace package, so the app bundles and + * runs the built package with its own export conditions, and the package's + * dependencies resolve from its real path, as in an installed app. + */ +export function workspacePackageLinks( + names: ReadonlyArray, +): Record { + return Object.fromEntries( + names.map((name) => [`node_modules/${name}`, workspacePackage(name)]), + ) +} + +/** + * Creates the links of `workspacePackageLinks(names)` under `root`, for an + * app made without the harnesses' `createApp`. + */ +export async function linkWorkspacePackages( + root: string, + names: ReadonlyArray, +): Promise { + for (const [link, target] of Object.entries(workspacePackageLinks(names))) { + await mkdir(path.dirname(path.join(root, link)), { recursive: true }) + await symlink(target, path.join(root, link)) + } +} diff --git a/packages/compiler-test-utils/tests/integration/workspace-packages.test.ts b/packages/compiler-test-utils/tests/integration/workspace-packages.test.ts new file mode 100644 index 00000000000..66110b1c4ce --- /dev/null +++ b/packages/compiler-test-utils/tests/integration/workspace-packages.test.ts @@ -0,0 +1,49 @@ +/** + * Control tests of workspace-packages.ts: a test app gets a built workspace + * package through a link in its `node_modules`, and asking for a package + * that is no workspace package fails with its name. + */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { expect, onTestFinished, test } from 'vitest' +import { + linkWorkspacePackages, + workspacePackage, + workspacePackageLinks, +} from '../../src/workspace-packages' + +// `@tanstack/history` is built by this project's `test:integration` target +// (package.json `nx.targets`); nothing in this package depends on it. +const name = '@tanstack/history' + +test('a workspace package is its directory under packages/, with its build', () => { + const root = workspacePackage(name) + expect(path.basename(root)).toBe('history') + expect(workspacePackageLinks([name])).toEqual({ + [`node_modules/${name}`]: root, + }) +}) + +test('a name that is no workspace package is refused by name', () => { + expect(() => workspacePackage('@tanstack/no-such-package')).toThrow( + '@tanstack/no-such-package', + ) +}) + +test('an app resolves a linked workspace package like an installed one', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'workspace-packages-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + await linkWorkspacePackages(root, [name]) + await writeFile( + path.join(root, 'main.mjs'), + `import { createMemoryHistory } from '${name}' +export const location = createMemoryHistory({ initialEntries: ['/linked'] }).location.pathname +`, + ) + const { location } = (await import( + /* @vite-ignore */ pathToFileURL(path.join(root, 'main.mjs')).href + )) as { location: string } + expect(location).toBe('/linked') +}) From ce8553b9ed89b363cd6adbaec332eb2c8be08b76 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:37:50 +0200 Subject: [PATCH 0421/1425] test(start-plugin-core): the real router in the Start build and dev harnesses `routerRuntimeFiles`, the stand-in `@tanstack/react-router` of apps with routes (route creation, child linking, lazy loading, `sideEffects: false`), is gone: the apps resolve the real built `@tanstack/react-router` (a dependency of this package, built by `^build`) like any dependency, as the `realRouter` dev servers already did. `realRouter` is gone with it (always on). `startRuntimeLinks`/`linkStartRuntime` use the compiler-test-utils workspace-packages helper. Scripts that called a split component as a function render through the real router and React instead: every app gets `harness/render.ts` (`renderPath(routeTree, pathname)`: `createRouter`, `router.load()`, `` to HTML; plus React's `createElement`/`renderToString` for a loaded component on its own), which a test entry re-exports. Rows: S10 (build-harness, start-build-options, start-build-route-hmr), S47's runtime observation half (page HTML, not a component call), the source-map stack now comes from React rendering the loaded component. start-build-options: the hand-written router `index.d.ts` is deleted; the Register checks type-check against the real router declarations (verified: the test passes with them, and `Equal` would fail if the import did not resolve). hydrate-build.test.ts (A3b's file): its stand-in router no longer derives from the harness's; the route creation moved into its own stand-in block (W2-h replaces the hydrate stand-ins with the real packages). Co-Authored-By: Claude Fable 5.1 --- .../start-plugin-core/tests/build-harness.ts | 147 ++++++++---------- .../start-plugin-core/tests/dev-server.ts | 27 +--- .../tests/integration/build-harness.test.ts | 23 +-- .../tests/integration/hydrate-build.test.ts | 33 ++-- .../importProtection/build.test.ts | 8 +- .../integration/known-bugs-dev-server.test.ts | 1 - .../known-bugs-route-options.test.ts | 3 +- .../start-build-manifest.test.ts | 7 +- .../start-build-determinism.test.ts | 2 - .../start-build-env-separation.test.ts | 2 - .../integration/start-build-manifest.test.ts | 3 +- .../integration/start-build-options.test.ts | 42 ++--- .../integration/start-build-route-hmr.test.ts | 19 ++- .../start-build-source-maps.test.ts | 10 +- .../start-compiler-dev-server.test.ts | 5 - .../start-router-dev-server.test.ts | 1 - 16 files changed, 144 insertions(+), 189 deletions(-) diff --git a/packages/start-plugin-core/tests/build-harness.ts b/packages/start-plugin-core/tests/build-harness.ts index df2f9afd6b0..8a6300f8455 100644 --- a/packages/start-plugin-core/tests/build-harness.ts +++ b/packages/start-plugin-core/tests/build-harness.ts @@ -11,16 +11,16 @@ * (`@tanstack/compiler-test-utils/build-harness`). The temporary apps live * in this directory. * - * `@tanstack/react-start` is the real package (`startRuntimeLinks`): both - * builds bundle and run Start's runtime. What the test process cannot do is - * stood in for, each checked against the real runtime in - * build-harness.test.ts: SSR scripts run in a request context - * (`inRequestContext`), and a client script observes the requests a server - * fn call sends (`requestsOfPrelude`). An SSR script calls a server fn by id - * through Start's own server entry and client RPC (`callByIdPrelude`). - * `@tanstack/react-router` is a stand-in for apps with routes - * (`routerRuntimeFiles`). The app's entries are the harness's: the client - * and server entries Start builds import `#tanstack-start-entry` + * `@tanstack/react-start` is the real package (`startRuntimeLinks`), and + * `@tanstack/react-router` the real one too (a dependency of this package, + * which the apps resolve like any dependency): both builds bundle and run + * Start's runtime and the router. What the test process cannot do is stood + * in for, each checked against the real runtime in build-harness.test.ts: + * SSR scripts run in a request context (`inRequestContext`), and a client + * script observes the requests a server fn call sends (`requestsOfPrelude`). + * An SSR script calls a server fn by id through Start's own server entry and + * client RPC (`callByIdPrelude`). The app's entries are the harness's: the + * client and server entries Start builds import `#tanstack-start-entry` * (`src/start.ts` when the app has one), `#tanstack-router-entry` * (`src/router.ts`, a stub unless the app has one) and the test's entry * module, whose exports `run()` gives the script as `entry`. Vite builds run @@ -30,10 +30,13 @@ * it; it checks the modules of the source directory (`src`) only. */ import { AsyncLocalStorage } from 'node:async_hooks' -import { existsSync } from 'node:fs' -import { mkdir, symlink } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { + linkWorkspacePackages, + workspacePackage, + workspacePackageLinks, +} from '@tanstack/compiler-test-utils/workspace-packages' import { BuildError, chunksOfBundle, @@ -65,20 +68,20 @@ import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core' import type { InlineConfig, Plugin } from 'vite' /** - * The workspace's `@tanstack/react-start` package. Its build (`dist`) is - * what apps link: the `test:unit` target depends on its `build` target. + * The workspace's built `@tanstack/react-start` package, which apps link + * (`startRuntimeLinks`): the `test:integration` target depends on its + * `build` target, as this package cannot depend on it. */ -export const reactStartPackage = fileURLToPath( - new URL('../../react-start', import.meta.url), -) +export const reactStartPackage = workspacePackage('@tanstack/react-start') /** * Links (`AppOptions.symlinks`) that give an app Start's real runtime: - * `node_modules/@tanstack/react-start` is the workspace package, so the app - * bundles and runs the built package with its own export conditions. Its - * dependencies (`@tanstack/start-client-core`, `@tanstack/start-server-core`, - * ...) resolve from its real path, as in an installed app. An app whose - * `files` bring their own `@tanstack/react-start` (hydrate-build.test.ts: + * `node_modules/@tanstack/react-start` is the workspace package + * (`workspacePackageLinks`), so the app bundles and runs the built package + * with its own export conditions. Its dependencies + * (`@tanstack/start-client-core`, `@tanstack/start-server-core`, ...) + * resolve from its real path, as in an installed app. An app whose `files` + * bring their own `@tanstack/react-start` (hydrate-build.test.ts: * `` rendered by a stand-in React) keeps it. */ export function startRuntimeLinks( @@ -87,20 +90,12 @@ export function startRuntimeLinks( if ('node_modules/@tanstack/react-start/package.json' in files) { return {} } - if (!existsSync(path.join(reactStartPackage, 'dist/esm/index.js'))) { - throw new Error( - `@tanstack/react-start is not built (no ${reactStartPackage}/dist): run the tests through Nx, whose test:unit target builds it first`, - ) - } - return { 'node_modules/@tanstack/react-start': reactStartPackage } + return workspacePackageLinks(['@tanstack/react-start']) } /** Creates the `startRuntimeLinks()` of an app made without `createApp`. */ -export async function linkStartRuntime(root: string) { - for (const [link, target] of Object.entries(startRuntimeLinks())) { - await mkdir(path.dirname(path.join(root, link)), { recursive: true }) - await symlink(target, path.join(root, link)) - } +export function linkStartRuntime(root: string) { + return linkWorkspacePackages(root, ['@tanstack/react-start']) } /** @@ -163,9 +158,7 @@ export function runInRequestContext(fn: () => T): T { /** * Exports of an SSR entry whose script calls server fns by id * (`callByIdPrelude`): Start's default server entry (the HTTP handler of a - * Start app) and Start's client RPC. The server entry renders with the real - * `@tanstack/react-router`: an app with the stand-in (`routerRuntimeFiles`) - * cannot load it. + * Start app) and Start's client RPC. */ export const callByIdExports = `export { default as startServerEntry } from '@tanstack/react-start/server-entry' export { createClientRpc } from '@tanstack/react-start/client-rpc' @@ -218,53 +211,6 @@ export async function requestsOf( export const requestsOfPrelude = `const requestsOf = ${requestsOf.toString()} ` -const routerPackage = 'node_modules/@tanstack/react-router' - -/** - * A stand-in `@tanstack/react-router` for apps with routes: a route is its - * options (`Route.options`), split options load their chunk when called, - * and the generated route tree links routes as children. - */ -export const routerRuntimeFiles: Record = { - [`${routerPackage}/package.json`]: JSON.stringify({ - name: '@tanstack/react-router', - type: 'module', - sideEffects: false, - exports: { '.': './index.js' }, - }), - [`${routerPackage}/index.js`]: `function createRouteObject(options) { - const route = { - options, - children: undefined, - update(extra) { - Object.assign(route.options, extra) - return route - }, - _addFileChildren(children) { - route.children = children - return route - }, - _addFileTypes: () => route, - } - return route -} -export const createRootRoute = (options = {}) => createRouteObject(options) -export const createFileRoute = () => (options = {}) => createRouteObject(options) -export const lazyFn = (load, name) => async (...args) => (await load())[name](...args) -export const lazyRouteComponent = (load, name = 'default') => { - let loaded - const Lazy = (props) => { - if (!loaded) throw new Error('lazy component rendered before it loaded') - return loaded(props) - } - Lazy.preload = async () => { - loaded = (await load())[name] - } - return Lazy -} -`, -} - export type StartBundler = 'vite' | 'rsbuild' /** Every bundler Start has a plugin for. */ @@ -355,11 +301,46 @@ globalThis.__tssBuildHarnessApp = { start, router } globalThis.${entryGlobal} = entry ` +/** + * `harness/render.ts` of every app: `renderPath(routeTree, pathname)` + * renders the app's route tree at `pathname` through the real router and + * React (`createRouter` over the tree, `router.load()`, which runs the + * loaders and loads the split route options, then `` to + * HTML), and React's `createElement` and `renderToString` render a + * component on its own (e.g. a loaded split component, whose render error + * `renderToString` throws). A test's entry re-exports what its scripts use + * (`export { renderPath } from '../harness/render'`: `entry.renderPath(...)`). + */ +const renderFile = `import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { + RouterProvider, + createMemoryHistory, + createRouter, +} from '@tanstack/react-router' +import type { AnyRoute } from '@tanstack/react-router' + +export { createElement, renderToString } + +export async function renderPath( + routeTree: AnyRoute, + pathname: string, +): Promise { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [pathname] }), + }) + await router.load() + return renderToString(createElement(RouterProvider, { router })) +} +` + /** The files the harness adds to every app, unless the app has them. */ const harnessFiles = (entries: { client: string; ssr: string }) => ({ 'harness/client-entry.ts': harnessEntry(entries.client), 'harness/server-entry.ts': harnessEntry(entries.ssr), 'harness/start.ts': `export const startInstance = undefined\n`, + 'harness/render.ts': renderFile, 'src/router.ts': `export const getRouter = () => undefined\n`, // The router generator needs a routes directory (nothing imports the // route tree unless the app's router does). diff --git a/packages/start-plugin-core/tests/dev-server.ts b/packages/start-plugin-core/tests/dev-server.ts index c604a727ddc..d7597126a6e 100644 --- a/packages/start-plugin-core/tests/dev-server.ts +++ b/packages/start-plugin-core/tests/dev-server.ts @@ -3,7 +3,8 @@ * compiler's Vite plugin (client and SSR environments, SSR as the server fn * provider) on a temporary app, through the shared dev-server harness * (`@tanstack/compiler-test-utils/dev-server`). `@tanstack/react-start` is the - * real package (`startRuntimeLinks`). The temporary apps live in this + * real package (`startRuntimeLinks`), and `@tanstack/react-router` the real + * one too (a dependency of this package). The temporary apps live in this * directory. * * With `start`, the dev server mounts Start's whole Vite plugin instead @@ -18,11 +19,7 @@ import { onTestFinished } from 'vitest' import { startCompilerPlugin } from '../src/vite/start-compiler-plugin/plugin' import { tanStackStartVite } from '../src/vite/plugin' import { ENTRY_POINTS } from '../src/constants' -import { - reactStartPackage, - routerRuntimeFiles, - startRuntimeLinks, -} from './build-harness' +import { reactStartPackage, startRuntimeLinks } from './build-harness' import { createClientRpc, startClientCore } from './start-runtime' import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' import type { StartCompilerPluginOptions } from '../src/vite/start-compiler-plugin/plugin' @@ -76,10 +73,9 @@ export interface StartDevServerOptions extends Omit< * 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`) unless `realRouter`; 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. + * as the provider). 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?: TanStackStartViteInputConfig /** @@ -88,18 +84,11 @@ export interface StartDevServerOptions extends Omit< * transform). */ compilerTransforms?: Array - /** - * With `start`: the app imports the real `@tanstack/react-router` (a - * devDependency of this package) instead of the build harness's stand-in, - * e.g. to run in a browser page (`browser`). Default `false`. - */ - realRouter?: boolean } /** * The server entry of an app with `start` whose server handles HTTP - * requests: Start's default one (`@tanstack/react-start/server-entry`), - * which renders with the real `@tanstack/react-router` (`realRouter`). + * requests: Start's default one (`@tanstack/react-start/server-entry`). */ export const startServerEntryFiles = { 'harness/server-entry.ts': `export { default } from '@tanstack/react-start/server-entry'\n`, @@ -187,7 +176,6 @@ export function startDevServer( providerEnvName = 'ssr', start, compilerTransforms, - realRouter = false, ...devServerOptions } = options /** Server environments Vite does not create by default. */ @@ -200,7 +188,6 @@ export function startDevServer( registerCleanup: onTestFinished, ...devServerOptions, files: { - ...(start && !realRouter && routerRuntimeFiles), ...(start && startPluginFiles), ...files, }, diff --git a/packages/start-plugin-core/tests/integration/build-harness.test.ts b/packages/start-plugin-core/tests/integration/build-harness.test.ts index f069563a418..056171b85a1 100644 --- a/packages/start-plugin-core/tests/integration/build-harness.test.ts +++ b/packages/start-plugin-core/tests/integration/build-harness.test.ts @@ -12,7 +12,6 @@ import { callByIdExports, callByIdPrelude, requestsOfPrelude, - routerRuntimeFiles, startBundlers, } from '../build-harness' @@ -170,7 +169,6 @@ export const inspect = createServerFn() const { client, ssr } = await buildStart({ bundler, files: { - ...routerRuntimeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, @@ -182,17 +180,24 @@ export const Route = createFileRoute('/')({ 'src/router.ts': `import { routeTree } from './routeTree.gen' export const getRouter = () => ({ routeTree }) `, - 'src/entry.ts': `export { getRouter } from './router'\n`, + 'src/entry.ts': `export { getRouter } from './router' +export { renderPath } from '../harness/render' +`, }, entry: 'src/entry.ts', }) expect(client.where('MARK_COMPONENT')).toBe('lazy') - const { result } = - await ssr.run(`const routes = Object.values(entry.getRouter().routeTree.children) -const component = routes[0].options.component -await component.preload?.() -return { ids: routes.map((route) => route.options.id), rendered: component() }`) - expect(result).toEqual({ ids: ['/'], rendered: 'MARK_COMPONENT' }) + // The real router over the generated tree renders the page at `/` + // (loading the route's component chunk). + const { result } = await ssr.run(`const { routeTree } = entry.getRouter() +return { + ids: Object.values(routeTree.children).map((route) => route.options.id), + rendered: await entry.renderPath(routeTree, '/'), +}`) + expect(result).toEqual({ + ids: ['/'], + rendered: expect.stringContaining('MARK_COMPONENT'), + }) }, timeout, ) diff --git a/packages/start-plugin-core/tests/integration/hydrate-build.test.ts b/packages/start-plugin-core/tests/integration/hydrate-build.test.ts index 074595ec7b7..a6fa2a01e2b 100644 --- a/packages/start-plugin-core/tests/integration/hydrate-build.test.ts +++ b/packages/start-plugin-core/tests/integration/hydrate-build.test.ts @@ -12,12 +12,7 @@ import { createDevServer, evaluateServed, } from '@tanstack/compiler-test-utils/dev-server' -import { - buildStart, - reactStartPackage, - routerRuntimeFiles, - startBundlers, -} from '../build-harness' +import { buildStart, reactStartPackage, startBundlers } from '../build-harness' import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' import { startDevServer } from '../dev-server' import { startRsbuildDevServer } from '../rsbuild-dev-server' @@ -64,9 +59,29 @@ export const visible = () => 'visible' sideEffects: false, exports: { '.': './index.js' }, }), - 'node_modules/@tanstack/react-router/index.js': `${routerRuntimeFiles[ - 'node_modules/@tanstack/react-router/index.js' - ]!.replace(/export const lazyRouteComponent[^]*$/, '')} + // The route creation of this stand-in router (routes are their options, + // the generated route tree links children) goes with its JSX runtime + // above: the real router renders with React. The build harness itself + // links the real `@tanstack/react-router`. + 'node_modules/@tanstack/react-router/index.js': `function createRouteObject(options) { + const route = { + options, + children: undefined, + update(extra) { + Object.assign(route.options, extra) + return route + }, + _addFileChildren(children) { + route.children = children + return route + }, + _addFileTypes: () => route, + } + return route +} +export const createRootRoute = (options = {}) => createRouteObject(options) +export const createFileRoute = () => (options = {}) => createRouteObject(options) +export const lazyFn = (load, name) => async (...args) => (await load())[name](...args) export function lazyRouteComponent(load, name = 'default') { let loaded const Lazy = (props) => { diff --git a/packages/start-plugin-core/tests/integration/importProtection/build.test.ts b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts index de2d0da4229..43c1fcba5eb 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/build.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts @@ -4,11 +4,7 @@ * Import protection checks the modules of the source directory (`src`). */ import { describe, expect, test } from 'vitest' -import { - buildStart, - routerRuntimeFiles, - startBundlers, -} from '../../build-harness' +import { buildStart, startBundlers } from '../../build-harness' import { expectMarkerViolation, rejection, @@ -506,7 +502,6 @@ export const read = () => [String(secret), ok(), fine(), String(s())].join(',') const { client } = await buildStart({ bundler, files: { - ...routerRuntimeFiles, ...deniedModules['a denied file'].files, 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' import { secret } from '../secret.server' @@ -666,7 +661,6 @@ describe.each(startBundlers)('%s build, server-only marker', (bundler) => { buildStart({ bundler, files: { - ...routerRuntimeFiles, ...marked, 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' import { secret } from '../marked' diff --git a/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts index 7adb411bec7..5c5f6894552 100644 --- a/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts @@ -133,7 +133,6 @@ describe('editing a server fn factory (SSR)', () => { async function editFactorySsr(importFrom: string) { const server = await startDevServer({ start: {}, - realRouter: true, files: { ...startServerEntryFiles, 'src/b.ts': `export const base = { handler: (fn: () => unknown) => fn }\n`, diff --git a/packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts index 70c7b0193bd..f2ead7cc5c1 100644 --- a/packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-route-options.test.ts @@ -12,7 +12,7 @@ * pins are in outside-compiler-corpus/prerender-route-options.test.ts.) */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import { buildStart, startBundlers } from '../build-harness' import type { StartBuild } from '../build-harness' const timeout = 60_000 @@ -64,7 +64,6 @@ describe.each(startBundlers)('%s build: server-only routes', (bundler) => { * carries the row's marker into its server handler. */ const files: Record = { - ...routerRuntimeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, diff --git a/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts index 18408834cfa..4fa46dba93f 100644 --- a/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts +++ b/packages/start-plugin-core/tests/integration/outside-compiler-corpus/start-build-manifest.test.ts @@ -9,11 +9,7 @@ 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 { buildStart, startBundlers } from '../../build-harness' import type { StartBuild } from '../../build-harness' const timeout = 60_000 @@ -27,7 +23,6 @@ const handlers = `server: { handlers: { GET: () => new Response('x') } }` * `/api/inner`). */ const files = { - ...routerRuntimeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, diff --git a/packages/start-plugin-core/tests/integration/start-build-determinism.test.ts b/packages/start-plugin-core/tests/integration/start-build-determinism.test.ts index 5e45618eacc..78ff8d5bf49 100644 --- a/packages/start-plugin-core/tests/integration/start-build-determinism.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-determinism.test.ts @@ -10,7 +10,6 @@ import { buildStart, callByIdExports, callByIdPrelude, - routerRuntimeFiles, startBundlers, } from '../build-harness' import type { StartBuild } from '../build-harness' @@ -19,7 +18,6 @@ const timeout = 60_000 /** An app with server fns in two modules, a route and a lazy module. */ const appFiles: Record = { - ...routerRuntimeFiles, 'src/a.ts': `import { createServerFn } from '@tanstack/react-start' export const greet = createServerFn().handler(async () => 'MARK_A') `, diff --git a/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts index 92261acd187..fa71d2f4e05 100644 --- a/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts @@ -26,7 +26,6 @@ import { callByIdExports, callByIdPrelude, requestsOfPrelude, - routerRuntimeFiles, runInRequestContext, startBundlers, startRuntimeLinks, @@ -169,7 +168,6 @@ export const ${name} = (..._args: Array) => '${marker}' * (`src/router.ts`) like Start's entries. */ const files = { - ...routerRuntimeFiles, ...dependency('handlerDep', 'MARK_HANDLER_DEP'), ...dependency('baseHandlerDep', 'MARK_BASE_HANDLER_DEP'), ...dependency('middlewareServer', 'MARK_MIDDLEWARE_SERVER'), diff --git a/packages/start-plugin-core/tests/integration/start-build-manifest.test.ts b/packages/start-plugin-core/tests/integration/start-build-manifest.test.ts index 81b6d4f7671..eb17c6b4060 100644 --- a/packages/start-plugin-core/tests/integration/start-build-manifest.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-manifest.test.ts @@ -5,7 +5,7 @@ * outside-compiler-corpus/start-build-manifest.test.ts.) */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import { buildStart, startBundlers } from '../build-harness' import type { StartBuild } from '../build-harness' const timeout = 60_000 @@ -18,7 +18,6 @@ const handlers = `server: { handlers: { GET: () => new Response('x') } }` * server-only subtree (`/api`, `/api/inner`). */ const files = { - ...routerRuntimeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, diff --git a/packages/start-plugin-core/tests/integration/start-build-options.test.ts b/packages/start-plugin-core/tests/integration/start-build-options.test.ts index f249ec381e1..cd88c41f83f 100644 --- a/packages/start-plugin-core/tests/integration/start-build-options.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-options.test.ts @@ -13,7 +13,6 @@ import { buildStart, callByIdExports, callByIdPrelude, - routerRuntimeFiles, startBundlers, } from '../build-harness' import type { StartBuild } from '../build-harness' @@ -29,25 +28,6 @@ export const greet = createServerFn().handler(async () => 'MARK_HANDLER') ${callByIdExports}`, } -/** - * Type declarations of the stand-in router (`@tanstack/react-start` is the - * real package, with its types). - */ -const typeFiles = { - '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( @@ -153,7 +133,6 @@ describe.each(startBundlers)('%s build', (bundler) => { /** A route whose component and loader each carry a marker. */ const routeFiles = { - ...routerRuntimeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, @@ -166,7 +145,9 @@ export const Route = createFileRoute('/')({ 'src/router.ts': `import { routeTree } from './routeTree.gen' export const getRouter = () => ({ routeTree }) `, - 'src/entry.ts': `export { getRouter } from './router'\n`, + 'src/entry.ts': `export { getRouter } from './router' +export { renderPath } from '../harness/render' +`, } // Start passes the router options through to the code splitter of each @@ -214,14 +195,19 @@ export const getRouter = () => ({ routeTree }) client: expected, ssr: expected, }) - // Wherever they are, the route's loader and component still run. + // Wherever they are, the route's loader and component still run: the + // real router renders the page at `/`. const script = `const route = Object.values(entry.getRouter().routeTree.children).find( (candidate) => candidate.options.id === '/', ) -const { loader, component } = route.options -await component.preload?.() -return { loader: await loader(), component: component() }` - const runs = { loader: 'MARK_LOADER', component: 'MARK_COMPONENT' } +return { + loader: await route.options.loader(), + page: await entry.renderPath(entry.getRouter().routeTree, '/'), +}` + const runs = { + loader: 'MARK_LOADER', + page: expect.stringContaining('MARK_COMPONENT'), + } expect({ client: (await client.run(script)).result, ssr: (await ssr.run(script)).result, @@ -241,8 +227,6 @@ return { loader: await loader(), component: component() }` const { ssr } = await buildStart({ bundler, files: { - ...routerRuntimeFiles, - ...typeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, diff --git a/packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts b/packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts index e1008c82277..b5ff60d6eb6 100644 --- a/packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-route-hmr.test.ts @@ -10,7 +10,7 @@ * the same app has some of them. */ import { describe, expect, test } from 'vitest' -import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import { buildStart, startBundlers } from '../build-harness' import { startDevServer } from '../dev-server' import { startRsbuildStartDevServer } from '../rsbuild-dev-server' @@ -42,7 +42,6 @@ function devOnlyNamesIn(modules: Array) { /** An app with a page route whose loader and component carry markers. */ const files = { - ...routerRuntimeFiles, 'src/routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute() `, @@ -58,7 +57,9 @@ export const Route = createFileRoute('/')({ 'src/router.ts': `import { routeTree } from './routeTree.gen' export const getRouter = () => ({ routeTree }) `, - 'src/entry.ts': `export { getRouter } from './router'\n`, + 'src/entry.ts': `export { getRouter } from './router' +export { renderPath } from '../harness/render' +`, } describe("dev servers with Start's plugin add route HMR code (controls)", () => { @@ -101,10 +102,14 @@ describe.each(startBundlers)('%s production build', (bundler) => { const { result } = await client.run(`const route = Object.values( entry.getRouter().routeTree.children, ).find((candidate) => candidate.options.id === '/') -const { loader, component } = route.options -await component.preload?.() -return { loader: await loader(), page: component() }`) - expect(result).toEqual({ loader: 'MARK_LOADER', page: 'MARK_PAGE' }) +return { + loader: await route.options.loader(), + page: await entry.renderPath(entry.getRouter().routeTree, '/'), +}`) + expect(result).toEqual({ + loader: 'MARK_LOADER', + page: expect.stringContaining('MARK_PAGE'), + }) }, timeout, ) diff --git a/packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts b/packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts index 50f66962691..c79b909dc57 100644 --- a/packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-source-maps.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, test } from 'vitest' import { originalFrameOf } from '@tanstack/compiler-test-utils/build-harness' -import { buildStart, routerRuntimeFiles, startBundlers } from '../build-harness' +import { buildStart, startBundlers } from '../build-harness' import type { StartBundler } from '../build-harness' import type { InlineConfig } from 'vite' @@ -145,22 +145,24 @@ async function expectRouteThrowMapsToRouteFile( viteConfig, sourcemap: true, files: { - ...routerRuntimeFiles, 'src/routes/index.tsx': route, 'src/prefix.ts': `export const prefix = 'MARK'\n`, 'src/router.ts': `import { routeTree } from './routeTree.gen' export const getRouter = () => ({ routeTree }) `, - 'entry.ts': `export { getRouter } from './src/router'\n`, + 'entry.ts': `export { getRouter } from './src/router' +export { createElement, renderToString } from './harness/render' +`, }, }) expect(client.where('_PAGE_THROW')).toBe('lazy') + // React renders the loaded split component; its throw is the render's. const { result: stack } = await client.run(`const route = Object.values( entry.getRouter().routeTree.children, ).find((candidate) => candidate.options.id === '/') await route.options.component.preload() try { - route.options.component({}) + entry.renderToString(entry.createElement(route.options.component)) } catch (error) { return error.stack }`) diff --git a/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts b/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts index 5c36d36b505..53274046c96 100644 --- a/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts @@ -77,7 +77,6 @@ describe('Vite dev: server fns by id', () => { async () => { const server = await startDevServer({ start: {}, - realRouter: true, files: { ...startServerEntryFiles, 'src/first.ts': `import { createServerFn } from '@tanstack/react-start' @@ -198,7 +197,6 @@ describe('Vite dev: editing a server fn handler', () => { async () => { const server = await startDevServer({ start: {}, - realRouter: true, files: { ...startServerEntryFiles, 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start' @@ -237,7 +235,6 @@ export const greet = createServerFn().handler(() => 'after') async () => { const server = await startDevServer({ start: {}, - realRouter: true, files: { ...startServerEntryFiles, 'src/fns.ts': `import { createServerFn } from '@tanstack/react-start' @@ -387,7 +384,6 @@ export const label = () => 'MARK_CLIENT_LABEL' async () => { const server = await startDevServer({ start: {}, - realRouter: true, plugins: [react()], bundledDev: { entry: 'src/entry.ts' }, files: { @@ -439,7 +435,6 @@ export const label = () => 'MARK_CLIENT_LABEL' async () => { const server = await startDevServer({ start: {}, - realRouter: true, plugins: [react()], bundledDev: { entry: 'src/entry.ts' }, files: { diff --git a/packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts b/packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts index 8f027ecdf5a..a8e3254ad15 100644 --- a/packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/start-router-dev-server.test.ts @@ -174,7 +174,6 @@ export const Route = createFileRoute('/')({ loader: () => '${loader}', component ` const server = await startDevServer({ start: { vite: { installDevServerMiddleware: false } }, - realRouter: true, browser: true, // Pre-bundled up front, as Vite does for an app that installed them // (the router is a linked workspace package here) From b8d7cbc3618ed664dc14d96878ed98c78e41bb74 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:37:50 +0200 Subject: [PATCH 0422/1425] test(router-plugin): the real router packages in the Vite dev-server harness `routerPackageStub` (a fake `@tanstack/{react,solid,vue}-router` with an invented `.load()`) and `loadRouteOption` are gone (split F03): the apps resolve the real built router packages, dependencies of this package. New helpers of tests/dev-server.ts: - `renderRoutePath(server, pathname)`: the real router over the generated tree, `router.load()`, `` to HTML (SSR environment). - `renderRouteComponent(component)`: React renders a component option (after the real `preload()` when split). - `servedRouterPackageUrl(server, target)`: the URL the client environment serves the router package at (Vite's own resolver), the key of its stub for `evaluateServed`. - `importRouteModule` returns `{ server, routeModule }`. `expectExportedRouteOptions` asserts outcomes: the export exists and is a component; a split option is the real router's lazy component of it (`preload`) rendering the same HTML; the page renders through the real router. The pins that use it (known-bugs-dev-server: class and aliased exports lost, shared overloads) fail without `.fails` on the missing export (checked). `expectSharedOverloads` calls the real options. Consumers (minimal edits in other owners' files): plugin-options.test.ts (PO), router-plugin-context.test.ts (CTX), route-hmr.test.ts (W2-g: Solid cases get `vite-plugin-solid` with `ssr: true`, which a Solid app's SSR environment needs for the router's Solid build; the client evaluation stubs the router with the SSR-loaded real package), dev-server-new-routes (W2-f) and vite-hot-update.ts (W2-g deletes it): the router is served from `/@fs/`, so the page-load skip patterns include it. Co-Authored-By: Claude Fable 5.1 --- .../tests/dev-server-new-routes.test.ts | 8 +- packages/router-plugin/tests/dev-server.ts | 143 +++++++++++------- .../route-hmr-debug-output.test.ts | 4 +- .../tests/plugin-options.test.ts | 22 +-- .../router-plugin/tests/route-hmr.test.ts | 29 ++-- .../tests/router-plugin-context.test.ts | 16 +- .../router-plugin/tests/vite-hot-update.ts | 10 +- 7 files changed, 129 insertions(+), 103 deletions(-) diff --git a/packages/router-plugin/tests/dev-server-new-routes.test.ts b/packages/router-plugin/tests/dev-server-new-routes.test.ts index dd99bb5f79c..4757658ab79 100644 --- a/packages/router-plugin/tests/dev-server-new-routes.test.ts +++ b/packages/router-plugin/tests/dev-server-new-routes.test.ts @@ -7,7 +7,7 @@ import react from '@vitejs/plugin-react' import { afterEach, describe, expect, test, vi } from 'vitest' import { describeHmr } from '@tanstack/compiler-test-utils/dev-server' -import { routerDevServer, routerPackageStub } from './dev-server' +import { routerDevServer } from './dev-server' import type { DevServer } from '@tanstack/compiler-test-utils/dev-server' const timeout = 30_000 @@ -132,14 +132,14 @@ describe('created routes in Vite dev', () => { 'an edit of a created route a page loaded is a hot update', async () => { const server = await routerDevServer({ - plugins: [routerPackageStub('react'), react()], + plugins: [react()], files: { 'routes/index.tsx': route('/', 'MARK_INDEX'), 'entry.ts': `export { routeTree } from './routeTree.gen'`, }, }) - // The router package stub is a virtual module (`/@id/...`). - const skip = /^\/@id\/|\/node_modules\/|^\/@(vite|react-refresh)\b/ + // The router package is served from outside the app (`/@fs/...`). + const skip = /^\/@fs\/|\/node_modules\/|^\/@(vite|react-refresh)\b/ await server.load('/entry.ts', { skip }) await server.edit({ 'routes/posts.tsx': route('/posts', 'MARK_POSTS') }) expect(await routeTreePaths(server)).toEqual(['/', '/posts']) diff --git a/packages/router-plugin/tests/dev-server.ts b/packages/router-plugin/tests/dev-server.ts index 79ff4cdcdaf..5d66d0147be 100644 --- a/packages/router-plugin/tests/dev-server.ts +++ b/packages/router-plugin/tests/dev-server.ts @@ -4,11 +4,13 @@ * `routeTree.gen.ts`, code splitting on) on a temporary app, through the * shared dev-server harness (`@tanstack/compiler-test-utils/dev-server`). The * temporary apps live in this directory, so their imports resolve to this - * package's dependencies. + * package's dependencies: the real router packages, React, Solid and Vue. */ import path from 'node:path' import { fileURLToPath } from 'node:url' import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' import { expect, onTestFinished } from 'vitest' import { tanstackRouter } from '../src/vite' import type { @@ -40,9 +42,8 @@ export interface RouterDevServerOptions extends Omit< */ pluginOptions?: Partial | null /** - * Other Vite plugins, after the router plugin (e.g. a framework plugin or - * a stub of a router package), or a function of the app root returning - * them. + * Other Vite plugins, after the router plugin (e.g. a framework plugin), + * or a function of the app root returning them. */ plugins?: | Array @@ -50,86 +51,113 @@ export interface RouterDevServerOptions extends Omit< } /** - * A Vite plugin that serves a stub of the router package of `target` (the - * real packages need their framework's runtime and a DOM): routes keep - * their options (`{ options }`), and lazy - * route options (`lazyRouteComponent`, `lazyFn`) call what their module - * provides and have `load()`, which resolves to it. + * Starts a dev server on `routes/page.tsx` holding `route` and imports the + * route module in the SSR environment, with the dev server (the route module + * resolves the real router package of the target, a dependency of this + * package). */ -export function routerPackageStub( - target: Config['target'] = 'react', -): PluginOption { - const routerPackage = `@tanstack/${target}-router` - const stubId = `\0${routerPackage}-stub` - return { - name: 'router-package-stub', - enforce: 'pre', - resolveId: (id) => (id === routerPackage ? stubId : undefined), - load: (id) => - id === stubId - ? `const route = (options) => ({ options }) -export const createFileRoute = () => route -export const createRootRoute = route -const lazy = (importer, name) => { - const load = async () => (await importer())[name] - return Object.assign(async (...args) => (await load())(...args), { load }) +export async function importRouteModule( + route: string, + pluginOptions: Partial = {}, +) { + const server = await routerDevServer({ + pluginOptions, + files: { 'routes/page.tsx': route }, + }) + return { server, routeModule: await server.import('/routes/page.tsx') } } -export const lazyRouteComponent = (importer, name = 'default') => lazy(importer, name) -export const lazyFn = (importer, name = 'default') => lazy(importer, name)` - : undefined, - } + +/** + * Renders the app's generated route tree (`routeTree.gen.ts`) at `pathname` + * through the real router and React, in the SSR environment of `server`: + * `createRouter` over the tree, `router.load()` (which runs the loaders and + * loads the split route options), then `` to HTML. React + * targets only. (The router package is the one the served modules use: the + * SSR environment's; React and its server renderer are this process's, as + * the SSR environment loads them from Node too.) + */ +export async function renderRoutePath( + server: Pick, + pathname: string, +): Promise { + const { routeTree } = await server.import('/routeTree.gen.ts') + const { RouterProvider, createMemoryHistory, createRouter } = + await server.import('@tanstack/react-router') + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [pathname] }), + }) + await router.load() + return renderToString(createElement(RouterProvider, { router })) } /** - * The value of a route option of a route module served with - * `routerPackageStub`, once its lazy module (if any) loaded. + * The URL `server` serves the router package of `target` at in the client + * environment (the built workspace package, outside the app root, which + * Vite's resolver picks with its development condition): the key of a stub + * of it for `evaluateServed`, e.g. the same package loaded in the SSR + * environment (`server.import('@tanstack/react-router')`). */ -export function loadRouteOption(value: any): Promise { - return typeof value?.load === 'function' - ? value.load() - : Promise.resolve(value) +export async function servedRouterPackageUrl( + server: Pick, + target: Config['target'] = 'react', +): Promise { + const resolved = + await server.vite.environments.client!.pluginContainer.resolveId( + `@tanstack/${target}-router`, + path.join(server.root, 'routes/__root.tsx'), + ) + if (!resolved) { + throw new Error(`@tanstack/${target}-router does not resolve from the app`) + } + return `/@fs${resolved.id}` } /** - * Starts a dev server (`routerPackageStub` for the router package) on - * `routes/page.tsx` holding `route` and imports the route module in the SSR - * environment. + * Renders a React component option of a served route module (loaded first, + * when the code splitter made it lazy: `preload()` of the real router's lazy + * route component) to HTML with React, like a route render does. */ -export async function importRouteModule( - route: string, - pluginOptions: Partial = {}, -) { - const server = await routerDevServer({ - pluginOptions, - plugins: [routerPackageStub(pluginOptions.target)], - files: { 'routes/page.tsx': route }, - }) - return server.import('/routes/page.tsx') +export async function renderRouteComponent(component: any): Promise { + await component.preload?.() + return renderToString(createElement(component)) } /** * Serves `route` as `routes/page.tsx` (`importRouteModule`, with React HMR, * `autoCodeSplitting` and the component and the loader in their own chunks) - * and asserts that each export named in `exported` is the value of the given - * route options, once loaded. + * and asserts that each export named in `exported` is a component the given + * route options render as: the export is the option's value, or (a split + * option) the real router's lazy component of it, rendering the same HTML + * once loaded; the page at `/page` renders through the real router. */ export async function expectExportedRouteOptions( route: string, exported: Record>, autoCodeSplitting: boolean, ) { - const routeModule = await importRouteModule(route, { + const { server, routeModule } = await importRouteModule(route, { autoCodeSplitting, codeSplittingOptions: { defaultBehavior: [['component'], ['loader']] }, }) for (const [name, keys] of Object.entries(exported)) { expect(routeModule).toHaveProperty(name) + expect(routeModule[name], `${name} is a component`).toBeTypeOf('function') + const rendered = await renderRouteComponent(routeModule[name]) for (const key of keys) { - expect(routeModule[name], `${name} is the ${key} option`).toBe( - await loadRouteOption(routeModule.Route.options[key]), - ) + const option = routeModule.Route.options[key] + if (option !== routeModule[name]) { + expect(option, `${key} is the lazy ${name}`).toHaveProperty('preload') + expect( + await renderRouteComponent(option), + `${key} renders as ${name}`, + ).toBe(rendered) + } } } + expect(await renderRoutePath(server, '/page')).toContain( + await renderRouteComponent(routeModule.Route.options.component), + ) } /** @@ -173,7 +201,7 @@ export class component extends Component { render() { return null } }`, * options keep working. */ export async function expectSharedOverloads(autoCodeSplitting: boolean) { - const routeModule = await importRouteModule( + const { routeModule } = await importRouteModule( `import { createFileRoute } from '@tanstack/react-router' export function helper(value: string): string export function helper(value: number): number @@ -197,7 +225,8 @@ export const Route = createFileRoute('/page')({ /** * Starts a Vite dev server with the router plugin on an app of `files` * (`routes/__root.tsx` defaults to an empty root route of the target's - * router package). + * router package). The app resolves the real router packages + * (`@tanstack/{react,solid,vue}-router`, dependencies of this package). */ export function routerDevServer( options: RouterDevServerOptions, diff --git a/packages/router-plugin/tests/outside-compiler-corpus/route-hmr-debug-output.test.ts b/packages/router-plugin/tests/outside-compiler-corpus/route-hmr-debug-output.test.ts index 5c11b0598ea..fbec4c17826 100644 --- a/packages/router-plugin/tests/outside-compiler-corpus/route-hmr-debug-output.test.ts +++ b/packages/router-plugin/tests/outside-compiler-corpus/route-hmr-debug-output.test.ts @@ -9,7 +9,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' import { expect, onTestFinished, test, vi } from 'vitest' -import { rootRouteOf, routerDevServer, routerPackageStub } from '../dev-server' +import { rootRouteOf, routerDevServer } from '../dev-server' // The temporary apps live in the tests directory, so their imports resolve // to this package's dependencies. @@ -40,7 +40,6 @@ test( const printed = recordConsole() const server = await routerDevServer({ pluginOptions: { target: 'solid', autoCodeSplitting: false }, - plugins: [routerPackageStub('solid')], files: { 'routes/index.tsx': `import { createFileRoute } from '@tanstack/solid-router' export const Route = createFileRoute('/')({ loader: () => 'x', component: () =>

    x

    })`, @@ -84,7 +83,6 @@ export const Route = createFileRoute('/')({ loader: () => 'x' })`, routesDirectory: path.join(app.root, 'routes'), generatedRouteTree: path.join(app.root, 'routeTree.gen.ts'), }), - routerPackageStub('react'), ], }) expect( diff --git a/packages/router-plugin/tests/plugin-options.test.ts b/packages/router-plugin/tests/plugin-options.test.ts index 6c992ce4d9c..ea574411183 100644 --- a/packages/router-plugin/tests/plugin-options.test.ts +++ b/packages/router-plugin/tests/plugin-options.test.ts @@ -11,7 +11,11 @@ import { } from '@tanstack/compiler-test-utils/dev-server' import { describe, expect, onTestFinished, test } from 'vitest' import { routeHead } from './build-harness' -import { loadRouteOption, rootRouteOf, routerPackageStub } from './dev-server' +import { + renderRouteComponent, + rootRouteOf, + servedRouterPackageUrl, +} from './dev-server' import { compileRouteModules, head } from './regression-helpers' import type { BuildResult } from '@tanstack/compiler-test-utils/build-harness' import type { RouterPluginOptions } from './build-harness' @@ -67,31 +71,27 @@ export const Route = createFileRoute('/')({ context, ), ), - routerPackageStub('react'), ] }, }) - /** Loads the route's split component and its loader's value. */ + /** Renders the route's split component, and the loader's value. */ const read = async (routeModule: Record) => { const { options } = routeModule.Route - const component = (await loadRouteOption( - options.component, - )) as () => string - return [component(), options.loader()] + return [await renderRouteComponent(options.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. + // The browser gets the router package from the dev server; 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\//, + skip: /^\/@fs\//, stubs: { - '/@id/__x00__@tanstack/react-router-stub': routerPackage, + [await servedRouterPackageUrl(server)]: routerPackage, }, }), ), diff --git a/packages/router-plugin/tests/route-hmr.test.ts b/packages/router-plugin/tests/route-hmr.test.ts index c54d13027f3..e6617f174a0 100644 --- a/packages/router-plugin/tests/route-hmr.test.ts +++ b/packages/router-plugin/tests/route-hmr.test.ts @@ -13,12 +13,17 @@ import { SourceMap } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' +import solid from 'vite-plugin-solid' import { describe, expect, it, onTestFinished, test, vi } from 'vitest' import { createDevServer, evaluateServed, } from '@tanstack/compiler-test-utils/dev-server' -import { rootRouteOf, routerDevServer, routerPackageStub } from './dev-server' +import { + rootRouteOf, + routerDevServer, + servedRouterPackageUrl, +} from './dev-server' import { compileRouteModules, evaluateModule, @@ -354,7 +359,6 @@ export const Route = createFileRoute('/')({ loader: () => 'x' })` } const server = await routerDevServer({ pluginOptions: { autoCodeSplitting: false }, - plugins: [routerPackageStub('react')], files: { 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/')({ loader: () => 'x', component: () =>

    x

    })`, @@ -374,7 +378,6 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => async () => { const server = await routerDevServer({ pluginOptions: { autoCodeSplitting: false }, - plugins: [routerPackageStub('react')], files: { 'routes/index.ts': loaderRoute() }, }) expect( @@ -392,7 +395,6 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => autoCodeSplitting: false, plugin: { vite: { environmentName: 'client' } }, }, - plugins: [routerPackageStub('react')], files: { 'routes/index.tsx': loaderRoute() }, }) expect( @@ -413,7 +415,6 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => autoCodeSplitting: false, plugin: { hmr: { style: 'vite' } }, }, - plugins: [routerPackageStub('react')], files: { 'routes/index.tsx': loaderRoute() }, }) expect( @@ -434,7 +435,9 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => async ({ target, reactRefreshHooks }) => { const server = await routerDevServer({ pluginOptions: null, - plugins: [routerPackageStub(target)], + // A Solid app's SSR environment needs vite-plugin-solid (the + // router's Solid build) + plugins: target === 'solid' ? [solid({ ssr: true })] : [], files: { 'tsr.config.json': JSON.stringify({ target, @@ -448,13 +451,14 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => const window = {} vi.stubGlobal('window', window) try { - // As the page loads it (router package and JSX runtime stubbed) + // As the page loads it (the router package as the SSR environment + // loaded it, the JSX runtime stubbed) const { Route } = await evaluateServed(server, '/routes/index.tsx', { - skip: /^\/@id\/|\/node_modules\/|^\/@vite\b/, + skip: /^\/@fs\/|\/node_modules\/|^\/@vite\b/, stubs: { - [`/@id/__x00__@tanstack/${target}-router-stub`]: { - createFileRoute: () => (options: unknown) => ({ options }), - }, + [await servedRouterPackageUrl(server, target)]: await server.import( + `@tanstack/${target}-router`, + ), }, fallbackStub: (_, names) => Object.fromEntries([...names].map((name) => [name, () => null])), @@ -493,7 +497,7 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => app.root, ), ), - routerPackageStub('solid'), + solid({ ssr: true }), ], }) expect( @@ -525,7 +529,6 @@ export const Route = createFileRoute('/')({ ` const server = await routerDevServer({ pluginOptions: { target, autoCodeSplitting: false }, - plugins: [routerPackageStub(target)], files: { 'routes/index.tsx': route }, }) const result = diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index e34d6de3789..0f12e848b80 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -5,12 +5,7 @@ import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin' import { createRouterPluginContext } from '../src/core/router-plugin-context' import { normalizePath } from '../src/core/utils' import { tanstackRouter } from '../src/vite' -import { - loadRouteOption, - rootRoute, - routerDevServer, - routerPackageStub, -} from './dev-server' +import { renderRouteComponent, rootRoute, routerDevServer } from './dev-server' import { createRouteLoader, evaluateModule, @@ -102,7 +97,6 @@ export const Route = createFileRoute('${routePath}')({ component: Page }) ` const server = await routerDevServer({ plugins: [ - routerPackageStub(), tanstackRouter({ target: 'react', routesDirectory: './routes-b', @@ -125,9 +119,11 @@ export const Route = createFileRoute('${routePath}')({ component: Page }) [], ) const { Route } = await server.import(url!) - const component = await loadRouteOption(Route.options.component) - expect(component, url).not.toBe(Route.options.component) - expect((component as () => string)(), url).toBe(label) + // Split: the option is the real router's lazy component of it + expect(Route.options.component, url).toHaveProperty('preload') + expect(await renderRouteComponent(Route.options.component), url).toBe( + label, + ) } }, 30_000) diff --git a/packages/router-plugin/tests/vite-hot-update.ts b/packages/router-plugin/tests/vite-hot-update.ts index 7f5776044f0..c84a1449bb9 100644 --- a/packages/router-plugin/tests/vite-hot-update.ts +++ b/packages/router-plugin/tests/vite-hot-update.ts @@ -6,7 +6,7 @@ * boundaries); an update no boundary accepts reloads the page. */ import { describeHmr } from '@tanstack/compiler-test-utils/dev-server' -import { routerDevServer, routerPackageStub } from './dev-server' +import { routerDevServer } from './dev-server' import type { HmrMessage } from '@tanstack/compiler-test-utils/dev-server' import type { PluginOption, Update } from 'vite' import type { TargetFramework } from './regression-helpers' @@ -30,11 +30,11 @@ export type RouteEdit = { files?: Record } -/** Dependencies, Vite's client and the router package stub (`/@id/...`). */ -const notLoaded = /^\/@id\/|\/node_modules\/|^\/@(?:vite|react-refresh)\b/ +/** Dependencies (the router package: `/@fs/...`) and Vite's client. */ +const notLoaded = /^\/@fs\/|\/node_modules\/|^\/@(?:vite|react-refresh)\b/ /** - * Starts a dev server (router package stubbed) on an app whose client entry + * Starts a dev server on an app whose client entry * imports the route tree, loads the entry, what it imports and `loaded` in * the client environment, as a page does, edits the route file and returns * what the client received: the HMR messages (`describeHmr` lines), the @@ -48,7 +48,7 @@ export async function editRouteFile(edit: RouteEdit) { target: edit.target, autoCodeSplitting: edit.autoCodeSplitting, }, - plugins: [routerPackageStub(edit.target), ...(edit.plugins ?? [])], + plugins: edit.plugins ?? [], files: { ...edit.files, [file]: edit.route('page'), From 5dd655924589b8110d47c7303f0d4d3aa5eab6c4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:50:52 +0200 Subject: [PATCH 0423/1425] test(router-generator): check generated route trees through the real router The shared route tree helper evaluated the tree against a stub of the router packages and re-derived route ids and full paths with copies of router-core's cleanPath/trimPathLeft and Route.init (route-tree.ts), and described lazy options by recognising lazyRouteComponent/lazyFn; route-id.ts ran route files against stub constructors and a stub JSX runtime. Both were copies of our own runtime (FIX-PLAN A2a, GEN-01). Now (tests/route-tree.ts): - withRouteTree() loads the tree and the modules it imports with a real Vite server (module runner, packages loaded by Node, @vitejs/plugin-vue for apps with .vue files) and builds the router with the app's own router package (createRouter + createMemoryHistory). routesOf/urlsOf read routesById and fullPath; the file of a route is the evaluated module whose Route export is that route. matchesOf() uses router.matchRoutes. Vue apps render with vue/server-renderer: render() for a route option (after its preload), renderAt(url) for a page (router.load + RouterProvider). - evaluateModule() runs a module in a temporary app with the real packages. - routeOptionsOf/loadLazyRoute/routePathsOf are gone. Route pieces are checked by rendering each component option once the router loaded the route (route-pieces.test.ts, GEN-18/20/32); the pathless-layout check reads what the router matches at / (route-paths.test.ts; red when the layout gets path '/', checked by mutating the generated tree). - route-id.ts: routeCallOf() reads the constructor (bound through the router package import, aliases and namespaces) and the id literal of the Route call with Oxc. The id argument of createFileRoute is types-only at runtime (react/solid/vue fileRoute.ts ignore it), so literal upkeep is checked on the parsed value; evaluation is real (evaluateModule) where it was checked. real-router.test.ts now asserts literal ids/full paths/parents, matching of URLs, Vue pages rendering their single-file components, and that the tree's types (FileRoutesById ids, fullPath, parentRoute id) equal the router's (J1.13). New pin: a route group layout ((marketing)/route.tsx) is typed fullPath '' while the router gives '/' (utils.ts:929-950 inferFullPath). The settings/_layout.lazy.tsx row became settings/_layout.tsx: the lazy-only variant is the G1 bug pinned in route-paths.test.ts. .lazy.vue pin (J1.17, GEN-20): drops the "or the generator warns" branch; /about must render the SFC through the real router. Empty .lazy.vue pin (GEN-32): unchanged empty, or an SFC the Vue plugin compiles and Vue renders. Deleted: - route-tree.test.ts (10 controls of the stub evaluator); loading is now Vite + the router, exercised by real-router.test.ts and every consumer. - route-id.test.ts evaluateRouteFile controls (helper removed); routeCallOf controls replace them. - transform.test.ts row "adds the missing constructor next to a default import": tsc accepts `import Router from '@tanstack/react-router'` only via allowSyntheticDefaultImports (bundler resolution, no error), but Vite rejects it ("does not provide an export named 'default'"), so the input never runs in an app. The aliased and namespace rows still cover the binding. Solid: this package has no @tanstack/solid-router (not an approved devDependency), so Solid route trees are not loaded; the Solid rows keep their upkeep checks and skip the tree load with a comment (generator-options.test.ts, template.test.ts). Every .fails pin of router-generator was re-run without .fails: all 20 red for their stated reasons. Co-Authored-By: Claude Opus 5.5 --- .../tests/generator-options.test.ts | 77 ++- .../outside-compiler-corpus/transform.test.ts | 42 +- .../tests/real-router.test.ts | 408 ++++++++---- .../tests/route-file-upkeep.test.ts | 30 +- .../router-generator/tests/route-id.test.ts | 120 ++-- packages/router-generator/tests/route-id.ts | 207 ++++-- .../tests/route-paths.test.ts | 7 +- .../tests/route-pieces.test.ts | 176 ++--- .../router-generator/tests/route-tree.test.ts | 192 ------ packages/router-generator/tests/route-tree.ts | 630 ++++++++---------- .../router-generator/tests/template.test.ts | 17 +- 11 files changed, 965 insertions(+), 941 deletions(-) delete mode 100644 packages/router-generator/tests/route-tree.test.ts diff --git a/packages/router-generator/tests/generator-options.test.ts b/packages/router-generator/tests/generator-options.test.ts index b2a908a5b97..349dd090b88 100644 --- a/packages/router-generator/tests/generator-options.test.ts +++ b/packages/router-generator/tests/generator-options.test.ts @@ -10,8 +10,8 @@ import path from 'node:path' import { parseSync } from 'vite' import { afterEach, describe, expect, test, vi } from 'vitest' import { Generator, getConfig } from '../src' -import { evaluateRouteFile, routeIdOf } from './route-id' -import { routesOf } from './route-tree' +import { routeCallOf, routeIdOf } from './route-id' +import { evaluateModule, routesOf } from './route-tree' import type { Config } from '../src' let roots: Array = [] @@ -169,10 +169,13 @@ export const Route = createFileRoute('/old')({ const written = await read(root, `src/routes/${file}`) expect(written).toBe(source.replace(/(['"])\/old\1/, '$1/posts$1')) expect(await routeIdOf(written)).toBe('/posts') - expect(await routesOf(generatedRouteTree)).toEqual({ - __root__: 'routes/__root.tsx', - '/posts': `routes/${file}`, - }) + // (this package has no Solid router: Solid route trees do not load) + if (config?.target !== 'solid') { + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/posts': `routes/${file}`, + }) + } }, ) @@ -218,15 +221,17 @@ export const Route = createFileRoute('/posts')({ 'Link', 'createLazyFileRoute', ]) - const { Route } = await evaluateRouteFile(written, ['Link']) - expect([Route.id, Route.createdWith]).toEqual([ - '/posts', - 'createLazyFileRoute', - ]) - expect(await routesOf(generatedRouteTree)).toEqual({ - __root__: 'routes/__root.tsx', - '/posts': 'routes/posts.tsx', + expect(routeCallOf(written)).toEqual({ + constructor: 'createLazyFileRoute', + id: '/posts', }) + // (this package has no Solid router: Solid route trees do not load) + if (target !== 'solid') { + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/posts': 'routes/posts.tsx', + }) + } }, ) @@ -244,9 +249,13 @@ export const Route = createFileRoute('/old')({ await generate(root, { target }) const written = await read(root, 'src/routes/posts.tsx') - expect(valueImports(written, routerPackage)).toContain('createFileRoute') - const { Route } = await evaluateRouteFile(written, ['Link']) - expect([Route.id, Route.options.component]).toEqual(['/posts', 'Link']) + expect(valueImports(written, routerPackage)).toEqual( + expect.arrayContaining(['createFileRoute', 'Link']), + ) + expect(routeCallOf(written)).toEqual({ + constructor: 'createFileRoute', + id: '/posts', + }) expect(withoutImports(written)).toBe( withoutImports(source.replace('/old', '/posts')), ) @@ -667,10 +676,13 @@ export const Route = createLazyFileRoute('/about')({}) .module.staticImports.map((statement) => statement.moduleRequest.value) .filter((specifier) => !specifier.startsWith('.')) expect(new Set(packages)).toEqual(new Set([routerPackage])) - expect(await routesOf(generatedRouteTree)).toEqual({ - __root__: 'routes/__root.tsx', - '/about': null, - }) + // (this package has no Solid router: Solid route trees do not load) + if (target !== 'solid') { + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/about': null, + }) + } }) }) @@ -691,20 +703,15 @@ describe('custom scaffolding', () => { }) await generate(root) - const posts = await evaluateRouteFile( - await read(root, 'src/routes/posts.tsx'), - ) - expect([posts.Route.id, posts.Route.options.staticData]).toEqual([ - '/posts', - { template: 'route' }, - ]) - const about = await evaluateRouteFile( - await read(root, 'src/routes/about.lazy.tsx'), - ) - expect([about.Route.id, about.Route.options.staticData]).toEqual([ - '/about', - { template: 'lazy' }, - ]) + for (const [file, constructor, id, template] of [ + ['posts.tsx', 'createFileRoute', '/posts', 'route'], + ['about.lazy.tsx', 'createLazyFileRoute', '/about', 'lazy'], + ]) { + const code = await read(root, `src/routes/${file}`) + expect(routeCallOf(code)).toEqual({ constructor, id }) + const { exports } = await evaluateModule(code) + expect(exports.Route.options.staticData).toEqual({ template }) + } }) }) diff --git a/packages/router-generator/tests/outside-compiler-corpus/transform.test.ts b/packages/router-generator/tests/outside-compiler-corpus/transform.test.ts index 09c303623ac..4b4f7c25f68 100644 --- a/packages/router-generator/tests/outside-compiler-corpus/transform.test.ts +++ b/packages/router-generator/tests/outside-compiler-corpus/transform.test.ts @@ -7,7 +7,8 @@ import { parseSync } from 'vite' import { describe, expect, it } from 'vitest' import { transform } from '../../src/transform/transform' -import { evaluateRouteFile, routeIdOf } from '../route-id' +import { routeCallOf, routeIdOf } from '../route-id' +import { evaluateModule } from '../route-tree' import type { RouteNode } from '../../src/types' /** @@ -280,8 +281,11 @@ describe('transform', () => { throw new Error(`expected modified result, got ${result.result}`) } expect(result.output).not.toMatch(/(? { }) it.each([ - { - name: 'a default import', - imports: "import Router from '@tanstack/react-router'", - uses: 'Router', - bound: ['default'], - }, { name: 'an aliased import of the constructor', imports: @@ -670,14 +668,12 @@ export const Route = createFileRoute('/old')({})` (name) => name === 'createFileRoute', ), ).toHaveLength(1) - const evaluated = await evaluateRouteFile(result.output, [ - 'default', - 'Link', - ]) - expect(evaluated.Route.id).toBe('/new') + const { exports, router } = await evaluateModule(result.output) + expect(routeCallOf(result.output).id).toBe('/new') + // (the router package exports the uses are bound to) expect( - evaluated.uses.map((value: unknown) => - typeof value === 'function' ? 'createFileRoute' : value, + exports.uses.map((value: unknown) => + Object.keys(router!).find((name) => router![name] === value), ), ).toEqual(bound) expect(withoutImports(result.output)).toBe( @@ -791,8 +787,8 @@ export const Route = createFileRoute('/new')({})`, async ({ lazy, source }) => { const output = modifiedOutput(transformTo(source, lazy)) - const evaluated = await evaluateRouteFile(output, ['Link']) - expect(evaluated.Route.id).toBe('/new') + await evaluateModule(output) + expect(routeCallOf(output).id).toBe('/new') const [required, other] = lazy ? ['createLazyFileRoute', 'createFileRoute'] : ['createFileRoute', 'createLazyFileRoute'] @@ -851,7 +847,7 @@ export const Route = createFileRoute('/old')({})`, .map((entry) => entry.localName.value), ), ).toEqual(['LinkProps']) - expect((await evaluateRouteFile(output)).Route.id).toBe('/new') + expect(routeCallOf(output).id).toBe('/new') }) it.each([ @@ -883,7 +879,7 @@ export const Route = createFileRoute('/old')({})`, expect( newline === '\n' ? output.includes('\r') : /(? name === 'createFileRoute', @@ -936,7 +932,7 @@ export const Route = createFileRoute('/old')({});` 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') + expect(routeCallOf(output).id).toBe('/new') }) it('renaming the constructor keeps the multi-line formatting of its import declaration', () => { @@ -971,7 +967,7 @@ export const uses = [Link, cfr] export const Route = createFileRoute('/old')({})`), ) - expect((await evaluateRouteFile(output, ['Link'])).Route.id).toBe('/new') + expect(routeCallOf(output).id).toBe('/new') expect( valueImports(output, '@tanstack/react-router').filter( (name) => name === 'createFileRoute', diff --git a/packages/router-generator/tests/real-router.test.ts b/packages/router-generator/tests/real-router.test.ts index c94ba5f7fec..710a082091f 100644 --- a/packages/router-generator/tests/real-router.test.ts +++ b/packages/router-generator/tests/real-router.test.ts @@ -1,16 +1,11 @@ /** - * Generated route trees loaded by the real router: the routes - * `@tanstack/react-router` builds from a generated tree (`routesById`, each - * route's full path and `path` option) are the ones the shared route tree - * helpers (route-tree.ts) read from the tree with their stub of the router. - * This validates the helpers (route ids and URL paths derived from - * `getParentRoute`, lazy route constructors) that the other tests rely on. - * The app also type-checks with the real TypeScript compiler, and the route - * ids of the tree's types are the router's. + * Generated route trees loaded by the real router: the app's router package + * builds a router from the tree (`createRouter`), and each route gets the id, + * full path and parent its file name or virtual route config gives it; URLs + * match the expected routes, Vue pages render their single-file components, + * and the tree's types agree with the router (checked by the real TypeScript + * compiler with the real router types). */ -import { symlink } from 'node:fs/promises' -import path from 'node:path' -import { createRouter } from '@tanstack/react-router' import { describe, expect, test } from 'vitest' import { index, @@ -20,128 +15,313 @@ import { route as virtualRoute, } from '@tanstack/virtual-file-routes' import { createApp, fileRoute, generate, lazyFileRoute, rootRoute } from './app' -import { routePathsOf, urlsOf } from './route-tree' +import { withRouteTree } from './route-tree' import { typeAssertions, typeErrorsOf } from './type-check' import type { Config } from '../src' -/** This package's dependencies, for the app to import the router from. */ -const nodeModules = path.resolve(import.meta.dirname, '../node_modules') - /** Route files whose ids the generator fixes. */ -const routes = (files: Array) => +const routes = (files: Array, target: Config['target'] = 'react') => Object.fromEntries( files.map((file) => [ `src/routes/${file}`, - file.includes('.lazy.') ? lazyFileRoute('/wrong') : fileRoute('/wrong'), + file.includes('.lazy.') + ? lazyFileRoute('/wrong', '{}', target) + : fileRoute('/wrong', '{}', target), ]), ) -describe('the real router on a generated route tree', () => { - test.each<{ - name: string - files: Record - config?: Partial - }>([ - { - name: 'flat, directory, layout, pathless, group, lazy and param routes', - files: { - 'src/routes/__root.tsx': rootRoute(), - ...routes([ - 'index.tsx', - 'about.tsx', - 'about.lazy.tsx', - 'contact.lazy.tsx', - 'posts.tsx', - 'posts.index.tsx', - 'posts.$postId.tsx', - 'posts_.$postId.edit.tsx', - 'blog/route.tsx', - 'blog/index.tsx', - 'blog/$slug.tsx', - '_auth.tsx', - '_auth/login.tsx', - '_auth/_admin.tsx', - '_auth/_admin/users.tsx', - '(marketing)/route.tsx', - '(marketing)/pricing.tsx', - '(legal)/terms.tsx', - '{-$locale}/help.tsx', - 'files.$.tsx', - '[_]internal.tsx', - 'docs.[guide].tsx', - 'settings/_layout.lazy.tsx', - 'settings/_layout/profile.tsx', - ]), - }, +/** A Vue single-file component rendering `text`, and its child routes. */ +const sfc = (text: string) => ` + + +` + +interface Case { + name: string + files: Record + config?: Partial + /** Route id to its full path and its parent's id (null for the root). */ + expected: Record + /** URL to the id of the deepest route the router matches for it. */ + visits: Record + /** Vue only: URL to the texts its page renders, in order. */ + pages?: Record> +} + +const cases: Array = [ + { + name: 'flat, directory, layout, pathless, group, lazy and param routes', + files: { + 'src/routes/__root.tsx': rootRoute(), + ...routes([ + 'index.tsx', + 'about.tsx', + 'about.lazy.tsx', + 'contact.lazy.tsx', + 'posts.tsx', + 'posts.index.tsx', + 'posts.$postId.tsx', + 'posts_.$postId.edit.tsx', + 'blog/route.tsx', + 'blog/index.tsx', + 'blog/$slug.tsx', + '_auth.tsx', + '_auth/login.tsx', + '_auth/_admin.tsx', + '_auth/_admin/users.tsx', + '(marketing)/pricing.tsx', + '(legal)/terms.tsx', + '{-$locale}/help.tsx', + 'files.$.tsx', + '[_]internal.tsx', + 'docs.[guide].tsx', + 'settings/_layout.tsx', + 'settings/_layout/profile.tsx', + ]), + }, + expected: { + __root__: ['/', null], + '/': ['/', '__root__'], + '/about': ['/about', '__root__'], + '/contact': ['/contact', '__root__'], + '/posts': ['/posts', '__root__'], + '/posts/': ['/posts/', '/posts'], + '/posts/$postId': ['/posts/$postId', '/posts'], + '/posts_/$postId/edit': ['/posts/$postId/edit', '__root__'], + '/blog': ['/blog', '__root__'], + '/blog/': ['/blog/', '/blog'], + '/blog/$slug': ['/blog/$slug', '/blog'], + '/_auth': ['/', '__root__'], + '/_auth/login': ['/login', '/_auth'], + '/_auth/_admin': ['/', '/_auth'], + '/_auth/_admin/users': ['/users', '/_auth/_admin'], + '/(marketing)/pricing': ['/pricing', '__root__'], + '/(legal)/terms': ['/terms', '__root__'], + '/{-$locale}/help': ['/{-$locale}/help', '__root__'], + '/files/$': ['/files/$', '__root__'], + '/_internal': ['/_internal', '__root__'], + '/docs/guide': ['/docs/guide', '__root__'], + '/settings/_layout': ['/settings', '__root__'], + '/settings/_layout/profile': ['/settings/profile', '/settings/_layout'], }, - { - name: 'a virtual route config with layouts, an index and a physical mount', - files: { - 'src/routes/root.tsx': rootRoute(), - ...routes([ - 'home.tsx', - 'posts/layout.tsx', - 'posts/list.tsx', - 'posts/detail.tsx', - 'auth-layout.tsx', - 'login.tsx', - 'legacy/about.tsx', - 'legacy/index.tsx', + visits: { + '/': '/', + '/about': '/about', + '/contact': '/contact', + '/posts': '/posts/', + '/posts/1': '/posts/$postId', + '/posts/1/edit': '/posts_/$postId/edit', + '/blog/hello': '/blog/$slug', + '/login': '/_auth/login', + '/users': '/_auth/_admin/users', + '/pricing': '/(marketing)/pricing', + '/terms': '/(legal)/terms', + '/help': '/{-$locale}/help', + '/fr/help': '/{-$locale}/help', + '/files/a/b': '/files/$', + '/_internal': '/_internal', + '/docs/guide': '/docs/guide', + '/settings/profile': '/settings/_layout/profile', + }, + }, + { + name: 'a virtual route config with layouts, an index and a physical mount', + files: { + 'src/routes/root.tsx': rootRoute(), + ...routes([ + 'home.tsx', + 'posts/layout.tsx', + 'posts/list.tsx', + 'posts/detail.tsx', + 'auth-layout.tsx', + 'login.tsx', + 'legacy/about.tsx', + 'legacy/index.tsx', + ]), + }, + config: { + virtualRouteConfig: virtualRootRoute('root.tsx', [ + index('home.tsx'), + virtualRoute('/posts', 'posts/layout.tsx', [ + index('posts/list.tsx'), + virtualRoute('$postId', 'posts/detail.tsx'), ]), - }, - config: { - virtualRouteConfig: virtualRootRoute('root.tsx', [ - index('home.tsx'), - virtualRoute('/posts', 'posts/layout.tsx', [ - index('posts/list.tsx'), - virtualRoute('$postId', 'posts/detail.tsx'), - ]), - layout('auth', 'auth-layout.tsx', [ - virtualRoute('/login', 'login.tsx'), - ]), - physical('/legacy', 'legacy'), + layout('auth', 'auth-layout.tsx', [ + virtualRoute('/login', 'login.tsx'), ]), - }, + physical('/legacy', 'legacy'), + ]), + }, + expected: { + __root__: ['/', null], + '/': ['/', '__root__'], + '/posts': ['/posts', '__root__'], + '/posts/': ['/posts/', '/posts'], + '/posts/$postId': ['/posts/$postId', '/posts'], + '/_auth': ['/', '__root__'], + '/_auth/login': ['/login', '/_auth'], + '/legacy/about': ['/legacy/about', '__root__'], + '/legacy/': ['/legacy/', '__root__'], + }, + visits: { + '/': '/', + '/posts': '/posts/', + '/posts/1': '/posts/$postId', + '/login': '/_auth/login', + '/legacy/about': '/legacy/about', + '/legacy': '/legacy/', + }, + }, + { + name: 'a Vue app with route files and component pieces', + config: { target: 'vue' }, + files: { + 'src/routes/__root.ts': rootRoute('vue'), + 'src/routes/__root.component.vue': sfc('root'), + ...routes( + [ + 'index.ts', + 'posts.ts', + 'posts.$postId.ts', + '_auth.ts', + '_auth/login.ts', + ], + 'vue', + ), + 'src/routes/index.component.vue': sfc('home'), + 'src/routes/posts.component.vue': sfc('posts'), + 'src/routes/posts.$postId.component.vue': sfc('post'), + 'src/routes/_auth.component.vue': sfc('auth'), + 'src/routes/_auth/login.component.vue': sfc('login'), + // A route with a component piece and no route file + 'src/routes/about.component.vue': sfc('about'), + }, + expected: { + __root__: ['/', null], + '/': ['/', '__root__'], + '/about': ['/about', '__root__'], + '/posts': ['/posts', '__root__'], + '/posts/$postId': ['/posts/$postId', '/posts'], + '/_auth': ['/', '__root__'], + '/_auth/login': ['/login', '/_auth'], }, - ])( - '$name', - async ({ files, config }) => { - const root = await createApp(files) - const { generatedRouteTree } = await generate(root, config) - await symlink(nodeModules, path.join(root, 'node_modules'), 'junction') + visits: { + '/': '/', + '/about': '/about', + '/posts': '/posts', + '/posts/1': '/posts/$postId', + '/login': '/_auth/login', + }, + pages: { + '/': ['root', 'home'], + '/about': ['root', 'about'], + '/posts/1': ['root', 'posts', 'post'], + '/login': ['root', 'auth', 'login'], + }, + }, +] + +/** Types for Vue single-file component imports, as in a Vue app. */ +const vueShim = `declare module '*.vue' { + const component: unknown + export default component +} +` - const { routeTree } = await import(/* @vite-ignore */ generatedRouteTree) - const router = createRouter({ routeTree }) - const realRoutes = Object.values(router.routesById) +/** The texts of the `

    ` elements of server-rendered `html`, in order. */ +const paragraphs = (html: string) => + [...html.matchAll(/

    ([^<]*)<\/p>/g)].map((match) => match[1]) + +/** + * Generates the app of `testCase` and checks the router over its route tree + * and the tree's types against the expected routes. + */ +async function checkApp({ files, config, expected, visits, pages }: Case) { + const root = await createApp({ ...files, 'src/vue-shim.d.ts': vueShim }) + const { generatedRouteTree } = await generate(root, config) - expect( - Object.fromEntries( - realRoutes.map((route) => [route.id, route.fullPath]), - ), - ).toEqual(await urlsOf(generatedRouteTree)) - expect( - Object.fromEntries( - realRoutes.map((route) => [ - route.id, - (route.options as { path?: string }).path, - ]), - ), - ).toEqual(await routePathsOf(generatedRouteTree)) + await withRouteTree(generatedRouteTree, async (tree) => { + const { router } = tree + expect( + Object.fromEntries( + Object.values(router.routesById).map((route) => [ + route.id, + [route.fullPath, route.parentRoute?.id ?? null], + ]), + ), + ).toEqual(expected) + expect( + Object.fromEntries( + Object.keys(visits).map((url) => [ + url, + router.matchRoutes(url).at(-1)?.routeId, + ]), + ), + ).toEqual(visits) + for (const [url, texts] of Object.entries(pages ?? {})) { + expect(paragraphs(await tree.renderAt(url))).toEqual(texts) + } + }) - const ids = realRoutes - .map((route) => JSON.stringify(route.id)) - .join(' | ') - expect( - await typeErrorsOf( - root, - 'src/checks.ts', - `import type { FileRoutesById } from './routeTree.gen' + // The tree's types: the same route ids, and for each route the same full + // path and parent as the router's. + const ids = Object.keys(expected) + const checks = ids.map((id, i) => { + const [fullPath, parent] = expected[id]! + const route = `FileRoutesById[${JSON.stringify(id)}]` + const actual = `[${JSON.stringify(id)}, ${route}['fullPath'], ${parent === null ? 'null' : `${route}['parentRoute']['id']`}]` + const wanted = JSON.stringify([id, fullPath, parent]) + // (the assignment names the types of a route that differ) + return `export type Check${i} = Expect> +export const check${i}: ${wanted} = null as unknown as ${actual}` + }) + expect( + await typeErrorsOf( + root, + 'src/checks.ts', + `/// +import type { FileRoutesById } from './routeTree.gen' ${typeAssertions} -export type Check = Expect> +export type Ids = Expect JSON.stringify(id)).join(' | ')}>> +${checks.join('\n')} `, - ), - ).toEqual([]) - }, + ), + ).toEqual([]) +} + +describe('the real router on a generated route tree', () => { + test.each(cases)('$name', checkApp, 60_000) + + // Bug: the route tree types the layout route of a route group directory + // (`(marketing)/route.tsx`) with `fullPath: ''` in `FileRoutesByPath`, + // while the router gives it the full path `/` (a pathless layout, + // `_auth.tsx`, is typed `/`, and agrees). + // Source: src/utils.ts:929-950 (`inferFullPath`: `/(marketing)` without + // its group is `/`, which is not an index path, so its trailing slash is + // dropped), printed at src/utils.ts:1260. + // Impact: `Route.fullPath` and the types derived from it (`from` of + // links and hooks) say `''` in such a layout while the app sees `/`. + test.fails( + 'the layout route of a route group directory has the full path of the router in the types', + () => + checkApp({ + name: 'a route group layout', + files: { + 'src/routes/__root.tsx': rootRoute(), + ...routes(['(marketing)/route.tsx', '(marketing)/pricing.tsx']), + }, + expected: { + __root__: ['/', null], + '/(marketing)': ['/', '__root__'], + '/(marketing)/pricing': ['/pricing', '/(marketing)'], + }, + visits: { '/pricing': '/(marketing)/pricing' }, + }), 60_000, ) }) diff --git a/packages/router-generator/tests/route-file-upkeep.test.ts b/packages/router-generator/tests/route-file-upkeep.test.ts index 076f8423572..30e14cef742 100644 --- a/packages/router-generator/tests/route-file-upkeep.test.ts +++ b/packages/router-generator/tests/route-file-upkeep.test.ts @@ -13,8 +13,8 @@ import { parseSync } from 'vite' import { afterEach, describe, expect, test, vi } from 'vitest' import { createApp, generate, read, rootRoute } from './app' -import { evaluateRouteFile } from './route-id' -import { routesOf } from './route-tree' +import { routeCallOf } from './route-id' +import { evaluateModule, routesOf } from './route-tree' import type { Config } from '../src' afterEach(() => { @@ -104,10 +104,9 @@ function withoutImports(code: string) { } /** - * Asserts the upkeep of a route file: it evaluates (with the router exports - * it uses) to a Route with `id` created with `createdWith`, which it binds - * once from the router package, and its code outside the imports is - * `expected`'s. + * Asserts the upkeep of a route file: it evaluates with the real router, its + * Route is created with `createdWith` and `id`, which it binds once from the + * router package, and its code outside the imports is `expected`'s. */ async function expectRouteFile( written: string, @@ -115,16 +114,14 @@ async function expectRouteFile( id, createdWith = 'createFileRoute', expected, - routerExports = [], }: { id: string createdWith?: 'createFileRoute' | 'createLazyFileRoute' expected: string - routerExports?: Array }, ) { - const { Route } = await evaluateRouteFile(written, routerExports) - expect([Route.id, Route.createdWith]).toEqual([id, createdWith]) + await evaluateModule(written) + expect(routeCallOf(written)).toEqual({ constructor: createdWith, id }) expect( routerValueImports(written).filter((name) => name === createdWith), ).toHaveLength(1) @@ -325,7 +322,6 @@ export const Route = createFileRoute('/new')({ id: '/new', createdWith: 'createLazyFileRoute', expected: source.replace('createFileRoute(', 'createLazyFileRoute('), - routerExports: ['Link'], }) }) @@ -380,7 +376,6 @@ export const Route = createFileRoute('/new')({}) expected: lazy ? source.replace('createFileRoute(', 'createLazyFileRoute(') : source, - routerExports: ['Link'], }) }, ) @@ -435,10 +430,11 @@ describe('missing route constructor imports', () => { expect(outputLines).toContain(line) } expect(routerValueImports(written)).toEqual(['createFileRoute']) - const { Route } = await evaluateRouteFile( - written.replace(/^import .* from 'react'$/m, ''), - ) - expect([Route.id, Route.createdWith]).toEqual(['/new', 'createFileRoute']) + await evaluateModule(written) + expect(routeCallOf(written)).toEqual({ + constructor: 'createFileRoute', + id: '/new', + }) }, ) @@ -467,7 +463,6 @@ export const Route = createFileRoute('/old')({}) await expectRouteFile(written, { id: '/new', expected: body.replace('/old', '/new'), - routerExports: ['Link'], }) }, ) @@ -587,7 +582,6 @@ export const Route = createFileRoute('/old')({})`, await expectRouteFile(written, { id: '/new', expected: source.replace('/old', '/new'), - routerExports: ['Link'], }) // The file keeps its BOM and its line endings. expect(written.startsWith('\uFEFF')).toBe(source.startsWith('\uFEFF')) diff --git a/packages/router-generator/tests/route-id.test.ts b/packages/router-generator/tests/route-id.test.ts index d11119442fc..ef3e00d920e 100644 --- a/packages/router-generator/tests/route-id.test.ts +++ b/packages/router-generator/tests/route-id.test.ts @@ -1,68 +1,82 @@ -/** Controls of the shared `routeIdOf` helper (route-id.ts). */ +/** Controls of the shared `routeCallOf` helper (route-id.ts). */ import { describe, expect, test } from 'vitest' -import { evaluateRouteFile, routeIdOf } from './route-id' +import { routeCallOf } from './route-id' -describe('routeIdOf', () => { +describe('routeCallOf', () => { test.each(['react', 'solid', 'vue'])( - 'returns the id a %s route file creates its Route with', - async (framework) => { + 'reads the constructor and id of a %s route file', + (framework) => { expect( - await routeIdOf(`import { createFileRoute } from '@tanstack/${framework}-router' + routeCallOf(`import { createFileRoute } from '@tanstack/${framework}-router' export const Route = createFileRoute('/posts/$id')({ component: () => null })`), - ).toBe('/posts/$id') + ).toEqual({ constructor: 'createFileRoute', id: '/posts/$id' }) }, ) - test('reads the id of a lazy file route', async () => { - expect( - await routeIdOf(`import { createLazyFileRoute } from '@tanstack/react-router' -export const Route = createLazyFileRoute('/lazy')({})`), - ).toBe('/lazy') - }) - - test('evaluateRouteFile tells which constructor created the Route', async () => { - const lazy = - await evaluateRouteFile(`import { createLazyFileRoute } from '@tanstack/react-router' -export const Route = createLazyFileRoute('/lazy')({})`) - const route = - await evaluateRouteFile(`import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/route')({})`) - expect([lazy.Route.createdWith, route.Route.createdWith]).toEqual([ - 'createLazyFileRoute', - 'createFileRoute', - ]) - }) - - test('reads the id of a route file with JSX', async () => { - expect( - await routeIdOf(`import { createFileRoute } from '@tanstack/react-router' + test.each([ + { + name: 'a lazy file route', + code: `import { createLazyFileRoute } from '@tanstack/react-router' +export const Route = createLazyFileRoute('/lazy')({})`, + expected: { constructor: 'createLazyFileRoute', id: '/lazy' }, + }, + { + name: 'a root route', + code: `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})`, + expected: { constructor: 'createRootRoute', id: undefined }, + }, + { + name: 'an aliased constructor, a template literal and wrappers', + code: `import { createFileRoute as cfr } from '@tanstack/react-router' +export const Route = (cfr(\`/a\` as any)({}) as any)`, + expected: { constructor: 'createFileRoute', id: '/a' }, + }, + { + name: 'a namespace import', + code: `import * as Router from '@tanstack/react-router' +export const Route = Router.createFileRoute("/a")({})`, + expected: { constructor: 'createFileRoute', id: '/a' }, + }, + { + name: 'a declaration exported in an export list', + code: `import { createFileRoute } from '@tanstack/react-router' +const route = createFileRoute('/a')({}) +const other = createFileRoute('/other')({}) +export { route as Route, other }`, + expected: { constructor: 'createFileRoute', id: '/a' }, + }, + { + name: 'a constructor not imported from a router package', + code: `import { createFileRoute } from './local' +export const Route = createFileRoute('/a')({})`, + expected: { constructor: undefined, id: '/a' }, + }, + { + name: 'a constructor imported for its type only', + code: `import { type createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/a')({})`, + expected: { constructor: undefined, id: '/a' }, + }, + { + name: 'JSX', + code: `import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/jsx')({ component: Page }) function Page() { return <>

    Title

    Body

    -}`), - ).toBe('/jsx') - }) - - test('fails on a module that does not evaluate', async () => { - await expect( - routeIdOf(`import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/a')({}) -export const Route = 1`), - ).rejects.toThrow() - await expect( - routeIdOf(`import { createFileRoute, Missing } from '@tanstack/react-router' -export const Route = createFileRoute('/a')({ component: Missing })`), - ).rejects.toThrow() +}`, + expected: { constructor: 'createFileRoute', id: '/jsx' }, + }, + ])('reads $name', ({ code, expected }) => { + expect(routeCallOf(code)).toEqual(expected) }) - test('evaluateRouteFile provides the other router exports it is given', async () => { - const exports = await evaluateRouteFile( - `import Router, { createFileRoute, Link } from '@tanstack/react-router' -export const used = [Router, Link] -export const Route = createFileRoute('/a')({})`, - ['default', 'Link'], - ) - expect(exports.used).toEqual(['default', 'Link']) - expect(exports.Route.id).toBe('/a') + test('fails on a file that does not parse or exports no Route call', () => { + expect(() => + routeCallOf(`import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/a')({}`), + ).toThrow() + expect(() => routeCallOf(`export const Route = 1`)).toThrow() + expect(() => routeCallOf(`export const helper = 1`)).toThrow() }) }) diff --git a/packages/router-generator/tests/route-id.ts b/packages/router-generator/tests/route-id.ts index 3a3516c9386..5b69ba09f4d 100644 --- a/packages/router-generator/tests/route-id.ts +++ b/packages/router-generator/tests/route-id.ts @@ -1,59 +1,168 @@ /** - * Shared test helper: the route id a route file creates its `Route` with, - * found by evaluating the file (not by reading its text). + * Shared test helper: the route constructor call a route file exports as + * `Route`, read from the file's syntax tree (Oxc, Vite's parser). + * + * The route id a file route passes to `createFileRoute` is used by the + * types only (`FileRoutesByPath`); at runtime the router takes ids from the + * route tree. Upkeep of that argument is therefore checked on its parsed + * value, not by running the file. What the file does at runtime is checked + * by loading the route tree with the real router (route-tree.ts). */ -import { transformWithOxc } from 'vite' +import { parseSync } from 'vite' -/** The router packages a route file may import its route constructors from. */ -const routerPackages = /(["'])@tanstack\/(?:react|solid|vue)-router\1/g +/** The router packages a route file may import its route constructor from. */ +const routerPackages = /^@tanstack\/(?:react|solid|vue)-router$/ -/** The JSX runtime that compiled JSX imports. */ -const jsxRuntime = /(["'])react\/jsx-runtime\1/g +/** The route constructor call a route file's `Route` export is. */ +export interface RouteCall { + /** + * The router package export the called constructor is bound to + * (`createFileRoute`, `createLazyFileRoute`, `createRootRoute`, …), or + * undefined when the callee is not bound to a router package import. + */ + constructor: string | undefined + /** The value of the string literal the constructor gets, if any. */ + id: string | undefined +} -/** - * Evaluates a route file with a stub of the router packages (any framework's) - * and returns its exports. The stub provides the route constructors - * (`createFileRoute`, `createLazyFileRoute`; a route is `{ id, options, - * createdWith }`, `createdWith` naming the constructor) - * and, for each name in `routerExports`, an export of that name whose value - * is the name (`default` for a default export). JSX compiles to calls of a - * stub JSX runtime. Like a bundler, fails on an invalid module or an import of - * a name the stub does not provide. - */ -export async function evaluateRouteFile( - code: string, - routerExports: Array = [], -): Promise> { - const { code: javascript } = await transformWithOxc(code, 'route.tsx') - const router = `data:text/javascript,${encodeURIComponent( - `export const createFileRoute = (id) => (options) => ({ id, options, createdWith: 'createFileRoute' }) -export const createLazyFileRoute = (id) => (options) => ({ id, options, createdWith: 'createLazyFileRoute' }) -${routerExports - .map((name) => - name === 'default' - ? `export default 'default'` - : `export const ${name} = ${JSON.stringify(name)}`, - ) - .join('\n')}`, - )}` - const runtime = `data:text/javascript,${encodeURIComponent( - `export const jsx = (type) => ({ type }) -export const jsxs = jsx -export const Fragment = 'Fragment'`, - )}` - const linked = javascript - .replaceAll(routerPackages, JSON.stringify(router)) - .replaceAll(jsxRuntime, JSON.stringify(runtime)) - return import( - /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(linked)}` - ) +type Node = Record + +/** `node` without the TypeScript wrappers and parentheses around it. */ +function unwrap(node: Node | null | undefined): Node | undefined { + let current = node ?? undefined + while ( + current && + [ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'ParenthesizedExpression', + ].includes(current.type) + ) { + current = current.expression + } + return current +} + +/** The value of a string literal or a template literal without expressions. */ +function stringValue(node: Node | undefined) { + if (node?.type === 'Literal' && typeof node.value === 'string') { + return node.value as string + } + if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis[0].value.cooked as string + } + return undefined +} + +/** The name of an identifier or string module export name. */ +function nameOf(node: Node) { + return (node.type === 'Identifier' ? node.name : node.value) as string } /** - * Evaluates a route file (see `evaluateRouteFile`) and returns the id its - * `Route` is created with. + * Reads the route constructor call that `code` (a route file) exports as + * `Route`: `export const Route = createFileRoute('/id')({ ... })` or a + * declaration exported under that name, with any TypeScript wrappers. Fails + * when the file does not parse or exports no such `Route`. */ -export async function routeIdOf(code: string): Promise { - const { Route } = await evaluateRouteFile(code) - return Route.id as string +export function routeCallOf(code: string, fileName = 'route.tsx'): RouteCall { + const { program, errors } = parseSync(fileName, code) + if (errors.length) { + throw new Error(errors.map((error) => error.message).join('\n')) + } + + /** Local name to the router package export it is bound to. */ + const bindings = new Map() + /** Local names of namespace imports of a router package. */ + const namespaces = new Set() + /** Top-level variable initializers, by name. */ + const initializers = new Map() + /** The local name exported as `Route`. */ + let exported: string | undefined + + const declare = (declaration: Node | null | undefined) => { + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') { + initializers.set(declarator.id.name, declarator.init) + } + } + } + } + for (const statement of program.body as Array) { + if (statement.type === 'ImportDeclaration') { + if ( + !routerPackages.test(statement.source.value) || + statement.importKind === 'type' + ) { + continue + } + for (const specifier of statement.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaces.add(specifier.local.name) + } else if ( + specifier.type === 'ImportSpecifier' && + specifier.importKind !== 'type' + ) { + bindings.set(specifier.local.name, nameOf(specifier.imported)) + } + } + } else if (statement.type === 'VariableDeclaration') { + declare(statement) + } else if (statement.type === 'ExportNamedDeclaration') { + declare(statement.declaration) + if ( + statement.declaration?.type === 'VariableDeclaration' && + statement.declaration.declarations.some( + (declarator: Node) => + declarator.id.type === 'Identifier' && + declarator.id.name === 'Route', + ) + ) { + exported = 'Route' + } + if (!statement.source) { + for (const specifier of statement.specifiers) { + if (nameOf(specifier.exported) === 'Route') { + exported = nameOf(specifier.local) + } + } + } + } + } + + const route = exported === undefined ? undefined : initializers.get(exported) + const call = unwrap(route) + if (call?.type !== 'CallExpression') { + throw new Error('The route file exports no Route created by a call') + } + // `createFileRoute('/id')(options)`, or `createRootRoute(options)` + const factory = unwrap(call.callee) + const constructorCall = factory?.type === 'CallExpression' ? factory : call + const callee = unwrap(constructorCall.callee) + let constructor: string | undefined + if (callee?.type === 'Identifier') { + constructor = bindings.get(callee.name) + } else if ( + callee?.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + namespaces.has(callee.object.name) + ) { + constructor = callee.property.name + } + return { + constructor, + id: + constructorCall === call + ? undefined + : stringValue(unwrap(constructorCall.arguments[0])), + } +} + +/** The route id a route file passes to its route constructor. */ +export async function routeIdOf(code: string): Promise { + return routeCallOf(code).id } diff --git a/packages/router-generator/tests/route-paths.test.ts b/packages/router-generator/tests/route-paths.test.ts index 569c83d2b20..0a6c228479d 100644 --- a/packages/router-generator/tests/route-paths.test.ts +++ b/packages/router-generator/tests/route-paths.test.ts @@ -14,7 +14,7 @@ import { route as virtualRoute, } from '@tanstack/virtual-file-routes' import { createApp, fileRoute, generate, lazyFileRoute, rootRoute } from './app' -import { routePathsOf, routesOf, urlsOf } from './route-tree' +import { matchesOf, routesOf, urlsOf } from './route-tree' import { typeAssertions, typeErrorsOf } from './type-check' import type { Config } from '../src' @@ -240,8 +240,9 @@ describe('layouts and groups', () => { '/(group)/_layout': '/', '/(group)/_layout/inner': '/inner', }) - expect((await routePathsOf(generatedRouteTree))['/(group)/_layout']).toBe( - undefined, + // (the layout is no page: the router does not match it at `/`) + expect(await matchesOf(generatedRouteTree, '/')).not.toContain( + '/(group)/_layout', ) }) diff --git a/packages/router-generator/tests/route-pieces.test.ts b/packages/router-generator/tests/route-pieces.test.ts index 3a707d720ea..61e2a01163e 100644 --- a/packages/router-generator/tests/route-pieces.test.ts +++ b/packages/router-generator/tests/route-pieces.test.ts @@ -2,19 +2,16 @@ * Route piece files: Vue single-file components named after a route and a * component option (`posts.component.vue`, `__root.errorComponent.vue`, …) * become that option of the route, loaded lazily from the file's default - * export. The generator never writes to them. + * export. The generator never writes to them. Checked with the real router + * and real Vue: each option, once the router loaded the route, renders the + * single-file component (compiled by the Vue plugin of a real Vite server). */ import { afterEach, describe, expect, test, vi } from 'vitest' -import { - createApp, - fileRoute, - generate, - lazyFileRoute, - read, - rootRoute, -} from './app' +import { createSSRApp } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { createApp, fileRoute, generate, read, rootRoute } from './app' import { routeIdOf } from './route-id' -import { loadLazyRoute, routeOptionsOf, routesOf, urlsOf } from './route-tree' +import { evaluateModule, routesOf, urlsOf, withRouteTree } from './route-tree' afterEach(() => { vi.restoreAllMocks() @@ -30,12 +27,39 @@ const label = '${text}' ` -/** The option a piece file gives its route: its default export, lazily. */ -const lazyComponent = (file: string) => ({ - kind: 'lazyRouteComponent', - file: `routes/${file}`, - name: 'default', -}) +/** The texts of the `

    ` elements of server-rendered `html`, joined. */ +const textOf = (html: string) => + [...html.matchAll(/

    ([^<]*)<\/p>/g)].map((match) => match[1]).join(' ') + +/** The component options a route may have. */ +const componentOptions = [ + 'component', + 'pendingComponent', + 'errorComponent', + 'notFoundComponent', +] as const + +/** + * The text each component option of each route renders, by route id, once + * the router loaded the route (its lazy route file and components). + */ +function piecesOf(treeFile: string) { + return withRouteTree(treeFile, async ({ router, render }) => { + const pieces: Record> = {} + for (const route of Object.values(router.routesById)) { + await router.loadRouteChunk(route) + pieces[route.id] = {} + for (const option of componentOptions) { + if (route.options[option]) { + pieces[route.id]![option] = textOf( + await render(route.options[option]), + ) + } + } + } + return pieces + }) +} 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 () => { @@ -70,27 +94,22 @@ describe('Vue piece files', () => { '/posts/$postId': 'routes/posts.$postId.ts', '/settings': 'routes/settings.ts', }) - expect(await routeOptionsOf(generatedRouteTree)).toEqual({ + expect(await piecesOf(generatedRouteTree)).toEqual({ __root__: { - component: lazyComponent('__root.component.vue'), - notFoundComponent: lazyComponent('__root.notFoundComponent.vue'), - errorComponent: lazyComponent('__root.errorComponent.vue'), - pendingComponent: lazyComponent('__root.pendingComponent.vue'), + component: 'root', + notFoundComponent: 'root not found', + errorComponent: 'root error', + pendingComponent: 'root pending', }, - '/': { component: lazyComponent('index.component.vue') }, - '/about': { component: lazyComponent('about.component.vue') }, + '/': { component: 'index' }, + '/about': { component: 'about' }, '/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'), + pendingComponent: 'posts pending', + errorComponent: 'posts error', + notFoundComponent: 'posts not found', }, + '/posts/$postId': { component: 'post', errorComponent: 'post error' }, + '/settings': { pendingComponent: 'settings pending' }, }) for (const [file, content] of Object.entries(files)) { expect(await read(root, file)).toBe(content) @@ -110,9 +129,9 @@ describe('Vue piece files', () => { }) expect(generatedRouteTree.endsWith('.js')).toBe(true) - expect(await routeOptionsOf(generatedRouteTree)).toEqual({ - __root__: { component: lazyComponent('__root.component.vue') }, - '/': { component: lazyComponent('index.component.vue') }, + expect(await piecesOf(generatedRouteTree)).toEqual({ + __root__: { component: 'root' }, + '/': { component: 'index' }, }) }) @@ -136,8 +155,8 @@ describe('Vue piece files', () => { '/posts/$postId': 'routes/posts.$postId.ts', '/posts/$postId/edit': 'routes/posts.$postId.edit.ts', }) - expect((await routeOptionsOf(generatedRouteTree))['/posts']).toEqual({ - component: lazyComponent('posts.component.vue'), + expect((await piecesOf(generatedRouteTree))['/posts']).toEqual({ + component: 'posts', }) }) @@ -149,24 +168,23 @@ describe('Vue piece files', () => { const root = await createApp({ 'src/routes/__root.ts': rootRoute('vue'), 'src/routes/about.ts': fileRoute('/about', '{}', 'vue'), - 'src/routes/about.lazy.ts': lazyFileRoute('/about', '{}', 'vue'), + 'src/routes/about.lazy.ts': `import { h } from 'vue' +import { createLazyFileRoute } from '@tanstack/vue-router' + +export const Route = createLazyFileRoute('/about')({ + component: () => h('p', 'about lazy'), +}) +`, 'src/routes/posts.ts': fileRoute('/posts', '{}', 'vue'), 'src/routes/posts.component.vue': sfc('posts'), }) const { generatedRouteTree } = await generate(root, { target: 'vue' }) - expect(await routeOptionsOf(generatedRouteTree)).toEqual({ + expect(await piecesOf(generatedRouteTree)).toEqual({ __root__: {}, - '/about': { lazy: { file: 'routes/about.lazy.ts' } }, - '/posts': { component: lazyComponent('posts.component.vue') }, + '/about': { component: 'about lazy' }, + '/posts': { component: 'posts' }, }) - // (the lazy route the router loads is the file's `Route`) - const lazyRoute = { options: { component: 'about' } } - expect( - await loadLazyRoute(generatedRouteTree, '/about', (file) => - file === 'routes/about.lazy.ts' ? { Route: lazyRoute } : {}, - ), - ).toBe(lazyRoute) }) // Bug: the route file of a directory route is `route.ts` (the default @@ -196,8 +214,8 @@ describe('Vue piece files', () => { '/posts': '/posts', '/posts/': '/posts/', }) - expect((await routeOptionsOf(generatedRouteTree))['/posts']).toEqual({ - component: lazyComponent('posts/route.component.vue'), + expect((await piecesOf(generatedRouteTree))['/posts']).toEqual({ + component: 'posts', }) }, ) @@ -209,43 +227,27 @@ describe('Vue piece files', () => { // reads the lazy route's `options` (router-core load-client.ts), which the // component does not have, so loading the route fails. // Impact: a Vue app with a `.lazy.vue` route file fails when the route - // loads (low: the documented Vue piece is `.component.vue`). + // loads (low: the documented Vue piece is `.component.vue`). A + // `.lazy.vue` file is the lazily loaded component of its route (ruling + // 2026-10-18, SPEC J1.17). // Source: mutation run R4 final re-audit (G3). test.fails( - 'a .lazy.vue route file is the lazily loaded component of the route, or the generator warns that it is unsupported', + 'a .lazy.vue route file is the lazily loaded component of the route', async () => { const root = await createApp({ 'src/routes/__root.ts': rootRoute('vue'), 'src/routes/about.ts': fileRoute('/about', '{}', 'vue'), 'src/routes/about.lazy.vue': sfc('about'), }) - const warnings: Array = [] - vi.spyOn(console, 'warn').mockImplementation((...args) => { - warnings.push(args.join(' ')) - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - const { generatedRouteTree } = await generate(root, { - target: 'vue', - disableLogging: false, - }) - const options = (await routeOptionsOf(generatedRouteTree))['/about']! - if (!JSON.stringify(options).includes('about.lazy.vue')) { - // (not wired: the user is told) - expect(warnings.join('\n')).toContain('about.lazy.vue') - return - } - const component = { name: 'AboutSfc' } - if (options.component) { - expect(options.component).toEqual(lazyComponent('about.lazy.vue')) - } else { - const lazyRoute = (await loadLazyRoute( - generatedRouteTree, - '/about', - (file) => - file === 'routes/about.lazy.vue' ? { default: component } : {}, - )) as { options?: Record } | undefined - expect(lazyRoute?.options?.component).toBe(component) - } + const { generatedRouteTree } = await generate(root, { target: 'vue' }) + + expect( + textOf( + await withRouteTree(generatedRouteTree, ({ renderAt }) => + renderAt('/about'), + ), + ), + ).toBe('about') }, ) @@ -266,8 +268,8 @@ describe('Vue piece files', () => { }) const { generatedRouteTree } = await generate(root, { target: 'vue' }) - expect(await routeOptionsOf(generatedRouteTree)).toEqual({ - __root__: { component: lazyComponent('__root.component.vue') }, + expect(await piecesOf(generatedRouteTree)).toEqual({ + __root__: { component: 'root' }, '/': {}, }) }, @@ -296,7 +298,7 @@ describe('empty files', () => { __root__: 'routes/__root.ts', '/posts': 'routes/posts.ts', }) - expect(await routeOptionsOf(generatedRouteTree)).toEqual({ + expect(await piecesOf(generatedRouteTree)).toEqual({ __root__: {}, '/posts': {}, }) @@ -322,8 +324,12 @@ describe('empty files', () => { await generate(root, { target: 'vue' }) const content = await read(root, 'src/routes/about.lazy.vue') - // (empty, or a single-file component: it starts with a block tag) - expect(content === '' || content.trimStart().startsWith('<')).toBe(true) + // Empty, or a single-file component the Vue plugin compiles to a + // component that renders. + if (content !== '') { + const { exports } = await evaluateModule(content, 'about.lazy.vue') + await renderToString(createSSRApp(exports.default)) + } }, ) diff --git a/packages/router-generator/tests/route-tree.test.ts b/packages/router-generator/tests/route-tree.test.ts deleted file mode 100644 index 50a50055f6f..00000000000 --- a/packages/router-generator/tests/route-tree.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** Controls of the shared route tree helpers (route-tree.ts). */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, test } from 'vitest' -import { loadLazyRoute, routeOptionsOf, routesOf, urlsOf } from './route-tree' - -let root: string | undefined - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - root = undefined - } -}) - -/** Writes `tree` next to a routes directory with the given files. */ -async function writeTree(tree: string, routeFiles: Array) { - root = await mkdtemp(path.join(tmpdir(), 'tsr-route-tree-')) - await mkdir(path.join(root, 'routes')) - for (const file of routeFiles) { - await writeFile(path.join(root, 'routes', file), '') - } - await writeFile(path.join(root, 'routeTree.gen.ts'), tree) - return path.join(root, 'routeTree.gen.ts') -} - -const tree = ( - postsImport: string, - lazyImport = './routes/posts.lazy', - postsIndexParent = 'PostsRoute', -) => ` -import { createFileRoute } from '@tanstack/react-router' -import { Route as rootRouteImport } from './routes/__root' -import { Route as PostsRouteImport } from '${postsImport}' -import { Route as PostsIndexRouteImport } from './routes/posts.index' - -const AboutRouteImport = createFileRoute('/about')() -const PostsRoute = PostsRouteImport.update({ - id: '/posts', - path: '/posts', - getParentRoute: () => rootRouteImport, -} as any).lazy(() => import('${lazyImport}').then((d) => d.Route)) -const PostsIndexRoute = PostsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => ${postsIndexParent}, -} as any) -const AboutRoute = AboutRouteImport.update({ - id: '/about', - path: '/about', - getParentRoute: () => rootRouteImport, -} as any) -const rootRouteChildren: any = { - PostsRoute: PostsRoute._addFileChildren({ PostsIndexRoute }), - AboutRoute, -} -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() -` - -describe('routesOf', () => { - test.each(['./routes/posts', './routes/posts.tsx', './routes/posts.js'])( - 'returns the route ids and files of a route tree importing %s', - async (postsImport) => { - const treeFile = await writeTree(tree(postsImport), [ - '__root.tsx', - 'posts.tsx', - 'posts.lazy.tsx', - 'posts.index.tsx', - ]) - expect(await routesOf(treeFile)).toEqual({ - __root__: 'routes/__root.tsx', - '/posts': 'routes/posts.tsx', - '/posts/': 'routes/posts.index.tsx', - '/about': null, - }) - }, - ) - - test('fails on a static or dynamic import that does not resolve', async () => { - const files = [ - '__root.tsx', - 'posts.tsx', - 'posts.lazy.tsx', - 'posts.index.tsx', - ] - await expect( - routesOf(await writeTree(tree('./routes/missing'), files)), - ).rejects.toThrow('"./routes/missing" does not resolve') - await rm(root!, { recursive: true, force: true }) - await expect( - routesOf( - await writeTree(tree('./routes/posts', './routes/missing'), files), - ), - ).rejects.toThrow('"./routes/missing" does not resolve') - }) - - test('fails on a route tree that does not parse', async () => { - const treeFile = await writeTree(tree('./routes/posts') + '\n}', [ - '__root.tsx', - 'posts.tsx', - 'posts.lazy.tsx', - ]) - await expect(routesOf(treeFile)).rejects.toThrow() - }) - - test('derives ids and URL paths from getParentRoute, like router-core', async () => { - const treeFile = await writeTree(tree('./routes/posts'), [ - '__root.tsx', - 'posts.tsx', - 'posts.lazy.tsx', - 'posts.index.tsx', - ]) - expect(await urlsOf(treeFile)).toEqual({ - __root__: '/', - '/posts': '/posts', - '/posts/': '/posts/', - '/about': '/about', - }) - }) - - test.each([ - { name: 'another route', parent: 'rootRouteImport' }, - { name: 'nothing', parent: 'undefined' }, - ])( - 'fails when a route nested under another gets $name from getParentRoute', - async ({ parent }) => { - const treeFile = await writeTree( - tree('./routes/posts', './routes/posts.lazy', parent), - ['__root.tsx', 'posts.tsx', 'posts.lazy.tsx', 'posts.index.tsx'], - ) - await expect(routesOf(treeFile)).rejects.toThrow('getParentRoute') - }, - ) -}) - -describe('routeOptionsOf and loadLazyRoute', () => { - const lazyTree = ` -import { createFileRoute, createLazyRoute, lazyFn, lazyRouteComponent } from '@tanstack/react-router' -import { Route as rootRouteImport } from './routes/__root' - -const AboutRoute = createFileRoute('/about')() - .update({ id: '/about', path: '/about', getParentRoute: () => rootRouteImport } as any) - .lazy(() => - import('./routes/about.lazy').then((d) => createLazyRoute('/about')({ component: d.default })), - ) - .update({ - component: lazyRouteComponent(() => import('./routes/about.component')), - loader: lazyFn(() => import('./routes/about.loader'), 'loader'), - } as any) -export const routeTree = rootRouteImport._addFileChildren({ AboutRoute })._addFileTypes() -` - - test('describe lazy options by the file their importer loads, with the default export by default', async () => { - const treeFile = await writeTree(lazyTree, [ - '__root.tsx', - 'about.lazy.tsx', - 'about.component.tsx', - 'about.loader.ts', - ]) - expect((await routeOptionsOf(treeFile))['/about']).toEqual({ - lazy: { file: 'routes/about.lazy.tsx' }, - component: { - kind: 'lazyRouteComponent', - file: 'routes/about.component.tsx', - name: 'default', - }, - loader: { - kind: 'lazyFn', - file: 'routes/about.loader.ts', - name: 'loader', - }, - }) - }) - - test('the lazy route of a route is what its importer resolves to, with the router helpers it calls', async () => { - const treeFile = await writeTree(lazyTree, [ - '__root.tsx', - 'about.lazy.tsx', - 'about.component.tsx', - 'about.loader.ts', - ]) - const component = () => null - expect( - await loadLazyRoute(treeFile, '/about', (file) => - file === 'routes/about.lazy.tsx' ? { default: component } : {}, - ), - ).toEqual({ options: { id: '/about', component } }) - }) -}) diff --git a/packages/router-generator/tests/route-tree.ts b/packages/router-generator/tests/route-tree.ts index d7514db2590..c12101c47f7 100644 --- a/packages/router-generator/tests/route-tree.ts +++ b/packages/router-generator/tests/route-tree.ts @@ -1,415 +1,321 @@ /** - * Shared test helper: the routes a generated route tree gives the app, found - * by evaluating the tree (not by reading its text). + * Shared test helper: a generated route tree as the app gets it. The tree and + * the modules it imports are loaded by a real Vite server (the Vue plugin + * compiles single-file components), and the app's own router package + * (`@tanstack/react-router`, `@tanstack/vue-router`) builds a real router + * from it with `createRouter`. Route ids, full paths, parents, matching and + * lazily loaded options are the router's; nothing here derives them. + * + * This package has no Solid router, Solid compiler or React DOM renderer: + * Solid apps do not load here, and only Vue pages render. */ -import { readFile, stat } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { + mkdtemp, + readdir, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' import path from 'node:path' -import { parseSync, transformWithOxc } from 'vite' +import { createServer, createServerModuleRunner } from 'vite' +import type { ModuleRunner } from 'vite/module-runner' -/** The router packages a route tree may import runtime helpers from. */ -const routerPackages = /^@tanstack\/(?:react|solid|vue)-router$/ +/** This package's dependencies, for apps to import their packages from. */ +const nodeModules = path.resolve(import.meta.dirname, '../node_modules') -async function isFile(filePath: string) { - try { - return (await stat(filePath)).isFile() - } catch { - return false - } -} +/** The router packages an app may use, and the frameworks they render with. */ +const routerPackages = /^@tanstack\/(react|solid|vue)-router$/ /** - * Resolves an import of `importer` to a file on disk like a bundler does for - * TypeScript projects: the exact file, a `.js`/`.jsx` specifier to its - * TypeScript source, or an extensionless specifier to a script file. Returns - * undefined for an import that does not resolve. + * The root of the app `file` belongs to: the nearest directory above it with + * a `node_modules`, or, for an app in a temporary directory, the temporary + * directory, which gets this package's `node_modules`. */ -export async function resolveImport(specifier: string, importer: string) { - const base = path.isAbsolute(specifier) - ? specifier - : path.resolve(path.dirname(importer), specifier) - const candidates = [base] - if (/\.jsx?$/.test(base)) { - const stem = base.replace(/\.jsx?$/, '') - candidates.push(`${stem}.ts`, `${stem}.tsx`) - } - candidates.push(...['.tsx', '.ts', '.jsx', '.js'].map((ext) => base + ext)) - for (const candidate of candidates) { - if (await isFile(candidate)) { - return candidate +async function appRootOf(file: string) { + const temporary = await realpath(tmpdir()) + let dir = path.dirname(await realpath(file)) + for (;;) { + if (existsSync(path.join(dir, 'node_modules'))) { + return dir + } + const parent = path.dirname(dir) + if (parent === temporary) { + await symlink( + nodeModules, + path.join(dir, 'node_modules'), + 'junction', + ).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') { + throw error + } + }) + return dir } + if (parent === dir) { + throw new Error(`${file} is in no app (no node_modules above it)`) + } + dir = parent } - return undefined } /** - * A stub route: records what the route tree does with it (its options, - * loader options and lazy route module, and its children). + * The Vite plugins of the app at `root`: the Vue plugin when the app has + * single-file components (loaded only then: it is large). */ -function stubRoute(file: string | null) { - return `({ - file: ${JSON.stringify(file)}, - options: {}, - update(options) { Object.assign(this.options, options); return this }, - updateLoader(options) { Object.assign(this.options, options); return this }, - lazy(importer) { this.options.lazy = { importer }; this.lazyFn = importer; return this }, - _addFileChildren(children) { this.children = children; return this }, - _addFileTypes() { return this }, -})` +async function pluginsOf(root: string) { + const hasVueFiles = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith('.vue')) { + return true + } + if ( + entry.isDirectory() && + entry.name !== 'node_modules' && + !entry.name.startsWith('.') && + (await hasVueFiles(path.join(dir, entry.name))) + ) { + return true + } + } + return false + } + if (!(await hasVueFiles(root))) { + return [] + } + const { default: vue } = await import('@vitejs/plugin-vue') + return [vue()] } -/** - * A stub of the router packages, for routes without a file and lazily loaded - * route options. The route constructors give stub routes (a lazy route is - * `{ options }`, its options holding its id, like router-core's `LazyRoute`); - * `lazyRouteComponent` and `lazyFn` record the importer they load and the - * export they take from it, which router-core defaults to `default`. - */ -const routerModule = `const stubRoute = (file) => ${stubRoute(null)} -export const createFileRoute = () => (options) => stubRoute().update(options ?? {}) -export const createRoute = (options) => stubRoute().update(options ?? {}) -export const createLazyFileRoute = (id) => (options) => ({ options: { id, ...options } }) -export const createLazyRoute = createLazyFileRoute -export const lazyRouteComponent = (importer, name) => ({ kind: 'lazyRouteComponent', importer, name: name ?? 'default' }) -export const lazyFn = (importer, name) => ({ kind: 'lazyFn', importer, name: name || 'default' })` - -let evaluations = 0 +/** A module of the app, loaded by a Vite server of the app. */ +interface LoadedModule { + /** The module's exports. */ + exports: Record + /** The module runner that loaded it, with the modules it evaluated. */ + runner: ModuleRunner + /** The router package the app's modules import, if any, and its name. */ + router?: { name: string; exports: Record } +} /** - * A module URL for `code`, unique to an evaluation so that route trees and - * stubs with the same code are not shared between evaluations. + * Loads `file` (absolute path) like its app's dev server does for server + * rendering: a fresh Vite server (no transform or module caches shared with + * other loads), with the Vue plugin for an app with single-file components; + * packages are loaded by Node. JSX in `.tsx`/`.jsx` files compiles with + * Vite's default (React) transform. Runs `use` with the module, then closes + * the server. */ -function dataUrl(code: string, evaluation: number) { - return `data:text/javascript,${encodeURIComponent(`${code}\n// ${evaluation}`)}` -} - -/** router-core's `cleanPath`: collapses repeated slashes. */ -function cleanPath(value: string) { - return value.replace(/\/{2,}/g, '/') +async function withModule( + file: string, + use: (loaded: LoadedModule) => Promise | T, +): Promise { + const root = await appRootOf(file) + const server = await createServer({ + root, + configFile: false, + envFile: false, + logLevel: 'silent', + appType: 'custom', + clearScreen: false, + plugins: await pluginsOf(root), + server: { middlewareMode: true, hmr: false, watch: null, ws: false }, + optimizeDeps: { noDiscovery: true, include: [] }, + ssr: { external: true }, + }) + let runner: ModuleRunner | undefined + try { + runner = createServerModuleRunner(server.environments.ssr, { hmr: false }) + const exports = await runner.import(await realpath(file)) + // The router package the app's modules import: an external module, + // recorded under its name. + const routers = [...runner.evaluatedModules.idToModuleMap.values()] + .filter((node) => routerPackages.test(node.url)) + .map((node) => ({ name: node.url, exports: node.exports })) + if (routers.length > 1) { + throw new Error( + `The app imports ${routers.length} router packages: ${routers.map(({ name }) => name).join(', ')}`, + ) + } + return await use({ exports, runner, router: routers[0] }) + } finally { + await runner?.close() + await server.close() + } } -/** router-core's `trimPathLeft`: drops leading slashes, except of `/`. */ -function trimPathLeft(value: string) { - return value === '/' ? value : value.replace(/^\/+/, '') +/** + * Evaluates `code` as a module of an app (a file named `fileName` in a new + * temporary app) with the real router packages. Returns its exports and the + * router package it imports (its own exports, to compare bindings with). + */ +export async function evaluateModule(code: string, fileName = 'route.tsx') { + const root = await mkdtemp(path.join(tmpdir(), 'tsr-module-')) + try { + const file = path.join(root, fileName) + await writeFile(file, code) + return await withModule(file, ({ exports, router }) => ({ + exports, + router: router?.exports, + })) + } finally { + await rm(root, { recursive: true, force: true }) + } } -/** A route of an evaluated route tree, with its id (as in `FileRoutesById`). */ -interface EvaluatedRoute { +/** A route of the real router. */ +export interface RouterRoute { id: string - /** The route file, relative to the tree (null for a route without a file). */ - file: string | null + fullPath: string + path?: string + parentRoute?: RouterRoute options: Record - /** The function the route tree gave `route.lazy()`, if any. */ - lazyFn?: () => Promise - /** - * The URL path the route matches (router-core's `fullPath`): 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 } -/** - * A module whose every export records the module it was read from, for - * lazy imports that only need to be followed (not run). - */ -function recordingModule(file: string) { - return new Proxy( - {}, - { - get: (_, key) => - key === 'then' ? undefined : { lazyFile: file, export: key }, - }, - ) +/** A generated route tree loaded by the app, and the router built from it. */ +export interface LoadedRouteTree { + /** The real router (`createRouter` of the app's router package). */ + router: { + routeTree: RouterRoute + routesById: Record + matchRoutes: (pathname: string) => Array<{ routeId: string }> + [key: string]: any + } + /** The app's router package, as the app's modules import it. */ + routerPackage: Record + /** `react` or `vue`. */ + framework: string + /** + * The module file (relative to the tree, `/` separated) whose `Route` + * export is the route `routeId` of the router; null for a route the tree + * creates itself. + */ + fileOf: (routeId: string) => string | null + /** Imports a module of the app (absolute path) like the tree's imports. */ + import: (file: string) => Promise> + /** + * Server-renders `component` with the app's framework (Vue only), after + * loading it if it is a lazily loaded route component (`preload`). + */ + render: (component: unknown) => Promise + /** + * Server-renders the app at `url` (Vue only): a new router over the tree + * with a memory history at `url` loads the location, and its + * `RouterProvider` renders. + */ + renderAt: (url: string) => Promise } /** - * Evaluates the generated route tree at `treeFile`, with each route file it - * 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 - * lazy importer of the tree loads (found by running it). Like a bundler, - * fails on an invalid module or a static or dynamic import that does not - * resolve to a file. - * - * Like router-core, the id and URL path of each route derive from the route - * its `getParentRoute()` returns (router-core `Route.init`); the evaluation - * fails, like router-core would or where the router would see another tree - * than the one the route tree nests, when that route is not the one the - * route is nested under in the tree (`_addFileChildren`), when a route has no - * `getParentRoute`, or when two routes get the same id. + * Loads the generated route tree at `treeFile` like the app does, builds the + * router, and runs `use` with them. Fails like a bundler or the router on a + * module that does not load or a tree the router rejects. */ -async function evaluateRouteTree( +export function withRouteTree( treeFile: string, - importLazily: (file: string) => unknown = recordingModule, -) { - const evaluation = ++evaluations - const source = await readFile(treeFile, 'utf8') - const { code } = await transformWithOxc(source, path.basename(treeFile)) - const { module, errors } = parseSync('routeTree.js', code) - if (errors.length) { - throw new Error(errors.map((error) => error.message).join('\n')) - } - - const relative = (file: string) => - path.relative(path.dirname(treeFile), file).replaceAll(path.sep, '/') - - const replacements: Array<{ start: number; end: number; text: string }> = [] - for (const statement of module.staticImports) { - const specifier = statement.moduleRequest.value - let replacement: string - if (routerPackages.test(specifier)) { - replacement = dataUrl(routerModule, evaluation) - } else { - const file = await resolveImport(specifier, treeFile) - if (!file) { - throw new Error(`The route tree import "${specifier}" does not resolve`) - } - replacement = dataUrl( - `export const Route = ${stubRoute(relative(file))}`, - evaluation, - ) - } - replacements.push({ - start: statement.moduleRequest.start, - end: statement.moduleRequest.end, - text: JSON.stringify(replacement), - }) - } - /** The module each dynamic import loads, by specifier. */ - const lazyModules = new Map() - for (const dynamicImport of module.dynamicImports) { - const literal = code.slice( - dynamicImport.moduleRequest.start, - dynamicImport.moduleRequest.end, - ) - const specifier = literal.slice(1, -1) - const file = await resolveImport(specifier, treeFile) - if (!file) { - throw new Error( - `The route tree dynamic import "${specifier}" does not resolve`, - ) + use: (tree: LoadedRouteTree) => Promise | T, +): Promise { + return withModule(treeFile, async ({ exports, runner, router: imported }) => { + if (!imported) { + throw new Error('The app imports no router package') } - lazyModules.set(specifier, relative(file)) - // `import(` → the loader of the evaluation - replacements.push({ - start: dynamicImport.start, - end: dynamicImport.moduleRequest.start, - text: `globalThis.__tsrRouteTreeImport${evaluation}(`, + const routerPackage = imported.exports + const framework = routerPackages.exec(imported.name)![1]! + const { routeTree } = exports + const router = routerPackage.createRouter({ + routeTree, + history: routerPackage.createMemoryHistory(), }) - } - /** Loads a dynamically imported module (file relative to the tree). */ - let load = importLazily - ;(globalThis as Record)[ - `__tsrRouteTreeImport${evaluation}` - ] = (specifier: string) => - Promise.resolve().then(() => load(lazyModules.get(specifier)!)) - - let linked = code - for (const { start, end, text } of replacements.sort( - (a, b) => b.start - a.start, - )) { - linked = linked.slice(0, start) + text + linked.slice(end) - } - - const { routeTree } = await import( - /* @vite-ignore */ dataUrl(linked, evaluation) - ) - - /** The route each route is nested under in the tree. */ - const nestedUnder = new Map() - /** The routes, parents first, in the order of the tree. */ - const nested: Array> = [] - const collect = (parent: object, children: Record = {}) => { - for (const child of Object.values(children)) { - nestedUnder.set(child, parent) - nested.push(child) - collect(child, child.children) - } - } - collect(routeTree, routeTree.children) - - const describe = (route: Record) => - JSON.stringify(route.file ?? route.options.id ?? route.options.path) - const placed = new Map([ - [routeTree, { id: '__root__', url: '/' }], - ]) - /** The id and URL path router-core gives `route` (`Route.init`). */ - const place = (route: Record): { id: string; url: string } => { - const known = placed.get(route) - if (known) { - return known - } - const options = route.options - if (!options.path && !options.id) { - throw new Error( - `Route ${describe(route)} has neither a path nor an id: router-core takes it for a root route`, - ) - } - const parent = options.getParentRoute?.() - if (!parent) { - throw new Error( - `Route ${describe(route)} has no getParentRoute: router-core rejects it`, - ) - } - if (parent !== nestedUnder.get(route)) { - throw new Error( - `The getParentRoute of route ${describe(route)} is not the route it is nested under in the route tree`, - ) - } - const parentPlace = place(parent) - let routePath: string | undefined = options.path - if (routePath && routePath !== '/') { - routePath = trimPathLeft(routePath) - } - const customId = options.id || routePath - const result = { - id: cleanPath( - `${parentPlace.id === '__root__' ? '' : parentPlace.id}/${customId ?? ''}`, - ), - url: - routePath === undefined - ? parentPlace.url - : cleanPath(`${parentPlace.url}/${routePath}`), + const treeDir = path.dirname(await realpath(treeFile)) + const fileOf = (routeId: string) => { + const route = router.routesById[routeId] + if (!route) { + throw new Error(`The router has no route ${routeId}`) + } + for (const [file, nodes] of runner.evaluatedModules.fileToModulesMap) { + if ([...nodes].some((node) => node.exports?.Route === route)) { + return path.relative(treeDir, file).replaceAll(path.sep, '/') + } + } + return null } - placed.set(route, result) - return result - } - - const routes: Array = [] - for (const route of [routeTree, ...nested]) { - const { id, url } = place(route) - if (routes.some((other) => other.id === id)) { - throw new Error(`Two routes of the route tree have the id ${id}`) + const renderVue = async (render: () => unknown) => { + if (framework !== 'vue') { + throw new Error(`No ${framework} server renderer in this package`) + } + // (the Vue the app's modules import: Node loads packages) + const { createSSRApp } = await import('vue') + const { renderToString } = await import('vue/server-renderer') + return renderToString(createSSRApp({ render })) } - routes.push({ - id, - file: route.file, - options: route.options, - lazyFn: route.lazyFn, - url, + return use({ + router, + routerPackage, + framework, + fileOf, + import: (file) => runner.import(file), + render: async (component: any) => { + await component?.preload?.() + const { h } = await import('vue') + return renderVue(() => h(component)) + }, + renderAt: async (url) => { + const pageRouter = routerPackage.createRouter({ + routeTree, + history: routerPackage.createMemoryHistory({ initialEntries: [url] }), + }) + await pageRouter.load() + const { h } = await import('vue') + return renderVue(() => + h(routerPackage.RouterProvider, { router: pageRouter }), + ) + }, }) - } - - /** The file (relative to the tree) a lazy importer of the tree loads. */ - const lazyFile = async (importer: () => unknown) => { - const files: Array = [] - const previous = load - load = (file) => { - files.push(file) - return recordingModule(file) - } - try { - await importer() - } finally { - load = previous - } - if (files.length !== 1) { - throw new Error( - `A lazy importer of the route tree loads ${files.length} modules`, - ) - } - return files[0]! - } - 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__`. + * The routes of the router built from the generated route tree at + * `treeFile`: route id (`__root__` for the root route) to the file whose + * `Route` the route is, relative to the tree (null for a route the tree + * creates itself, such as a route with only a lazy route file). */ -export async function routesOf( +export 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' && - typeof value.importer === 'function' - ) { - 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 -} - -/** - * 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])) + return withRouteTree(treeFile, ({ router, fileOf }) => + Object.fromEntries( + Object.keys(router.routesById).map((id) => [id, fileOf(id)]), + ), + ) } /** - * Evaluates the generated route tree at `treeFile` (see `evaluateRouteTree`) - * and returns the `path` option each route gets, by route id (the root route - * as `__root__`); undefined for a route without one. Router-core treats a - * route with a `path`, even `/`, as a page the URL can match on its own, and - * a route without one (a pathless layout) as a wrapper of its children. + * The full path (the URL path it matches, with its parents') of each route + * of the router built from the generated route tree at `treeFile`, by route + * id. */ -export async function routePathsOf( - treeFile: string, -): Promise> { - const { routes } = await evaluateRouteTree(treeFile) - return Object.fromEntries(routes.map(({ id, options }) => [id, options.path])) +export function urlsOf(treeFile: string): Promise> { + return withRouteTree(treeFile, ({ router }) => + Object.fromEntries( + Object.values(router.routesById).map((route) => [ + route.id, + route.fullPath, + ]), + ), + ) } /** - * Evaluates the generated route tree at `treeFile` (see `evaluateRouteTree`) - * and loads the lazy route of route `routeId` the way the router does - * (`route.lazy(fn)`: the route's options are those of the lazy route `fn` - * resolves to), each module the route tree imports dynamically being - * `importModule(file)` (file relative to the tree). Returns the value `fn` - * resolves to, or undefined when the route has no lazy route. + * The ids of the routes the router built from the generated route tree at + * `treeFile` matches for `pathname`, the root route first. */ -export async function loadLazyRoute( +export function matchesOf( treeFile: string, - routeId: string, - importModule: (file: string) => Record, -): Promise { - const { routes } = await evaluateRouteTree(treeFile, importModule) - const route = routes.find(({ id }) => id === routeId) - if (!route) { - throw new Error(`No route ${routeId} in the route tree`) - } - return route.lazyFn?.() + pathname: string, +): Promise> { + return withRouteTree(treeFile, ({ router }) => + router.matchRoutes(pathname).map((match) => match.routeId), + ) } diff --git a/packages/router-generator/tests/template.test.ts b/packages/router-generator/tests/template.test.ts index 7f2883dfd03..5a34c936f1b 100644 --- a/packages/router-generator/tests/template.test.ts +++ b/packages/router-generator/tests/template.test.ts @@ -111,13 +111,16 @@ describe.each(['react', 'solid', 'vue'] as const)( expect([route.id, route.createdWith]).toEqual([id, createdWith]) expect(route.text.length + route.types.length).toBeGreaterThan(0) } - expect(await routesOf(generatedRouteTree)).toEqual({ - __root__: 'routes/__root.tsx', - '/posts': 'routes/posts.tsx', - '/about': null, - '/{-$locale}/contact': 'routes/{-$locale}/contact.tsx', - '/{-$locale}/help': null, - }) + // (this package has no Solid router: Solid route trees do not load) + if (target !== 'solid') { + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/posts': 'routes/posts.tsx', + '/about': null, + '/{-$locale}/contact': 'routes/{-$locale}/contact.tsx', + '/{-$locale}/help': null, + }) + } }) // The docs (file-based-routing.md, `quoteStyle` and `semicolons`): "when From a7b30d362e666131841e86732bb0e819863a7bf9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:51:01 +0200 Subject: [PATCH 0424/1425] test(router-generator): empty-prefix physical mounts merge, real conflicts fail (GEN-30) "a route file that resolves to an empty path fails the generator" pinned an error for physical('', 'merged') with merged/route.tsx as if any empty-prefix mount were a conflict. J1.7 (V0.3): only genuinely conflicting routes require the error. - New: the docs' "Merging Physical Routes at Current Level" example, with physical('features') and physical('', 'features'): /about, / and /contact through the real router. - New: a virtual /about next to features/about.tsx merged at the same level fails the generator with an error naming both files (the docs' note). - The root-layout case (merged/route.tsx) moves, unchanged, to outside-compiler-corpus/virtual-routes.test.ts as a characterisation outside acceptance. Co-Authored-By: Claude Opus 5.5 --- .../virtual-routes.test.ts | 35 +++++++++++++ .../tests/route-paths.test.ts | 50 +++++++++++++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 packages/router-generator/tests/outside-compiler-corpus/virtual-routes.test.ts diff --git a/packages/router-generator/tests/outside-compiler-corpus/virtual-routes.test.ts b/packages/router-generator/tests/outside-compiler-corpus/virtual-routes.test.ts new file mode 100644 index 00000000000..021fe7dc873 --- /dev/null +++ b/packages/router-generator/tests/outside-compiler-corpus/virtual-routes.test.ts @@ -0,0 +1,35 @@ +/** + * Not part of the compiler test corpus (out of scope: characterisation of + * virtual route configs the docs do not describe; the spec, J1.7, leaves + * them open). + */ +import path from 'node:path' +import { expect, test } from 'vitest' +import { + physical, + rootRoute as virtualRootRoute, +} from '@tanstack/virtual-file-routes' +import { createApp, fileRoute, generate, rootRoute } from '../app' + +// A layout route file at the root of a physical directory merged at the +// current level would be a second route at the root route's path: today the +// generator rejects it with an error naming the file. +test('a layout route file at the root of a directory merged at the root level fails the generator with an error naming it', async () => { + const root = await createApp({ + 'src/routes/__root.tsx': rootRoute(), + 'src/routes/merged/route.tsx': fileRoute('/wrong'), + }) + + const error = await generate(root, { + virtualRouteConfig: virtualRootRoute('__root.tsx', [ + physical('', 'merged'), + ]), + }).then( + () => undefined, + (e: unknown) => e as Error, + ) + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain( + path.join(root, 'src/routes/merged/route.tsx'), + ) +}) diff --git a/packages/router-generator/tests/route-paths.test.ts b/packages/router-generator/tests/route-paths.test.ts index 0a6c228479d..66f7d08f4c0 100644 --- a/packages/router-generator/tests/route-paths.test.ts +++ b/packages/router-generator/tests/route-paths.test.ts @@ -353,22 +353,64 @@ describe('conflicting routes', () => { ) }) - test('a route file that resolves to an empty path fails the generator with an error naming it', async () => { - const root = await appWithRoutes(['merged/route.tsx']) + // The docs' example of a physical directory merged at the current level + // (virtual-file-routes.md, "Merging Physical Routes at Current Level"): an + // empty path prefix is no conflict by itself. + test.each([ + { name: 'a directory only', mount: physical('features') }, + { name: 'an empty path prefix', mount: physical('', 'features') }, + ])( + 'a physical directory mounted with $name merges its routes at the current level', + async ({ mount }) => { + const root = await appWithRoutes([ + 'about.tsx', + 'features/index.tsx', + 'features/contact.tsx', + ]) + const { generatedRouteTree } = await generate(root, { + virtualRouteConfig: virtualRootRoute('__root.tsx', [ + virtualRoute('/about', 'about.tsx'), + mount, + ]), + }) + + 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/about.tsx': '/about', + 'routes/features/index.tsx': '/', + 'routes/features/contact.tsx': '/contact', + }) + }, + ) + + test('a route of a directory merged at the current level with the path of a virtual route fails the generator with an error naming both files', async () => { + const root = await appWithRoutes(['about.tsx', 'features/about.tsx']) const error = await generate(root, { virtualRouteConfig: virtualRootRoute('__root.tsx', [ - physical('', 'merged'), + virtualRoute('/about', 'about.tsx'), + physical('', 'features'), ]), }).then( () => undefined, (e: unknown) => e as Error, ) expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain(path.join(root, 'src/routes/about.tsx')) expect(error?.message).toContain( - path.join(root, 'src/routes/merged/route.tsx'), + path.join(root, 'src/routes/features/about.tsx'), ) }) + + // (A layout route file at the root of a directory merged at the current + // level, `physical('', 'merged')` with `merged/route.tsx`: outside the + // compiler test corpus, tests/outside-compiler-corpus/virtual-routes.test.ts.) }) describe('route names', () => { From 23372d1a3713fb38520349f08ecd69bd1c6bb03e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:51:18 +0200 Subject: [PATCH 0425/1425] test(router-generator): share modules across test files in a worker Loading generated trees through Vite and the real router packages costs ~150-250 ms of imports per test file under isolation (10 files load trees), which took the fast tier from 9.9 s to 12.3 s on 1 worker. Test files do not depend on module state: each test uses its own temporary app, restores its spies/env stubs, and the tree helpers create a fresh Vite server (fresh transform and module caches) per load. Runs with --sequence.shuffle (seeds 1 and 7, 1 and 2 workers) pass with identical counts. 1 worker: 9.9 s (baseline) -> 5.2 s; 4 workers: 3.1 s -> 2.5 s. Co-Authored-By: Claude Opus 5.5 --- packages/router-generator/vite.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/router-generator/vite.config.ts b/packages/router-generator/vite.config.ts index 5e305866b62..5c6fabc31c5 100644 --- a/packages/router-generator/vite.config.ts +++ b/packages/router-generator/vite.config.ts @@ -8,6 +8,10 @@ const config = defineConfig({ dir: './tests', watch: false, typecheck: { enabled: true }, + // Test files share a worker's modules (the router packages, Vite): every + // test works in its own temporary app and restores its spies and env + // stubs, and the route tree helpers start a fresh Vite server per load. + isolate: false, }, }) From cd267d30736ba1fc173161f36a22e8d54bc046cb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:52:41 +0200 Subject: [PATCH 0426/1425] test(compiler-test-utils): real packages, React JSX and load observers in the module linker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX-PLAN A3a/A4 (W2-b). The linker no longer renders JSX with a string stand-in: JSX compiles with React's automatic runtime (Vite's Oxc transform, what @vitejs/plugin-react configures) and graphs link real installed packages (`packages`), so a test renders with the real React the modules use (`importPackage`). New load observers: `requested` (every module a graph's modules requested), `fork()` (fresh instances, same modules and stubs) and `has(id)`. - `render.ts`: real framework transforms (Vite's React transform, vite-plugin-solid, @vitejs/plugin-vue-jsx, driven as Vite drives them) and server renderers (react-dom/server, solid-js/web, vue/server-renderer), all from the consumer's own packages. - `lazy-probe.ts`: laziness, loaded modules and groups observed by the modules a trigger requests, in forks, never by recognising a lazy helper (SPEC J2.6, §5.3, §5.8). - The linker control GEN-39 uses a plain exported object instead of JSX. - react-start-rsc keeps its JSX-as-text through the deprecated `runtime` option until FIX-PLAN A3c (W2-j) replaces that harness. Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/package.json | 4 +- .../compiler-test-utils/src/lazy-probe.ts | 115 +++++++ .../compiler-test-utils/src/module-linker.ts | 323 +++++++++++++----- packages/compiler-test-utils/src/render.ts | 151 ++++++++ .../tests/lazy-probe.test.ts | 80 +++++ .../tests/module-linker.test.ts | 164 ++++++++- .../tests/rscCssTransform.test.tsx | 19 ++ 7 files changed, 768 insertions(+), 88 deletions(-) create mode 100644 packages/compiler-test-utils/src/lazy-probe.ts create mode 100644 packages/compiler-test-utils/src/render.ts create mode 100644 packages/compiler-test-utils/tests/lazy-probe.test.ts diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index 3f07a1f5baf..c35ad4fecb0 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -2,14 +2,16 @@ "name": "@tanstack/compiler-test-utils", "version": "0.0.0", "private": true, - "description": "Test infrastructure shared by the compiler test suites: an ES module linker for compiled modules, a real-build harness, dev-server harnesses (Vite, Rsbuild), a real browser page and a validator of generated modules", + "description": "Test infrastructure shared by the compiler test suites: an ES module linker for compiled modules, real framework rendering of its graphs, a real-build harness, dev-server harnesses (Vite, Rsbuild), a real browser page and a validator of generated modules", "type": "module", "exports": { "./browser": "./src/browser.ts", "./budget-reporter": "./src/budget-reporter.ts", "./build-harness": "./src/build-harness.ts", "./dev-server": "./src/dev-server.ts", + "./lazy-probe": "./src/lazy-probe.ts", "./module-linker": "./src/module-linker.ts", + "./render": "./src/render.ts", "./rsbuild-dev-server": "./src/rsbuild-dev-server.ts", "./run-node": "./src/run-node.ts", "./validate-module": "./src/validate-module.ts", diff --git a/packages/compiler-test-utils/src/lazy-probe.ts b/packages/compiler-test-utils/src/lazy-probe.ts new file mode 100644 index 00000000000..263531a83a4 --- /dev/null +++ b/packages/compiler-test-utils/src/lazy-probe.ts @@ -0,0 +1,115 @@ +/** + * Observes lazy loading in a module graph (`module-linker.ts`) by the modules + * actually requested, never by recognising the helper that builds a lazy + * value (SPEC J2.6, §5.3, §5.8): `lazyRouteComponent`, `lazyFn`, + * `React.lazy`, an equivalent wrapper or an intermediate module all pass. + * + * API: `createLazyProbe(graph, entry, triggers, { owned })` → + * - `isLazy(name)`: starting to load `name` requests a module that did not + * load with `entry` (probed: nothing loads). + * - `loadedBy(name)`: ids of the modules loading `name` evaluates, in + * evaluation order (one or more: facades and support modules are allowed). + * - `groups(names)`: the lazy `names` grouped by what loads together: + * loading one makes every other of its group available (starting it + * requests nothing that has not loaded), while a name of another group + * stays unrequested. Each group sorted, groups sorted. A group that pulls + * another group's name in without the reverse shows as two overlapping + * groups. + * + * `triggers[name](exports)` starts loading `name` the way its runtime does, + * given the namespace of `entry` (a router's `component.preload()`, a loader + * call, a Hydrate boundary's `prefetch`…). It runs in forks of the graph + * (fresh module instances, the same stubs), so the graph a test inspects + * loads nothing it did not ask for; a trigger of a value that is not lazy + * may run user code there. Loading a name imports the modules its trigger + * requests (without awaiting the trigger itself). `owned(id)` tells the + * modules that count (default: modules of the graph, not stubs or + * packages). + */ +import type { ModuleGraph } from './module-linker' + +export interface LazyProbe { + isLazy: (name: string) => Promise + loadedBy: (name: string) => Promise> + groups: (names: Array) => Promise>> +} + +export function createLazyProbe( + graph: ModuleGraph, + entry: string, + triggers: Record) => unknown>, + options: { owned?: (id: string) => boolean } = {}, +): LazyProbe { + const fork = async () => { + const forked = graph.fork() + return { graph: forked, exports: await forked.import(entry) } + } + type Fork = Awaited> + /** The owned modules whose import starting to load `name` starts. */ + const probe = ({ graph: forked, exports }: Fork, name: string) => + forked + .probeDynamicImports(() => { + try { + return triggers[name]?.(exports) + } catch { + // A value that is not lazy ran (e.g. a loader without arguments) + return undefined + } + }) + .filter(options.owned ?? forked.has) + /** Loads `name` in a fork: the modules its trigger requests. */ + const load = async (forked: Fork, name: string) => { + for (const id of probe(forked, name)) { + await forked.graph.import(id) + } + } + + // One fork answers every `isLazy`, each name probed once: a lazy value + // may still be settling its probed (rejected) import when probed again. + let probeFork: Promise | undefined + const laziness = new Map>() + const isLazy = (name: string) => { + let lazy = laziness.get(name) + if (!lazy) { + probeFork ??= fork() + lazy = probeFork.then((forked) => probe(forked, name).length > 0) + laziness.set(name, lazy) + } + return lazy + } + + return { + isLazy, + async loadedBy(name) { + const forked = await fork() + const before = forked.graph.evaluated.length + await load(forked, name) + return forked.graph.evaluated.slice(before) + }, + async groups(names) { + const lazy: Array = [] + for (const name of names) { + if (await isLazy(name)) { + lazy.push(name) + } + } + const groups = new Map>() + for (const name of lazy) { + const forked = await fork() + await load(forked, name) + const evaluated = new Set(forked.graph.evaluated) + const group = lazy + .filter( + (other) => + other === name || + probe(forked, other).every((id) => evaluated.has(id)), + ) + .sort() + groups.set(group.join('+'), group) + } + return [...groups.values()].sort((a, b) => + a.join('+').localeCompare(b.join('+')), + ) + }, + } +} diff --git a/packages/compiler-test-utils/src/module-linker.ts b/packages/compiler-test-utils/src/module-linker.ts index f42defb3341..e44844d284e 100644 --- a/packages/compiler-test-utils/src/module-linker.ts +++ b/packages/compiler-test-utils/src/module-linker.ts @@ -8,12 +8,27 @@ * Module sources live in memory (a module graph) and are served through * in-thread module hooks (`module.registerHooks`) under URLs that are unique * per graph, so every graph evaluates fresh module instances. TypeScript is - * erased and JSX compiles to calls of `__jsx`/`__Fragment` (`jsxPragma`), - * by default a text renderer: intrinsic elements become tags, components are - * called. + * erased and JSX compiles with React's automatic runtime (`react/jsx-runtime`, + * as `@vitejs/plugin-react` compiles it); there is no JSX stand-in. A graph + * renders with the real framework when it links real packages (`packages`). + * + * API (see each member): + * - `createModuleGraph(options)`: `modules`, `stubs`, `resolve`, `load`, + * `packages` (real packages, e.g. `react`, `react-dom/server`, a router), + * `fallbackStub`, `transform`, `scope`, `importMeta`. + * - `graph.import(id)`, `graph.add(id, code)`, `graph.has(id)`. + * - Load observers: `graph.evaluated` (graph modules, in evaluation order), + * `graph.requested` (every module the graph's modules requested: graph + * modules, stubs, packages) and `graph.probeDynamicImports(run)` (the + * dynamic imports `run` starts, without loading them). + * - `graph.importPackage(specifier)`: the instance of a real package the + * graph's modules get (to render what they export with the same React). + * - `graph.fork()`: the same modules and options, fresh module instances (to + * observe loading without touching the graph a test inspects). */ import { createRequire, registerHooks } from 'node:module' import path from 'node:path' +import { pathToFileURL } from 'node:url' import { parseSync, transformWithOxc } from 'vite' /** @@ -28,7 +43,7 @@ export interface ModuleGraphOptions { modules?: Record /** * Modules by the specifier modules import them with. A stub wins over a - * module of the graph with the same specifier. + * module of the graph and over a package with the same specifier. */ stubs?: Record /** @@ -46,12 +61,21 @@ export interface ModuleGraphOptions { id: string, ) => string | null | undefined | Promise /** - * Stubs a specifier that is neither a stub nor a module of the graph, - * given the names its importer imports from it. Without it, such an import - * fails to link, or a dynamic one fails when it runs. One stub serves - * every importer of the specifier, like one module of a bundle: it is - * called again only for names earlier importers did not import, and the - * values it gave keep their identity. + * Directory real packages resolve from. A bare specifier that is neither a + * stub nor a module of the graph resolves with Node's own resolver (package + * `exports`, conditions `node` and `import`) as if a file of this directory + * imported it, and loads the real package: one instance per test process, + * shared by every graph, like a dependency every chunk of an app shares. + * A specifier that does not resolve there goes to `fallbackStub`. + */ + packages?: string + /** + * Stubs a specifier that is neither a stub, a module of the graph nor a + * package (`packages`), given the names its importer imports from it. + * Without it, such an import fails to link, or a dynamic one fails when it + * runs. One stub serves every importer of the specifier, like one module + * of a bundle: it is called again only for names earlier importers did not + * import, and the values it gave keep their identity. */ fallbackStub?: (specifier: string, names: Set) => ModuleStub /** @@ -66,11 +90,18 @@ export interface ModuleGraphOptions { * TypeScript like a project without it: imports nothing reads go too. */ verbatimModuleSyntax?: boolean + /** + * Package the default transform's automatic JSX runtime imports + * (`/jsx-runtime`); default `react`. + */ + jsxImportSource?: string /** Compiles a module to JavaScript; by default TS and JSX are erased. */ transform?: (code: string, id: string) => string | Promise /** - * Code run at the top of each module that defines its JSX factories - * (`__jsx` and `__Fragment`), instead of the default text renderer. + * Deprecated JSX stand-in for suites not yet rendering with a real + * framework: code run at the top of each module that defines `__jsx` and + * `__Fragment` (`jsxPragma`), the classic factories the default transform + * then compiles JSX to. Prefer real packages (`packages`). */ runtime?: string /** Bindings declared at the top of each module (e.g. `$RefreshReg$`). */ @@ -87,18 +118,44 @@ export interface ModuleGraph { import: (id: string) => Promise> /** Adds a module to the graph. */ add: (id: string, code: string) => void + /** + * Whether the graph has a module with this id (given, added or loaded so + * far; not a stub or a package). + */ + has: (id: string) => boolean /** Ids of the graph's modules, in the order they started evaluating. */ evaluated: Array + /** + * Every module the graph's modules requested so far, once each, in the + * order Node resolved them: ids of modules of the graph, specifiers of + * stubs and of packages. A static import is requested when its importer + * links (before any of them evaluates), a dynamic one when it runs. What + * `importPackage` and `probeDynamicImports` start is not requested. + */ + requested: Array /** * Runs `run` and returns the ids of the graph's modules (or the - * specifiers of stubs) whose dynamic `import()` it starts synchronously, - * without loading or evaluating them: those imports reject, and a promise - * `run` returns is settled silently. Later imports load as usual. + * specifiers of stubs and packages) whose dynamic `import()` it starts + * synchronously, without loading or evaluating them: those imports + * reject, and a promise `run` returns is settled silently. Later imports + * load as usual. */ probeDynamicImports: (run: () => unknown) => Array + /** + * The namespace of a real package (`packages`) as the graph's modules get + * it: the same instance (e.g. the React the modules' JSX creates elements + * of). + */ + importPackage: (specifier: string) => Promise> + /** + * A new graph with the same options and the same modules (those added or + * loaded since included), whose modules evaluate fresh instances. Stubs + * and packages are the same values. Compiled code is reused. + */ + fork: () => ModuleGraph } -/** The JSX factory names compiled modules call. */ +/** The classic JSX factory names of the deprecated `runtime` stand-in. */ export const jsxPragma = { pragma: '__jsx', pragmaFrag: '__Fragment' } /** Language of a module id for Oxc, from its file extension. */ @@ -118,8 +175,17 @@ interface ServedModule { source: string /** URL each specifier the module imports resolves to. */ imports: Map + /** + * Specifiers the module imports from real packages, with the names it + * imports from each. + */ + packages?: Map> /** Why the module could not be prepared; loading it throws this error. */ failure?: unknown + /** A module of the harness (not the app's): its requests are not recorded. */ + internal?: boolean + /** The specifier a stub serves. */ + specifier?: string } interface Graph { @@ -130,83 +196,79 @@ interface Graph { /** Exports of each served object stub, by URL. */ stubs: Map> evaluated: Array + requested: Array /** Targets of the dynamic imports started while probing, else undefined. */ probed?: Array + /** URL a file of `options.packages` has, to resolve packages from. */ + packagesParent?: string + /** URL of the fallback stub of a specifier, if `fallbackStub` gives one. */ + fallbackUrl: (specifier: string, names: Set) => string | undefined } interface Registry { graphs: Map nextGraph: number - jsx: Record } const scheme = 'compiler-test-module:' const registryKey = Symbol.for('tanstack.compiler-test-utils.module-linker') const registryExpression = `globalThis[Symbol.for(${JSON.stringify(registryKey.description)})]` -const runtimeUrl = `${scheme}//runtime/jsx` - -/** Renders JSX to text: intrinsic elements become tags, components are called. */ -function createJsxRuntime() { - const Fragment = Symbol('Fragment') - const h = (type: unknown, props: unknown, ...children: Array) => { - const text = children - .flat(Infinity) - .filter((child) => child != null && child !== false && child !== true) - .join('') - if (type === Fragment) { - return text - } - if (typeof type === 'function') { - return type({ ...(props as object), children: text }) - } - if (typeof type !== 'string') { - throw new Error('cannot render ' + String(type)) - } - return '<' + type + '>' + text + '' + +/** Records a module a graph's module requested (see `requested`). */ +function recordRequest(graph: Graph, requested: string) { + if (!graph.requested.includes(requested)) { + graph.requested.push(requested) } - return { h, Fragment } } function getRegistry(): Registry { const global = globalThis as unknown as Record if (!global[registryKey]) { - global[registryKey] = { - graphs: new Map(), - nextGraph: 0, - jsx: createJsxRuntime(), - } + global[registryKey] = { graphs: new Map(), nextGraph: 0 } registerHooks({ resolve(specifier, context, nextResolve) { - if (specifier === runtimeUrl || lookup(specifier)) { + if (lookup(specifier)) { return { url: specifier, shortCircuit: true } } const parent = context.parentURL ? lookup(context.parentURL) : undefined - if (parent) { - const url = parent.module.imports.get(specifier) - if (url === undefined) { - throw new Error( - `No stub for import ${specifier} (imported by ${parent.id})`, - ) - } - // Node resolves a dynamic import synchronously when it starts: - // while a graph probes, record its target instead of loading it. - if (parent.graph.probed) { - parent.graph.probed.push(lookup(url)?.id ?? specifier) - throw new ProbedImport() - } + if (!parent) { + return nextResolve(specifier, context) + } + const { graph } = parent + const url = parent.module.imports.get(specifier) + const names = parent.module.packages?.get(specifier) + if (url === undefined && names === undefined) { + throw new Error( + `No stub for import ${specifier} (imported by ${parent.id})`, + ) + } + const target = url === undefined ? specifier : targetName(url) + // Node resolves a dynamic import synchronously when it starts: + // while a graph probes, record its target instead of loading it. + if (graph.probed) { + graph.probed.push(target) + throw new ProbedImport() + } + if (!parent.module.internal) { + recordRequest(graph, target) + } + if (url !== undefined) { return { url, shortCircuit: true } } - return nextResolve(specifier, context) - }, - load(url, context, nextLoad) { - if (url === runtimeUrl) { - return { - format: 'module', - source: `const { h, Fragment } = ${registryExpression}.jsx -export { h as ${jsxPragma.pragma}, Fragment as ${jsxPragma.pragmaFrag} }`, - shortCircuit: true, + try { + return nextResolve(specifier, { + ...context, + parentURL: graph.packagesParent, + }) + } catch (error) { + const fallback = graph.fallbackUrl(specifier, names!) + if (fallback === undefined) { + throw error } + return { url: fallback, shortCircuit: true } } + }, + load(url, context, nextLoad) { const found = lookup(url) if (!found) { return nextLoad(url, context) @@ -225,7 +287,7 @@ export { h as ${jsxPragma.pragma}, Fragment as ${jsxPragma.pragmaFrag} }`, return global[registryKey] } -type UrlKind = 'module' | 'stub' | 'failed' +type UrlKind = 'module' | 'stub' | 'failed' | 'package' function graphUrl(graph: Graph, kind: UrlKind, id: string) { return `${scheme}//g${graph.id}/${kind}/${encodeURIComponent(id)}` @@ -236,7 +298,25 @@ function lookup(url: string) { const match = /^compiler-test-module:\/\/g(\d+)\/(\w+)\/(.*)$/.exec(url) const graph = match ? getRegistry().graphs.get(Number(match[1])) : undefined const module = graph?.served.get(url) - return module && { module, graph: graph!, id: decodeURIComponent(match![3]!) } + return ( + module && { + module, + graph: graph!, + id: decodeURIComponent(match![3]!), + } + ) +} + +/** + * What `requested` and probes name the target of a URL by: a module's id, a + * stub's specifier. + */ +function targetName(url: string) { + const found = lookup(url) + if (!found) { + return url + } + return found.module.specifier ?? found.id } /** Rejects a dynamic import started while its graph probes imports. */ @@ -304,17 +384,27 @@ function moduleRequests(javascript: string) { /** * Erases TypeScript the way Vite does (see `verbatimModuleSyntax`) and - * compiles JSX to calls of `jsxPragma`. + * compiles JSX with React's automatic runtime from `jsxImportSource`, as + * `@vitejs/plugin-react` does, or to calls of `jsxPragma` for the deprecated + * `runtime` stand-in. */ async function defaultTransform( code: string, id: string, - verbatimModuleSyntax: boolean, + options: ModuleGraphOptions, ) { const result = await transformWithOxc(code, 'module', { lang: moduleLang(id), - jsx: { runtime: 'classic', ...jsxPragma }, - typescript: { onlyRemoveTypeImports: verbatimModuleSyntax }, + jsx: + options.runtime === undefined + ? { + runtime: 'automatic', + importSource: options.jsxImportSource ?? 'react', + } + : { runtime: 'classic', ...jsxPragma }, + typescript: { + onlyRemoveTypeImports: options.verbatimModuleSyntax ?? true, + }, }) return result.code } @@ -339,6 +429,11 @@ function candidateIds(specifier: string, importer: string) { return extensions.map((extension) => `${absolute}${extension}${query}`) } +/** Whether a specifier names a package (not a path, not a URL). */ +function isBareSpecifier(specifier: string) { + return !/^(?:[./]|[a-zA-Z][\w+.-]*:)/.test(specifier) +} + // Node's own `import()`, which runs the module hooks (Vitest's module runner // handles the `import()` calls of test files itself). const nativeImport = createRequire(import.meta.url)('./native-import.cjs') as ( @@ -346,26 +441,55 @@ const nativeImport = createRequire(import.meta.url)('./native-import.cjs') as ( ) => Promise> /** - * Creates a graph of in-memory modules linked to each other and to stubs. A - * module imported twice from one graph is evaluated once, like in a bundle. + * Creates a graph of in-memory modules linked to each other, to stubs and to + * real packages (`packages`). A module imported twice from one graph is + * evaluated once, like in a bundle. */ export function createModuleGraph( options: ModuleGraphOptions = {}, +): ModuleGraph { + return createGraph(options, new Map()) +} + +/** + * `createModuleGraph`, with the compiled code of each module source shared + * with the graph's forks (by id, then by source). + */ +function createGraph( + options: ModuleGraphOptions, + compiled: Map }>, ): ModuleGraph { const registry = getRegistry() + const sources = new Map(Object.entries(options.modules ?? {})) const graph: Graph = { id: registry.nextGraph++, options, served: new Map(), stubs: new Map(), evaluated: [], + requested: [], + packagesParent: + options.packages === undefined + ? undefined + : pathToFileURL(path.join(options.packages, 'importer.js')).href, + fallbackUrl: (specifier, names) => { + const fallback = fallbackFor(specifier, names) + return fallback === undefined ? undefined : stubUrl(specifier, fallback) + }, } registry.graphs.set(graph.id, graph) - const sources = new Map(Object.entries(options.modules ?? {})) const transform = options.transform ?? - ((code: string, id: string) => - defaultTransform(code, id, options.verbatimModuleSyntax ?? true)) + ((code: string, id: string) => defaultTransform(code, id, options)) + const compile = (code: string, id: string) => { + const known = compiled.get(id) + if (known && known.code === code) { + return known.javascript + } + const javascript = Promise.resolve(transform(code, id)) + compiled.set(id, { code, javascript }) + return javascript + } const graphExpression = `${registryExpression}.graphs.get(${graph.id})` const preparing = new Map< string, @@ -411,6 +535,7 @@ export function createModuleGraph( format: specifier.endsWith('.json') ? 'json' : 'module', source: stub, imports: new Map(), + specifier, }) } return url @@ -444,6 +569,7 @@ export function createModuleGraph( format: 'module', source: lines.join('\n'), imports: new Map(), + specifier, }) } return url @@ -494,11 +620,6 @@ export function createModuleGraph( const prologue = (id: string) => { const scope = Object.keys(options.scope?.(id) ?? {}) return [ - ...(options.runtime === undefined - ? [ - `import { ${jsxPragma.pragma}, ${jsxPragma.pragmaFrag} } from ${JSON.stringify(runtimeUrl)};`, - ] - : []), ...(scope.length ? [ `const { ${scope.join(', ')} } = ${graphExpression}.options.scope(${JSON.stringify(id)});`, @@ -525,7 +646,7 @@ export function createModuleGraph( if (code === undefined) { throw new Error(`No module ${id} in the graph`) } - const javascript = await transform(code, id) + const javascript = await compile(code, id) // A hashbang must stay first: the prologue goes after its line const [, hashbang = '', body = javascript] = /^(#![^\n]*\n)([\s\S]*)$/.exec(javascript) ?? [] @@ -535,12 +656,16 @@ export function createModuleGraph( // stay those of the module (without `runtime`). source: `${hashbang}${prologue(id)}${options.runtime ?? ''}${body}`, imports: new Map(), + packages: new Map(), } graph.served.set(graphUrl(graph, 'module', id), module) const requests = moduleRequests(javascript) const dependencies: Array<{ id: string; dynamic: boolean }> = [] const resolve = async (specifier: string, dynamic: boolean) => { - if (module.imports.has(specifier)) { + if ( + module.imports.has(specifier) || + module.packages!.has(specifier) + ) { return } const names = requests.names.get(specifier) ?? new Set() @@ -571,6 +696,12 @@ export function createModuleGraph( dependencies.push({ id: target, dynamic }) return } + // Resolved by Node when the module links (see the resolve hook), + // falling back to `fallbackStub` then. + if (graph.packagesParent && isBareSpecifier(specifier)) { + module.packages!.set(specifier, names) + return + } const fallback = fallbackFor(specifier, names) if (fallback !== undefined) { module.imports.set(specifier, stubUrl(specifier, fallback)) @@ -640,6 +771,7 @@ export function createModuleGraph( await prepare(id) return nativeImport(graphUrl(graph, 'module', id)) }, + has: (id) => sources.has(id), add(id, code) { if (sources.has(id) && sources.get(id) !== code) { throw new Error(`Module ${id} is already in the graph`) @@ -647,6 +779,7 @@ export function createModuleGraph( sources.set(id, code) }, evaluated: graph.evaluated, + requested: graph.requested, probeDynamicImports(run) { const probed: Array = [] graph.probed = probed @@ -661,5 +794,27 @@ export function createModuleGraph( } return probed }, + async importPackage(specifier) { + if (!graph.packagesParent) { + throw new Error('importPackage needs the `packages` option') + } + const url = graphUrl(graph, 'package', specifier) + if (!graph.served.has(url)) { + graph.served.set(url, { + format: 'module', + source: `import * as namespace from ${JSON.stringify(specifier)}\nexport { namespace }`, + imports: new Map(), + packages: new Map([[specifier, new Set()]]), + internal: true, + }) + } + return (await nativeImport(url)).namespace + }, + fork() { + return createGraph( + { ...options, modules: Object.fromEntries(sources) }, + compiled, + ) + }, } } diff --git a/packages/compiler-test-utils/src/render.ts b/packages/compiler-test-utils/src/render.ts new file mode 100644 index 00000000000..55d8268535b --- /dev/null +++ b/packages/compiler-test-utils/src/render.ts @@ -0,0 +1,151 @@ +/** + * Real framework rendering of module graphs (`module-linker.ts`): compile the + * graph's JSX with the framework's real transform and render what its + * modules export with the framework's real server renderer, all from the + * consumer's own installed packages (`packages` of the graph). No JSX or + * renderer stand-in. + * + * API: + * - `frameworkTransform(framework, packages)`: the `transform` option of a + * graph for `framework`. React: Vite's Oxc transform with React's automatic + * runtime (what `@vitejs/plugin-react` configures; the linker's default). + * Solid: `vite-plugin-solid`'s own transform (SSR output), Vue: + * `@vitejs/plugin-vue-jsx`'s own transform, each then TypeScript erased as + * Vite does after them. + * - `renderToHtml(graph, framework, component, props)`: server-renders a + * component of the graph (a route component, a `RouterProvider`) with + * `react-dom/server`, `solid-js/web` or `vue/server-renderer`, using the + * graph's own instances of the framework. React renders static markup + * (no hydration comments); Solid's SSR output carries its hydration keys. + * + * Mounting, effects and hydration need a browser: see `browser.ts`. + */ +import { transformWithOxc } from 'vite' +import { createModuleGraph, moduleLang } from './module-linker' +import type { ModuleGraph, ModuleGraphOptions } from './module-linker' + +/** A framework the router and Start support. */ +export type Framework = 'react' | 'solid' | 'vue' + +/** A graph that only imports packages, one per `packages` directory. */ +const packageImporters = new Map() + +/** A real package installed for the consumer (`packages`), natively loaded. */ +function importInstalled(specifier: string, packages: string) { + let importer = packageImporters.get(packages) + if (!importer) { + importer = createModuleGraph({ packages }) + packageImporters.set(packages, importer) + } + return importer.importPackage(specifier) +} + +/** A Vite plugin's hook given as a function or as `{ handler }`. */ +type Hook = ((...args: Array) => any) | { handler: (...args: any) => any } + +function handler(hook: Hook | undefined) { + return typeof hook === 'function' ? hook : hook?.handler +} + +/** A Vite plugin configured for a production build, as Vite configures it. */ +async function buildPlugin(plugin: Record) { + await handler(plugin.configResolved)?.call( + {}, + { + command: 'build', + mode: 'production', + isProduction: true, + root: process.cwd(), + build: { sourcemap: false }, + }, + ) + return (code: string, id: string, ssr: boolean): Promise => + Promise.resolve( + handler(plugin.transform)?.call({}, code, id, { ssr }), + ).then((result: string | { code: string } | null | undefined) => + result == null ? null : typeof result === 'string' ? result : result.code, + ) +} + +/** Erases TypeScript as Vite does after a framework plugin's transform. */ +async function eraseTypeScript(code: string, id: string) { + return ( + await transformWithOxc(code, 'module', { + lang: moduleLang(id), + typescript: { onlyRemoveTypeImports: true }, + }) + ).code +} + +/** + * The `transform` option of a module graph whose JSX is `framework`'s, + * compiled by the framework's real Vite plugin (packages installed in + * `packages`). `undefined` for React: the linker's default transform is + * Vite's own React JSX transform. + */ +export function frameworkTransform( + framework: Framework, + packages: string, +): ModuleGraphOptions['transform'] { + if (framework === 'react') { + return undefined + } + let plugin: + | Promise< + (code: string, id: string, ssr: boolean) => Promise + > + | undefined + const pluginTransform = () => { + plugin ??= (async () => { + if (framework === 'solid') { + const { default: solid } = await importInstalled( + 'vite-plugin-solid', + packages, + ) + return buildPlugin(solid({ ssr: true })) + } + const { default: vueJsx } = await importInstalled( + '@vitejs/plugin-vue-jsx', + packages, + ) + return buildPlugin(vueJsx()) + })() + return plugin + } + return async (code, id) => { + // The server output of each framework (rendered by `renderToHtml`) + const compiled = await (await pluginTransform())(code, id, true) + return eraseTypeScript(compiled ?? code, id) + } +} + +/** + * Server-renders `component` with `props` to HTML with the real renderer of + * `framework`, using the framework instance the graph's modules use (the + * graph links real packages: `packages`). A component that suspends without + * a boundary fails the render (React), as on a server that does not wait. + */ +export async function renderToHtml( + graph: ModuleGraph, + framework: Framework, + component: unknown, + props: Record = {}, +): Promise { + if (framework === 'react') { + const [{ createElement }, { renderToStaticMarkup }] = await Promise.all([ + graph.importPackage('react'), + graph.importPackage('react-dom/server'), + ]) + return renderToStaticMarkup(createElement(component, props)) + } + if (framework === 'solid') { + const { createComponent, renderToString } = + await graph.importPackage('solid-js/web') + return renderToString(() => createComponent(component, props)) + } + const [{ createSSRApp, h }, { renderToString }] = await Promise.all([ + graph.importPackage('vue'), + graph.importPackage('vue/server-renderer'), + ]) + return renderToString(createSSRApp({ render: () => h(component, props) })) +} diff --git a/packages/compiler-test-utils/tests/lazy-probe.test.ts b/packages/compiler-test-utils/tests/lazy-probe.test.ts new file mode 100644 index 00000000000..9aff5a908fc --- /dev/null +++ b/packages/compiler-test-utils/tests/lazy-probe.test.ts @@ -0,0 +1,80 @@ +/** + * Controls of the lazy-loading probe on fixed graphs: laziness, loaded + * modules and groups are observed by the modules requested, whatever builds + * the lazy value, and without loading anything in the graph a test + * inspects. + */ +import { describe, expect, it } from 'vitest' +import { createLazyProbe } from '../src/lazy-probe' +import { createModuleGraph } from '../src/module-linker' + +/** A graph whose modules import each other by id. */ +function graphOf(modules: Record, stubs = {}) { + return createModuleGraph({ + modules, + stubs, + resolve: (specifier) => (specifier in modules ? specifier : undefined), + }) +} + +/** Triggers starting to load each export of the entry: `load()` or `preload()`. */ +const triggers = Object.fromEntries( + ['a', 'b', 'c', 'eager'].map((name) => [ + name, + (exports: Record) => + exports[name]?.preload?.() ?? exports[name]?.load?.(), + ]), +) + +describe('createLazyProbe', () => { + it('tells lazy values from eager ones and the modules loading one evaluates', async () => { + const graph = graphOf( + { + '/entry.ts': `import { shared } from '/shared.ts' +export const eager = { load: () => shared } +// An equivalent wrapper of its own +export const a = { preload: () => import('/a.ts').then((m) => m.value) } +// An intermediate module loading the group's modules +export const b = { load: async () => (await import('/group.ts')).b } +export const c = { load: async () => (await import('/group.ts')).c }`, + '/shared.ts': `export const shared = 'shared'`, + '/a.ts': `import 'lib' +export const value = 'a'`, + '/group.ts': `export { b } from '/b.ts' +export { c } from '/c.ts'`, + '/b.ts': `export const b = 'b'`, + '/c.ts': `export const c = 'c'`, + }, + { lib: {} }, + ) + await graph.import('/entry.ts') + const probe = createLazyProbe(graph, '/entry.ts', triggers) + expect( + await Promise.all(['eager', 'a', 'b', 'c'].map(probe.isLazy)), + ).toEqual([false, true, true, true]) + // Stubs and packages are not the graph's modules. + expect(await probe.loadedBy('a')).toEqual(['/a.ts']) + expect((await probe.loadedBy('b')).sort()).toEqual([ + '/b.ts', + '/c.ts', + '/group.ts', + ]) + expect(await probe.groups(['eager', 'a', 'b', 'c'])).toEqual([ + ['a'], + ['b', 'c'], + ]) + // The graph itself loaded nothing more. + expect([...graph.evaluated]).toEqual(['/shared.ts', '/entry.ts']) + }) + + it('shows a group that loads another one with it', async () => { + const graph = graphOf({ + '/entry.ts': `export const a = { load: () => import('/a.ts') } +export const b = { load: () => import('/b.ts') }`, + '/a.ts': `import '/b.ts'`, + '/b.ts': `export {}`, + }) + const probe = createLazyProbe(graph, '/entry.ts', triggers) + expect(await probe.groups(['a', 'b'])).toEqual([['a', 'b'], ['b']]) + }) +}) diff --git a/packages/compiler-test-utils/tests/module-linker.test.ts b/packages/compiler-test-utils/tests/module-linker.test.ts index c00e86fe0e6..983777fc9b3 100644 --- a/packages/compiler-test-utils/tests/module-linker.test.ts +++ b/packages/compiler-test-utils/tests/module-linker.test.ts @@ -2,8 +2,9 @@ * Controls of the module linker: modules link like ES modules in a bundle * (every import and export form, live bindings, cycles), a dynamic import * evaluates its module only when it runs, and every graph evaluates fresh - * module instances. + * module instances; real packages, JSX, the load observers and forks. */ +import path from 'node:path' import { describe, expect, it, test } from 'vitest' import { createModuleGraph } from '../src/module-linker' import type { ModuleStub } from '../src/module-linker' @@ -156,7 +157,7 @@ export const read = () => count export { increment } export const lazy = () => import('/c.tsx')`, '/b.tsx': `export let count = 0\nexport function increment() { count++ }`, - '/c.tsx': `export const loaded =

    c

    `, + '/c.tsx': `export const loaded = { module: 'c' }`, } const resolve = (specifier: string) => specifier in modules ? specifier : undefined @@ -173,7 +174,7 @@ export const lazy = () => import('/c.tsx')`, const session = createModuleGraph({ modules, resolve }) const a = await session.import('/a.tsx') expect([...session.evaluated]).toEqual(['/b.tsx', '/a.tsx']) - expect((await a.lazy()).loaded).toBe('

    c

    ') + expect((await a.lazy()).loaded).toEqual({ module: 'c' }) expect([...session.evaluated]).toEqual(['/b.tsx', '/a.tsx', '/c.tsx']) }) @@ -335,6 +336,9 @@ describe('probeDynamicImports', () => { probe('stub'), probe('none'), ]).toEqual([['/first.tsx'], ['/second.tsx'], ['lib'], []]) + // Probing again names the module again. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect([probe('first'), probe('stub')]).toEqual([['/first.tsx'], ['lib']]) expect([...graph.evaluated]).toEqual(['/route.tsx']) // The imports work as usual afterwards. expect([await loaders.first(), (await loaders.second()).value]).toEqual([ @@ -346,5 +350,159 @@ describe('probeDynamicImports', () => { '/first.tsx', '/second.tsx', ]) + // A dynamic import of a module that already loaded is still named. + expect(probe('first')).toEqual(['/first.tsx']) + expect(await loaders.first()).toBe('first') + }) +}) + +// The package's own directory: its dependencies are the real packages a +// graph can link (`globals` is a CommonJS package, `vite` an ES one). +const packageDirectory = path.join(import.meta.dirname, '..') + +describe('packages', () => { + it('links a bare specifier to the real package, one instance for every graph and for importPackage', async () => { + const modules = { + '/a.ts': `import globals from 'globals' +import { version } from 'vite' +export { globals, version }`, + } + const first = createModuleGraph({ modules, packages: packageDirectory }) + const second = createModuleGraph({ modules, packages: packageDirectory }) + const [a, b] = [await first.import('/a.ts'), await second.import('/a.ts')] + const real = await first.importPackage('globals') + expect(Object.keys(a.globals)).toContain('browser') + expect(a.globals).toBe(real.default) + expect(b.globals).toBe(real.default) + expect(a.version).toBe((await second.importPackage('vite')).version) + }) + + it('stubs win over packages, and a specifier no package provides goes to fallbackStub', async () => { + const graph = createModuleGraph({ + modules: { + '/a.ts': `export { default as globals } from 'globals' +export { missing } from 'not-installed-package'`, + }, + packages: packageDirectory, + stubs: { globals: { default: 'stubbed' } }, + fallbackStub: (_, names) => + Object.fromEntries( + [...names].map((name) => [name, `fallback ${name}`]), + ), + }) + expect({ ...(await graph.import('/a.ts')) }).toEqual({ + globals: 'stubbed', + missing: 'fallback missing', + }) + }) + + it('without a fallback, an import of a package that does not resolve fails to link', async () => { + const graph = createModuleGraph({ + modules: { '/a.ts': `import 'not-installed-package'` }, + packages: packageDirectory, + }) + await expect(graph.import('/a.ts')).rejects.toThrow(/not-installed-package/) + }) +}) + +describe('JSX', () => { + // A control of how the default transform compiles JSX, with a stub that + // records the calls (no renderer): React's automatic runtime, as + // `@vitejs/plugin-react` compiles it. + it('compiles to calls of the automatic runtime of jsxImportSource (default react)', async () => { + const runtime = { + jsx: (type: unknown, props: unknown) => ({ type, props }), + jsxs: (type: unknown, props: unknown) => ({ type, props, static: true }), + Fragment: 'Fragment', + } + const code = `export const element =

    {'a'}

    +export const fragment = <>` + const react = await createModuleGraph({ + modules: { '/a.tsx': code }, + stubs: { 'react/jsx-runtime': runtime }, + }).import('/a.tsx') + expect(react.element).toEqual({ + type: 'p', + props: { id: 'x', children: 'a' }, + }) + expect(react.fragment).toEqual({ + type: 'Fragment', + props: { + children: [ + { type: 'i', props: {} }, + { type: 'b', props: {} }, + ], + }, + static: true, + }) + const other = await createModuleGraph({ + modules: { '/a.tsx': code }, + jsxImportSource: 'other', + stubs: { 'other/jsx-runtime': runtime }, + }).import('/a.tsx') + expect(other.element).toEqual(react.element) + }) +}) + +describe('requested', () => { + const modules: Record = { + '/route.tsx': `import { helper } from '/shared.tsx' +import { value } from 'lib' +export const read = () => [helper, value] +export const lazy = () => import('/chunk.tsx')`, + '/shared.tsx': `import 'side-effect' +export const helper = 'helper'`, + '/chunk.tsx': `import { helper } from '/shared.tsx' +import { other } from 'lib' +export const chunk = [helper, other]`, + } + const resolve = (specifier: string) => + specifier in modules ? specifier : undefined + const stubs = { lib: { value: 'value', other: 'other' }, 'side-effect': {} } + + it('records each module the graph requests once: static imports when they link, dynamic ones when they run', async () => { + const graph = createModuleGraph({ modules, resolve, stubs }) + const route = await graph.import('/route.tsx') + const eager = ['/shared.tsx', 'lib', 'side-effect'] + expect([...graph.requested].sort()).toEqual(eager) + // A probed import is not requested. + expect(graph.probeDynamicImports(() => route.lazy())).toEqual([ + '/chunk.tsx', + ]) + expect([...graph.requested].sort()).toEqual(eager) + expect((await route.lazy()).chunk).toEqual(['helper', 'other']) + expect([...graph.requested].sort()).toEqual(['/chunk.tsx', ...eager]) + }) + + it('names packages by their specifier, and not what importPackage loads', async () => { + const graph = createModuleGraph({ + modules: { '/a.ts': `export { default } from 'globals'` }, + packages: packageDirectory, + }) + await graph.importPackage('vite') + await graph.import('/a.ts') + expect(graph.requested).toEqual(['globals']) + }) +}) + +describe('fork', () => { + it('evaluates the same modules, added ones included, as fresh instances with the same stubs', async () => { + const counter = { count: 0 } + const graph = createModuleGraph({ + modules: { + '/a.ts': `import { counter } from 'counter' +export let own = 0 +export const bump = () => [++own, ++counter.count]`, + }, + stubs: { counter: { counter } }, + }) + graph.add('/b.ts', `export { bump } from '/a.ts'`) + const first = await graph.import('/b.ts') + first.bump() + const fork = graph.fork() + const forked = await fork.import('/b.ts') + expect(forked.bump()).toEqual([1, 2]) + expect([...fork.evaluated]).toEqual(['/a.ts', '/b.ts']) + expect([...graph.evaluated]).toEqual(['/a.ts', '/b.ts']) }) }) diff --git a/packages/react-start-rsc/tests/rscCssTransform.test.tsx b/packages/react-start-rsc/tests/rscCssTransform.test.tsx index e4a5138ac82..3a1d2face32 100644 --- a/packages/react-start-rsc/tests/rscCssTransform.test.tsx +++ b/packages/react-start-rsc/tests/rscCssTransform.test.tsx @@ -185,6 +185,9 @@ async function evaluateCompiled( ? 'rspackRsc' : 'viteRsc' const graph = createModuleGraph({ + // Stand-in until FIX-PLAN A3c replaces this harness with a real RSC + // provider build (W2-b removed the linker's default text renderer). + runtime: jsxAsText, modules: { [id]: compiled ?? code }, stubs: { '@tanstack/react-start/rsc': rsc, @@ -202,7 +205,23 @@ async function evaluateCompiled( return { module: await graph.import(id), calls, warnings } } +/** + * JSX as text (the linker's deprecated `runtime` option): intrinsic elements + * become tags, components are called. One line, so that line numbers stay + * those of the module. + */ +const jsxAsText = [ + `const __Fragment = Symbol('Fragment');`, + `const __jsx = (type, props, ...children) => {`, + `const text = children.flat(Infinity).filter((child) => child != null && child !== false && child !== 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 + ''; };`, +].join(' ') + /** `value` in the CSS envelope of the runtime, with the CSS `css`. */ + const withCss = (value: unknown) => createRscCssEnvelope(value, { __tanstackStartRscCss: 'css' }) From dbcec2b8b10bda1865c8852b857d3cebe8f340fc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:52:52 +0200 Subject: [PATCH 0427/1425] test(router-plugin): load split routes with the real router and React FIX-PLAN A3a/A4, F01/F02/F06 (W2-b). `loadRoute` links the compiled route with the real @tanstack/react-router (or vue-router) and React instead of a router stub whose `lazyRouteComponent`/`lazyFn` were recognised by identity: - `isLazy`, `loadedBy` (one or more modules: facades allowed) and `groups()` observe loading through the router's lazy contract (`preload()`, a loader call) and the modules requested (ctu lazy-probe); - `render` / `renderRoute` render with react-dom/server, the latter through a real router (`router.load()`, `RouterProvider`), so route hooks run; - `eager` lists everything the route module's load requested (F30); - `loadRouteGraph` loads fixed graphs: the harness controls now run on fixed graphs of several valid shapes (an equivalent wrapper, an intermediate module) and one wrong one (a group pulling another), not on the splitter's output (F06). `evaluateModule` and `evaluateRouteModule` use the real packages too. The router stub and the JSX-as-text stand-in that hmr-model.ts (deleted by A1) still needs moved into that file. Solid's router ships JSX source for bundlers and cannot be linked in process; Solid runtime stays with the real builds (integration/build-frameworks). Co-Authored-By: Claude Opus 5.5 --- packages/router-plugin/tests/hmr-model.ts | 66 +++- packages/router-plugin/tests/lazy-tracking.ts | 351 +++++++++++------- .../tests/regression-helpers.test.ts | 307 ++++++++++----- .../router-plugin/tests/regression-helpers.ts | 45 ++- 4 files changed, 526 insertions(+), 243 deletions(-) diff --git a/packages/router-plugin/tests/hmr-model.ts b/packages/router-plugin/tests/hmr-model.ts index 6a84db509b3..4f34fcb9dbf 100644 --- a/packages/router-plugin/tests/hmr-model.ts +++ b/packages/router-plugin/tests/hmr-model.ts @@ -22,9 +22,72 @@ import { webpackCompiler, } from './plugin-host' import type { RouteGraph, TargetFramework } from './plugin-host' -import { createRouterStub, stubExports } from './lazy-tracking' +import { stubExports } from './lazy-tracking' import type { Stubs } from './lazy-tracking' +// Stand-ins of this in-process HMR model only (W2-b moved them here from +// `lazy-tracking.ts`, which now loads routes with the real router and +// React). FIX-PLAN A1 deletes this file with them. + +/** + * Exports of a router package, as much of them as route modules use: routes + * keep their options, and lazy route options (`lazyRouteComponent`, + * `lazyFn`) load their module when called or preloaded. `lazy` collects the + * loader of every lazy value created. + */ +function createRouterStub(lazy: Map Promise>) { + const route = (options: unknown) => ({ options }) + return { + createFileRoute: () => route, + createLazyFileRoute: () => route, + createRootRoute: route, + createRootRouteWithContext: () => route, + createRoute: route, + Outlet: () => null, + lazyRouteComponent: ( + importer: () => Promise>, + exportName = 'default', + ) => { + let component: ((props: unknown) => unknown) | undefined + const load = async () => { + component = (await importer())[exportName] + return component + } + const Lazy = Object.assign( + (props: unknown) => { + if (!component) { + throw new Error('a lazy route component rendered before it loaded') + } + return component(props) + }, + { preload: load }, + ) + lazy.set(Lazy, load) + return Lazy + }, + lazyFn: (importer: () => Promise>, key = 'default') => { + const fn = async (...args: Array) => (await importer())[key](...args) + lazy.set(fn, async () => (await importer())[key]) + return fn + }, + } +} + +/** + * JSX as text (the linker's deprecated `runtime` option): intrinsic elements + * become tags, components are called. One line, so that line numbers stay + * those of the module. + */ +const textJsxRuntime = [ + `const __Fragment = Symbol('Fragment');`, + `const __jsx = (type, props, ...children) => {`, + `const text = children.flat(Infinity).filter((child) => child != null && child !== false && child !== 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 + ''; };`, +].join(' ') + const getStoreConfig: GetStoreConfig = () => ({ createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, @@ -203,6 +266,7 @@ export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { modules: Object.fromEntries(compiled.modules), resolve: compiled.resolve, stubs: { ...options.stubs, [routerPackage]: routerStub }, + runtime: textJsxRuntime, importMeta: (id) => ({ [style === 'webpack' ? 'webpackHot' : 'hot']: hotContext(id), }), diff --git a/packages/router-plugin/tests/lazy-tracking.ts b/packages/router-plugin/tests/lazy-tracking.ts index d2253afa2d4..62e800ca8fb 100644 --- a/packages/router-plugin/tests/lazy-tracking.ts +++ b/packages/router-plugin/tests/lazy-tracking.ts @@ -1,18 +1,64 @@ /** - * Loads a compiled route with stubs of the router package that track which - * options load lazily (`loadRoute`), and the stubs it links the route - * modules with. Re-exported by `regression-helpers.ts`. + * Loads a compiled route file the way an app does, with the real router + * package and the real framework: the route module (and what it imports + * statically) evaluates first, each split option's module only when the + * router loads it. Laziness is observed through the router's public lazy + * contract (a route component's `preload()`, a loader call) and the modules + * actually requested, never by recognising the helper that builds a lazy + * value (SPEC J2.6, §5.8): `lazyRouteComponent`, `lazyFn`, an equivalent + * wrapper or an intermediate module all pass. Re-exported by + * `regression-helpers.ts`. + * + * API (`loadRoute(code, options)` / `createRouteLoader(options)(code)`): + * - `modules`, `ids`, `graph`: the compiled modules (`compileRouteModules`). + * - `exports`, `Route`, `options`: the route module's exports, the real + * route it creates, the route's options. + * - `evaluated`: compiled modules evaluated so far (load observer); + * `eager`: the route module and every module its load requested (compiled + * modules, stubs, packages); `requested`: everything requested so far. + * - `isLazy(option)`: loading the option requests a compiled module that has + * not loaded with the route module. + * - `load(option)`: the option as the router uses it, once its module loaded + * (components: after `preload()`; a loader loads its module when called). + * - `render(option, props)`: the option's component rendered by the real + * framework's server renderer, after `load`. + * - `renderRoute({ at })`: the app (a root route rendering this route) + * rendered by the real router on the server: `createRouter`, memory + * history at `at` (default the route id), `router.load()`, then + * `RouterProvider` rendered. Loaders, error and not-found states, and route + * hooks such as `Route.useLoaderData()` run as in an app. + * - `loadedBy(option)`: the code of each compiled module loading the option + * evaluates (one or more: facades and support modules are allowed). + * - `groups()`: which lazy options load together. Activating a lazy option + * makes every option of its group available (its loading requests nothing + * new); options of another group stay unrequested. + * - `importFromRoute(source)`: another module of the app importing the route + * module (`'#route'`), for identity checks through a real importer. + * - `registered`: what React Refresh registered (`refresh`). + * + * The observers that load something (`isLazy`, `loadedBy`, `groups`) run in + * forks of the graph (`@tanstack/compiler-test-utils/lazy-probe`: fresh + * module instances, same stubs), so the route a test inspects loads nothing + * it did not ask for. The route module's top-level code runs again in each + * fork with the same stubs: assert what stubs record before using these. + * `isLazy('loader')` calls the loader once in a fork, without arguments: a + * loader that is not lazy runs there. + */ +import path from 'node:path' +import { createLazyProbe } from '@tanstack/compiler-test-utils/lazy-probe' import { createModuleGraph, - jsxPragma, moduleLang, } from '@tanstack/compiler-test-utils/module-linker' -import * as React from 'react' +import { + frameworkTransform, + renderToHtml, +} from '@tanstack/compiler-test-utils/render' import { transformWithOxc } from 'vite' -import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' import { createRouteGraphCompiler, labelRouteModules } from './plugin-host' -import type { CompileOptions, RouteGraph } from './plugin-host' +import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' +import type { CompileOptions, RouteGraph, TargetFramework } from './plugin-host' /** * Modules provided by the test, by import specifier: their exports, or the @@ -26,80 +72,39 @@ export function stubExports(stub: ModuleStub | undefined) { } /** - * Compiles a route file, evaluates its route module like `loadRoute` (every - * import the route file makes is stubbed by identity functions, unless - * `stubs` gives it) and returns the code of the module its `component` - * option loads lazily. + * The router plugin's own directory: the real packages compiled modules + * import (`react`, `react-dom`, the router packages, Solid, Vue) resolve + * from its dependencies. */ -export async function componentChunk( - code: string, - options: LoadRouteOptions = {}, -) { - const route = await loadRoute(code, { stubUnknownImports: true, ...options }) - return route.chunkOf('component') -} +export const packagesDirectory = path.join(import.meta.dirname, '..') + +/** A graph that only links packages: the instances every route graph gets. */ +const packageGraph = createModuleGraph({ packages: packagesDirectory }) /** - * Exports of a router package, as much of them as route modules use: routes - * keep their options, and lazy route options (`lazyRouteComponent`, - * `lazyFn`) load their module when called or preloaded, like the real ones. - * `lazy` collects the loader of every lazy value created. + * Server-renders a component of a compiled module with the real `framework` + * (default React), the instance compiled modules get. */ -export function createRouterStub(lazy: Map Promise>) { - const route = (options: unknown) => ({ options }) - return { - createFileRoute: () => route, - createLazyFileRoute: () => route, - createRootRoute: route, - createRootRouteWithContext: () => route, - createRoute: route, - Outlet: () => null, - lazyRouteComponent: ( - importer: () => Promise>, - exportName = 'default', - ) => { - let component: ((props: unknown) => unknown) | undefined - const load = async () => { - component = (await importer())[exportName] - return component - } - const Lazy = Object.assign( - (props: unknown) => { - if (!component) { - throw new Error('a lazy route component rendered before it loaded') - } - return component(props) - }, - { preload: load }, - ) - lazy.set(Lazy, load) - return Lazy - }, - lazyFn: (importer: () => Promise>, key = 'default') => { - const fn = async (...args: Array) => - (await importer())[key](...args) - lazy.set(fn, async () => (await importer())[key]) - return fn - }, - } +export function renderComponent( + component: unknown, + props: Record = {}, + framework: TargetFramework = 'react', +) { + return renderToHtml(packageGraph, framework, component, props) } -const routerPackages = [ - '@tanstack/react-router', - '@tanstack/solid-router', - '@tanstack/vue-router', -] - -/** Stubs of the router packages, merged with the test's own stubs. */ -export function withRouterStubs( - stubs: Stubs, - lazy: Map Promise>, -): Stubs { - const linked: Stubs = { ...stubs } - for (const name of routerPackages) { - linked[name] = { ...createRouterStub(lazy), ...stubExports(stubs[name]) } - } - return linked +/** + * Compiles a route file, evaluates its route module like `loadRoute` (every + * import the route file makes that no package provides is stubbed by + * identity functions, unless `stubs` gives it) and returns the code of each + * module loading its `component` option evaluates (`loadedBy`). + */ +export async function componentModules( + code: string, + options: LoadRouteOptions = {}, +) { + const route = await loadRoute(code, { stubUnknownImports: true, ...options }) + return route.loadedBy('component') } /** @@ -114,7 +119,7 @@ export async function transformWithReactRefresh( ) { const result = await transformWithOxc(code, 'module', { lang: moduleLang(id), - jsx: { runtime: 'classic', ...jsxPragma, refresh: {} }, + jsx: { runtime: 'automatic', refresh: {} }, typescript: { onlyRemoveTypeImports: verbatimModuleSyntax }, }) return result.code @@ -134,8 +139,11 @@ export function refreshScope(registered: Map) { } } -type LoadRouteOptions = CompileOptions & { - /** The route file's other imports, by specifier. */ +export type LoadRouteOptions = CompileOptions & { + /** + * The route file's other imports, by specifier. The router package and + * the framework are the real packages; do not stub them. + */ stubs?: Stubs /** * Compile every module with the React Refresh transform and record what it @@ -143,9 +151,9 @@ type LoadRouteOptions = CompileOptions & { */ refresh?: boolean /** - * Link every import that neither `stubs` nor the emitted modules provide - * to a module whose exports are identity functions, for tests that only - * inspect the emitted modules. + * Link every import that neither `stubs`, the emitted modules nor an + * installed package provide to a module whose exports are identity + * functions, for tests that only inspect the emitted modules. */ stubUnknownImports?: boolean /** @@ -165,13 +173,35 @@ const identityStub = (_: string, names: Set) => [...names].map((name) => [name, (value: unknown) => value]), ) +/** Route options the code splitter may split. */ +const splittableOptions = [ + 'component', + 'errorComponent', + 'pendingComponent', + 'notFoundComponent', + 'loader', +] + +/** + * Starts loading a route option the way the router does: a component's + * `preload()` (what intent preloading and `loadRouteChunk` call), a loader's + * call (function or `{ handler }`), without arguments. + */ +function triggerOption(value: any, option: string) { + if (option === 'loader') { + const loader = typeof value === 'function' ? value : value?.handler + return typeof loader === 'function' ? loader() : undefined + } + return value?.preload?.() +} + /** * Compiles a route file like `compileRouteModules`, then evaluates it the * way an app does: the route module (and what it imports statically) first, - * and each lazy module only when a route option loads it. Imports of the - * emitted modules link to each other; other imports link to `stubs` (the - * router package is stubbed). Route options are read from the evaluated - * `Route`; `load(option)` resolves a lazy option to what its module provides. + * and each lazy module only when the router loads a route option. Imports + * of the emitted modules link to each other; `stubs` provide other imports; + * everything else is a real installed package (the router package and the + * framework among them). See the file header for what the result offers. */ export async function loadRoute(code: string, options: LoadRouteOptions = {}) { return createRouteLoader(options)(code) @@ -187,86 +217,145 @@ export function createRouteLoader(options: LoadRouteOptions = {}) { return (code: string) => linkRoute(code, compile(code), options) } +/** + * Loads modules given as a compiled route graph, like `loadRoute` loads the + * code splitter's output: for controls of the harness on fixed graphs, of + * any shape a correct compiler may emit. + */ +export function loadRouteGraph( + graph: RouteGraph, + options: LoadRouteOptions = {}, +) { + return linkRoute(graph.modules.get(graph.id)!, graph, options) +} + async function linkRoute( code: string, graph: RouteGraph, options: LoadRouteOptions, ) { - const lazy = new Map Promise>() + const framework = options.targetFramework ?? 'react' + const routerPackage = `@tanstack/${framework}-router` const registered = new Map() const moduleGraph = createModuleGraph({ modules: Object.fromEntries(graph.modules), - stubs: withRouterStubs( - options.refresh - ? { react: React, ...options.stubs } - : (options.stubs ?? {}), - lazy, - ), + stubs: options.stubs, + packages: packagesDirectory, resolve: (specifier, importer) => specifier === routeSpecifier ? graph.id : graph.resolve(specifier, importer), ...(options.stubUnknownImports && { fallbackStub: identityStub }), verbatimModuleSyntax: options.verbatimModuleSyntax, - ...(options.refresh && { - scope: () => refreshScope(registered), - transform: (code: string, id: string) => - transformWithReactRefresh(code, id, options.verbatimModuleSyntax), - }), + ...(options.refresh + ? { + scope: () => refreshScope(registered), + transform: (code: string, id: string) => + transformWithReactRefresh(code, id, options.verbatimModuleSyntax), + } + : { transform: frameworkTransform(framework, packagesDirectory) }), }) const exports = await moduleGraph.import(graph.id) - const Route = exports.Route as { options: Record } | undefined + const Route = exports.Route as + | { options: Record; isRoot?: boolean } + | undefined if (!Route) { throw new Error('the route module does not export `Route`') } - // The module each lazy option loads: what its loader imports, found - // without evaluating it. - const lazyModules = new Map>() - const lazyOptions = new Map>() - for (const option of Object.keys(Route.options)) { - const loader = lazy.get(Route.options[option]) - if (!loader) { - continue + const eager = [graph.id, ...moduleGraph.requested] + + // Laziness by the modules starting to load an option requests, in forks + // of the graph (only compiled modules count, not stubs or packages). + const probe = createLazyProbe( + moduleGraph, + graph.id, + Object.fromEntries( + splittableOptions.map((option) => [ + option, + (forked: Record) => + triggerOption(forked.Route?.options[option], option), + ]), + ), + { owned: (id) => graph.modules.has(id) }, + ) + + const load = async (option: string): Promise => { + const value = Route.options[option] + if (option !== 'loader') { + await value?.preload?.() } - const ids = moduleGraph - .probeDynamicImports(loader) - .filter((id) => graph.modules.has(id)) - lazyModules.set(option, ids) - for (const id of ids) { - lazyOptions.set(id, [...(lazyOptions.get(id) ?? []), option]) + return value + } + + let routeTree: Promise | undefined + /** The real route tree: a root route rendering this route. */ + const createRouteTree = async () => { + if (Route.isRoot) { + return Route } + const router = await moduleGraph.importPackage(routerPackage) + const routeId = options.routeId ?? '/' + const root = router.createRootRoute() + ;(Route as any).update({ + id: routeId, + path: routeId, + getParentRoute: () => root, + }) + return root.addChildren([Route]) } - const compiled = labelRouteModules(code, graph, lazyOptions) + let importers = 0 return { - ...compiled, + ...labelRouteModules(code, graph), /** Exports of the route module. */ exports, Route, options: Route.options, - /** Ids of the modules evaluated so far, in evaluation order. */ + /** Ids of the compiled modules evaluated so far, in evaluation order. */ evaluated: moduleGraph.evaluated, - /** Whether a route option loads lazily from another module. */ - isLazy: (option: string) => lazy.has(Route.options[option]), - /** The value of a route option, once its lazy module (if any) loaded. */ - load: (option: string): Promise => { - const value = Route.options[option] - const loader = lazy.get(value) - return loader ? loader() : Promise.resolve(value) + /** The route module and every module its load requested (see the header). */ + eager, + /** Every module requested so far (see `ModuleGraph.requested`). */ + requested: moduleGraph.requested, + isLazy: probe.isLazy, + load, + /** Renders the option's component with `props` (see the header). */ + render: async (option: string, props: Record = {}) => + renderToHtml(moduleGraph, framework, await load(option), props), + /** Renders the app through the real router (see the header). */ + renderRoute: async ({ at }: { at?: string } = {}) => { + routeTree ??= createRouteTree() + const { createRouter, createMemoryHistory, RouterProvider } = + await moduleGraph.importPackage(routerPackage) + const router = createRouter({ + routeTree: await routeTree, + history: createMemoryHistory({ + initialEntries: [at ?? options.routeId ?? '/'], + }), + }) + await router.load() + return renderToHtml(moduleGraph, framework, RouterProvider, { router }) }, /** - * Code of the module a lazy route option loads (found by what its - * loader imports); fails for an option that is not lazy. + * Code of each compiled module that loading the option evaluates, in + * evaluation order; fails for an option that is not lazy. */ - chunkOf: (option: string): string => { - const ids = lazyModules.get(option) - if (!ids || ids.length !== 1) { - throw new Error( - `\`${option}\` does not load one module of the route lazily (it loads ${JSON.stringify(ids ?? [])})`, - ) + loadedBy: async (option: string): Promise> => { + const loaded = await probe.loadedBy(option) + if (loaded.length === 0) { + throw new Error(`\`${option}\` loads no module of the route lazily`) } - return graph.modules.get(ids[0]!)! + return loaded.map((id) => graph.modules.get(id)!) }, + /** + * The lazy options grouped by what loads together, each group sorted, + * groups sorted (see the header). A group that pulls another group's + * option without the reverse shows as two overlapping groups. + */ + groups: () => + probe.groups( + splittableOptions.filter((option) => option in Route.options), + ), /** * Evaluates `source` as another module of the app, next to the route * module, and returns its exports: it imports the route file as diff --git a/packages/router-plugin/tests/regression-helpers.test.ts b/packages/router-plugin/tests/regression-helpers.test.ts index a31e70d9673..41e8919cc80 100644 --- a/packages/router-plugin/tests/regression-helpers.test.ts +++ b/packages/router-plugin/tests/regression-helpers.test.ts @@ -8,7 +8,8 @@ import { describe, expect, it } from 'vitest' import { routerDevServer } from './dev-server' import { compileRouteModules, - componentChunk, + componentModules, + evaluateModule, expectRegisteredRouteOption, expectValidModules, generatedRouteId, @@ -17,9 +18,12 @@ import { importRequests, importSources, loadRoute, + loadRouteGraph, + renderComponent, routeModuleSources, routeSpecifier, } from './regression-helpers' +import type { RouteGraph } from './plugin-host' describe('compileRouteModules', () => { const route = `${head}export const Route = createFileRoute('/')({ @@ -52,80 +56,190 @@ export const Route = createFileRoute('/')({ }) }) -describe('loadRoute labels each lazy module by the route options that load it', () => { - const options = [ - 'loader', - 'component', - 'errorComponent', - 'notFoundComponent', - 'pendingComponent', - ] - const route = (groupings: string) => `${head}const shared = { value: 'v' } +/** + * A fixed route graph, as a compiler could emit it, with the route module + * at `/route.tsx`: `modules` by id, each id importable by its own name. + */ +function fixedGraph(modules: Record): RouteGraph { + const ids = new Map(Object.entries(modules)) + const importKinds = new Map() + for (const [id, code] of ids) { + for (const { specifier, dynamic } of importRequests(code, id)) { + if (ids.has(specifier) && importKinds.get(specifier) !== 'static') { + importKinds.set(specifier, dynamic ? 'dynamic' : 'static') + } + } + } + return { + id: '/route.tsx', + modules: ids, + importKinds, + resolve: (specifier) => (ids.has(specifier) ? specifier : undefined), + } +} + +// Controls of what `loadRoute` observes, on fixed graphs (not the code +// splitter's output): laziness, loading and grouping through the router's +// lazy contract and the modules requested, whatever helper builds a lazy +// value and however many modules a group loads (SPEC J2.6, §5.3, §5.8). +describe('loadRoute observes lazy options by their loading', () => { + const route = `import { createFileRoute, lazyRouteComponent, lazyFn } from '@tanstack/react-router' +import { shared } from '/shared.tsx' export const Route = createFileRoute('/')({ - codeSplitGroupings: ${groupings}, -${options.map((option) => ` ${option}: () =>

    {shared.value}${option}

    ,`).join('\n')} + loader: lazyFn(() => import('/loader.tsx'), 'loader'), + component: lazyRouteComponent(() => import('/component.tsx'), 'component'), + errorComponent: () =>

    error {shared}

    , })` + const modules = { + '/route.tsx': route, + '/shared.tsx': `export const shared = 'shared'`, + '/loader.tsx': `import { heavy } from './heavy' +export const loader = () => heavy`, + '/component.tsx': `import { shared } from '/shared.tsx' +export const component = () =>

    page {shared}

    `, + } + const stubs = { './heavy': { heavy: 'heavy data' } } - it('finds the module of every lazy option without evaluating it', async () => { - const loaded = await loadRoute( - route(JSON.stringify(options.map((option) => [option]))), - ) - expect(Object.keys(loaded.modules).sort()).toEqual([ - 'lazy component', - 'lazy errorComponent', - 'lazy loader', - 'lazy notFoundComponent', - 'lazy pendingComponent', - 'reference', - 'shared', - ]) - // Every module only imported lazily is labelled by an option. - const lazyIds = [...loaded.graph.importKinds] - .filter(([, kind]) => kind === 'dynamic') - .map(([id]) => id) - .sort() + it('tells lazy options from eager ones without loading them', async () => { + const loaded = await loadRouteGraph(fixedGraph(modules), { stubs }) expect( - options.map((option) => loaded.ids[`lazy ${option}`]).sort(), - ).toEqual(lazyIds) - expect(loaded.evaluated).toEqual([loaded.ids.reference]) - for (const option of options) { - expect(loaded.chunkOf(option)).toBe(loaded.modules[`lazy ${option}`]) - const value = await loaded.load(option) - expect(loaded.evaluated).toContain(loaded.ids[`lazy ${option}`]) - expect(value()).toBe(`

    v${option}

    `) - } + await Promise.all( + ['loader', 'component', 'errorComponent', 'pendingComponent'].map( + (option) => loaded.isLazy(option), + ), + ), + ).toEqual([true, true, false, false]) + expect(loaded.evaluated).toEqual(['/shared.tsx', '/route.tsx']) + expect(loaded.eager).not.toContain('./heavy') + expect(loaded.eager).toContain('/shared.tsx') }) - it('labels a module that several options load with all of them', async () => { - const loaded = await loadRoute( - route( - `[['loader', 'component'], ['errorComponent', 'notFoundComponent']]`, - ), + it('loads, renders and calls options the way the router does', async () => { + const loaded = await loadRouteGraph(fixedGraph(modules), { stubs }) + expect([ + await loaded.render('component'), + await loaded.render('errorComponent'), + await loaded.options.loader(), + await loaded.renderRoute(), + ]).toEqual([ + '

    page shared

    ', + '

    error shared

    ', + 'heavy data', + '

    page shared

    ', + ]) + expect(loaded.evaluated).toEqual([ + '/shared.tsx', + '/route.tsx', + '/component.tsx', + '/loader.tsx', + ]) + expect(loaded.requested).toContain('./heavy') + }) + + it('gives the modules loading an option evaluates, and its groups', async () => { + const loaded = await loadRouteGraph(fixedGraph(modules), { stubs }) + expect(await loaded.loadedBy('component')).toEqual([ + modules['/component.tsx'], + ]) + expect(await loaded.loadedBy('loader')).toEqual([modules['/loader.tsx']]) + await expect(loaded.loadedBy('errorComponent')).rejects.toThrow( + 'loads no module', ) - expect(Object.keys(loaded.modules).sort()).toEqual([ - 'lazy component+loader', - 'lazy errorComponent+notFoundComponent', - 'reference', - 'shared', + expect(await loaded.groups()).toEqual([['component'], ['loader']]) + // Observed in forks: the route the test inspects loaded nothing more. + expect(loaded.evaluated).toEqual(['/shared.tsx', '/route.tsx']) + }) + + // Another valid compiler: its own lazy wrapper, an intermediate module + // loading a group's modules, and the grouped options in other modules. + it('accepts an equivalent wrapper and an intermediate module', async () => { + const loaded = await loadRouteGraph( + fixedGraph({ + '/route.tsx': `import { createFileRoute } from '@tanstack/react-router' +import { createElement } from 'react' +const group = () => import('/group.tsx') +let Loaded +function Page(props) { + return createElement(Loaded, props) +} +Page.preload = async () => { + Loaded = (await group()).Page +} +export const Route = createFileRoute('/')({ + loader: async (context) => (await group()).loader(context), + component: Page, +})`, + '/group.tsx': `export { Page } from '/page.tsx' +export { loader } from '/data.tsx'`, + '/page.tsx': `export const Page = () =>

    page

    `, + '/data.tsx': `export const loader = () => 'data'`, + }), + ) + expect([ + await loaded.isLazy('component'), + await loaded.isLazy('loader'), + ]).toEqual([true, true]) + expect(await loaded.groups()).toEqual([['component', 'loader']]) + expect((await loaded.loadedBy('component')).length).toBe(3) + expect([ + await loaded.render('component'), + await loaded.options.loader(), + ]).toEqual(['

    page

    ', 'data']) + }) + + // A wrong compiler: the component's module imports the error + // component's module, so the error component loads with the component + // (but not the reverse). + it('shows an independent group that loads with another one', async () => { + const loaded = await loadRouteGraph( + fixedGraph({ + '/route.tsx': `import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + component: lazyRouteComponent(() => import('/component.tsx'), 'component'), + errorComponent: lazyRouteComponent(() => import('/error.tsx'), 'errorComponent'), +})`, + '/component.tsx': `import '/error.tsx' +export const component = () =>

    page

    `, + '/error.tsx': `export const errorComponent = () =>

    error

    `, + }), + ) + expect(await loaded.groups()).toEqual([ + ['component', 'errorComponent'], + ['errorComponent'], ]) - expect(loaded.chunkOf('loader')).toBe(loaded.chunkOf('component')) - expect(() => loaded.chunkOf('pendingComponent')).toThrow( - 'does not load one module', + }) + + it('renders the route through the real router, with its hooks', async () => { + const loaded = await loadRouteGraph( + fixedGraph({ + '/route.tsx': `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/posts')({ + loader: () => ['a', 'b'], + component: () =>

    {Route.useLoaderData().length} {Route.useSearch().page} {Route.fullPath}

    , +})`, + }), + { routeId: '/posts' }, + ) + expect(await loaded.renderRoute({ at: '/posts?page=3' })).toBe( + '

    2 3 /posts

    ', ) }) }) -describe('componentChunk', () => { - it('stubs the imports of the route file and returns the component chunk', async () => { - const chunk = await componentChunk(`${head}import { format } from './format' +describe('componentModules', () => { + it('stubs the imports of the route file and returns the modules of the component', async () => { + const loaded = + await componentModules(`${head}import { format } from './format' import { label } from './label' const prefix = label('route') export const Route = createFileRoute('/')({ loader: () => prefix, component: () =>

    {format('page')}

    , })`) - expect(importSources(chunk)).toContain('./format') - expect(importSources(chunk)).not.toContain('./label') + expect(loaded.flatMap((code) => importSources(code))).toContain('./format') + expect(loaded.flatMap((code) => importSources(code))).not.toContain( + './label', + ) }) }) @@ -138,7 +252,7 @@ export const Route = createFileRoute('/')({ const other = await route.importFromRoute(`import { store, Route } from '${routeSpecifier}' export const read = () => [store.count, typeof Route]`) - expect((await route.load('component'))()).toBe('

    1

    ') + expect(await route.render('component')).toBe('

    1

    ') expect(other.read()).toEqual([1, 'object']) expect(route.exports.store).toBe( (await route.importFromRoute(`export { store } from '${routeSpecifier}'`)) @@ -162,25 +276,20 @@ export const Route = createFileRoute('/')({ }, ) expect(route.evaluated).toEqual([route.ids.reference]) - expect([route.isLazy('component'), route.isLazy('loader')]).toEqual([ - true, - false, - ]) expect(route.options.loader()).toBe('data') - const component = await route.load('component') - expect(route.evaluated).toEqual([ - route.ids.reference, - route.ids['lazy component'], + expect([await route.render('component'), effects]).toEqual([ + '

    2

    ', + ['route', 'component'], ]) - expect([component(), effects]).toEqual(['

    2

    ', ['route', 'component']]) + expect(route.evaluated).toEqual([route.ids.reference, route.ids['lazy 1']]) }) it('returns an option that is not split as it is', async () => { const route = await loadRoute(`import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute({ component: () =>

    root

    })`) - expect(route.isLazy('component')).toBe(false) - expect((await route.load('component'))()).toBe('

    root

    ') + expect(await route.isLazy('component')).toBe(false) + expect(await route.render('component')).toBe('

    root

    ') }) }) @@ -219,26 +328,39 @@ export const Route = createFileRoute('/')({ component: Page })`, }) describe('framework and plugin options', () => { - it.each(['solid', 'vue'] as const)( - 'loadRoute compiles a %s route with its router package', - async (framework) => { - const route = await loadRoute( - `import { createFileRoute } from '@tanstack/${framework}-router' + it('loadRoute compiles a vue route with its router package and renders it with Vue', async () => { + const route = await loadRoute( + `import { createFileRoute } from '@tanstack/vue-router' export const Route = createFileRoute('/')({ - component: () =>

    ${framework}

    , + loader: () => ['a'], + component: () => { + const data = Route.useLoaderData() + return

    vue {data.value.length}

    + }, })`, - { targetFramework: framework }, - ) - expect(importSources(route.modules.reference!)).toContain( - `@tanstack/${framework}-router`, - ) - expect(importSources(route.modules.reference!)).not.toContain( - '@tanstack/react-router', - ) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe(`

    ${framework}

    `) - }, - ) + { targetFramework: 'vue' }, + ) + expect(await route.isLazy('component')).toBe(true) + // Vue's SSR output carries fragment markers + expect((await route.renderRoute()).replace(//g, '')).toBe( + '

    vue 1

    ', + ) + }) + + // The Solid router ships JSX source for bundlers (`solid` condition): it + // cannot be linked in process. Real Solid builds: build-frameworks.test.ts. + it('renders a Solid component with the Solid plugin and Solid SSR', async () => { + const { Page } = await evaluateModule( + `export const Page = (props: { name: string }) =>

    solid {props.name}

    `, + {}, + { framework: 'solid' }, + ) + // Solid's SSR output carries hydration keys and markers + const html = await renderComponent(Page, { name: 'x' }, 'solid') + expect(html.replace(/| data-hk="[^"]*"/g, '')).toBe( + '

    solid x

    ', + ) + }) const route = `${head}export const Route = createFileRoute('/')({ loader: () => 'data', @@ -247,21 +369,14 @@ export const Route = createFileRoute('/')({ it('compileRouteModules and loadRoute take the code-splitting options of the plugin', async () => { const byDefault = await loadRoute(route) - expect(byDefault.isLazy('loader')).toBe(false) + expect(await byDefault.isLazy('loader')).toBe(false) const grouped = await loadRoute(route, { pluginOptions: { codeSplittingOptions: { defaultBehavior: [['loader', 'component']] }, }, }) - expect([grouped.isLazy('loader'), grouped.isLazy('component')]).toEqual([ - true, - true, - ]) - expect(Object.keys(grouped.modules)).toEqual([ - 'reference', - 'lazy component+loader', - ]) + expect(await grouped.groups()).toEqual([['component', 'loader']]) expect(await (await grouped.load('loader'))()).toBe('data') const unsplit = compileRouteModules(route, { diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index f0ee95235c5..5bf1613fa5b 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -7,20 +7,21 @@ * (`hmr-model.ts`) live in their own modules and are re-exported here. */ import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' +import { frameworkTransform } from '@tanstack/compiler-test-utils/render' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' -import * as React from 'react' import { parseSync, transformWithOxc } from 'vite' import { expect } from 'vitest' import { build, routeApp } from './build-harness' import type { CodeSplitGroupings } from '../src/core/constants' import type { ESTree } from 'vite' import { + packagesDirectory, refreshScope, transformWithReactRefresh, - withRouterStubs, } from './lazy-tracking' import type { Stubs } from './lazy-tracking' import { routeModuleId } from './plugin-host' +import type { TargetFramework } from './plugin-host' export { webpackCompiler, @@ -43,10 +44,12 @@ export { } from './plugin-host' export type { TargetFramework, WebpackCompilerStub } from './plugin-host' export { - componentChunk, + componentModules, routeSpecifier, loadRoute, + loadRouteGraph, createRouteLoader, + renderComponent, } from './lazy-tracking' export { hotUpdate } from './hmr-model' export type { HotUpdateOptions } from './hmr-model' @@ -56,21 +59,32 @@ export { declaredNames } from '@tanstack/compiler-test-utils/validate-module' export const head = `import { createFileRoute } from '@tanstack/react-router'\n` /** - * Evaluates an emitted module like a runtime would: JSX becomes plain calls - * that render to text, and imports of every form (named, default, namespace, - * side-effect only, re-exports) link to `stubs`, keyed by specifier, as real - * ES modules. `importMeta` is assigned to `import.meta` before the module - * runs. Every call evaluates a fresh module instance. + * Evaluates an emitted module like a runtime would: imports of every form + * (named, default, namespace, side-effect only, re-exports) link to `stubs`, + * keyed by specifier, as real ES modules, and other bare imports to the real + * installed packages; JSX compiles with the real transform of `framework` + * (default React) into real elements (render them with `renderComponent`). + * `importMeta` is assigned to `import.meta` before the module runs. Every + * call evaluates a fresh module instance. */ export async function evaluateModule( code: string, stubs: Stubs = {}, - options: { importMeta?: Record; filename?: string } = {}, + options: { + importMeta?: Record + filename?: string + framework?: TargetFramework + } = {}, ): Promise) => any>> { const id = routeModuleId(options.filename ?? 'module.tsx') const moduleGraph = createModuleGraph({ modules: { [id]: code }, stubs, + packages: packagesDirectory, + transform: frameworkTransform( + options.framework ?? 'react', + packagesDirectory, + ), ...(options.importMeta && { importMeta: () => options.importMeta! }), }) return moduleGraph.import(id) @@ -78,11 +92,11 @@ export async function evaluateModule( /** * Evaluates one compiled route module on its own with the React Refresh - * transform: the router package is stubbed, `react` is React, `stubs` - * provide other imports and any remaining import is a module whose imports - * are identity functions (`connect(Page)` returns `Page`). `importMeta` is - * assigned to `import.meta` before the module runs. Returns its exports, its - * `Route` and what React Refresh registered. + * transform: the router package and React are the real ones, `stubs` + * provide other imports and any remaining import no package provides is a + * module whose imports are identity functions (`connect(Page)` returns + * `Page`). `importMeta` is assigned to `import.meta` before the module runs. + * Returns its exports, its `Route` and what React Refresh registered. */ export async function evaluateRouteModule( code: string, @@ -93,7 +107,8 @@ export async function evaluateRouteModule( const registered = new Map() const moduleGraph = createModuleGraph({ modules: { [id]: code }, - stubs: withRouterStubs({ react: React, ...stubs }, new Map()), + stubs, + packages: packagesDirectory, fallbackStub: (_, names) => Object.fromEntries( [...names].map((name) => [name, (value: unknown) => value]), From a1cae72bb23140478cb46bea03763d2fc5f4c70c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:53:08 +0200 Subject: [PATCH 0428/1425] test(router-plugin): splitter suites observe real renders and real loading FIX-PLAN A3a/A4 consumers (W2-b): F01, F02, F06, F30, F36. - Components render with real React (`render`, or `renderRoute` through the real router where they use route hooks); expected HTML is what React renders. Stubs of the router packages and of React are gone. - Laziness via `await isLazy`, groupings via `groups()` instead of module or evaluation counts (F06: route options, plugin pipeline, pragmas; F36: router-plugin-context checks that the requested group loads and an independently configured one stays unrequested). - "Not loaded with the route" via `eager`, the whole eager graph, instead of the route module's direct imports (F30: dead code, imports/exports, module state, scope); the compiler-level D5 checks stay on the split modules (`loadedBy`, one or more modules). - One-instance checks through real consumers (`importFromRoute`, a component recording what it sees), never the identity of a lazy wrapper. - Every `.fails` pin touched was run once without `.fails`: each fails for its own bug. The `@jsxFrag` pin's fixture now says `@jsxRuntime classic` (Vite 8's React transform ignores a lone `@jsx`, so the old fixture never used its factory). - New pin (code-splitter-pragmas, "in the route module, when the code calling the factory is split"): the route module emitted for a file with leading JSX pragmas starts with the splitter's own imports and drops the classic factory import, so JSX left in the route module compiles with React's default runtime under Vite 8. Hidden by the old JSX stand-in. Co-Authored-By: Claude Opus 5.5 --- .../tests/code-splitter-dead-code.test.ts | 151 +++-- .../tests/code-splitter-determinism.test.ts | 14 +- .../code-splitter-imports-exports.test.ts | 239 ++++---- .../tests/code-splitter-jsx.test.ts | 14 +- .../tests/code-splitter-module-state.test.ts | 178 +++--- .../code-splitter-plugin-pipeline.test.ts | 145 +++-- .../tests/code-splitter-pragmas.test.ts | 113 +++- .../tests/code-splitter-route-options.test.ts | 298 +++++----- .../tests/code-splitter-scope.test.ts | 87 +-- .../tests/known-bugs-code-splitter.test.ts | 530 +++++++++--------- .../tests/router-plugin-context.test.ts | 46 +- 11 files changed, 979 insertions(+), 836 deletions(-) diff --git a/packages/router-plugin/tests/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/code-splitter-dead-code.test.ts index 7401f98ef8d..4e0348e6443 100644 --- a/packages/router-plugin/tests/code-splitter-dead-code.test.ts +++ b/packages/router-plugin/tests/code-splitter-dead-code.test.ts @@ -27,8 +27,18 @@ const hmrModes = [{ hmr: false }, { hmr: true }] * a shared binding. */ async function renderSplitComponent(code: string) { - const route = await loadRoute(code) - return (await route.load('component'))() + return (await loadRoute(code)).render('component') +} + +/** + * The code of each compiled module that loads with the route module (the + * route module and what it imports statically, facades included). + */ +function eagerModules(route: Awaited>) { + return route.eager.flatMap((id) => { + const code = route.graph.modules.get(id) + return code === undefined ? [] : [code] + }) } describe('the reference module only drops code that splitting left unused', () => { @@ -104,7 +114,7 @@ export const Route = createFileRoute('/')({ component: () =>
    }) await new Promise((resolve) => setTimeout(resolve)) expect(calls).toEqual(expected) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('
    ') + expect(await route.render('component')).toBe('
    ') }, ) }) @@ -163,7 +173,7 @@ export const Route = createFileRoute('/')({ component: () =>
    }) }, ) expect(calls).toEqual(registered) - expect((await route.load('component'))()).toBe('
    ') + expect(await route.render('component')).toBe('
    ') }, ) }) @@ -209,9 +219,10 @@ export const Route = createFileRoute('/')({ const reference = route.modules.reference! expect(declaredNames(reference)).not.toContain('ping') expect(declaredNames(reference)).not.toContain('pong') - expect(importSources(reference)).not.toContain('./dep') + // Nothing that loads with the route requests what only the helpers read. + expect(route.eager).not.toContain('./dep') await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    dep

    ') + expect(await route.render('component')).toBe('

    dep

    ') }, ) @@ -227,11 +238,13 @@ export const Route = createFileRoute('/')({ `, { stubs: { bar: { used: 'used', preUnused: 'unused' } } }, ) - // The route module no longer imports `used` (whether it keeps loading - // `bar` is free). - expect(importedNames(route.modules.reference!, 'bar')).not.toContain('used') + // What loads with the route no longer imports `used` (whether it keeps + // loading `bar` is free). + expect( + eagerModules(route).flatMap((code) => importedNames(code, 'bar')), + ).not.toContain('used') await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    used

    ') + expect(await route.render('component')).toBe('

    used

    ') }) // That `track` runs once too is pinned in known-bugs-code-splitter.test.ts @@ -269,7 +282,7 @@ export const Route = createFileRoute('/')({ expect(tracked).toBe(1) expect([ route.options.beforeLoad(), - (await route.load('component'))(), + await route.render('component'), created, ]).toEqual([1, '

    1

    ', 1]) }) @@ -299,8 +312,8 @@ export const Route = createFileRoute('/')({ ) expect(calls).toEqual(['top-level']) expect([ - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('component'), + await route.render('errorComponent'), ]).toEqual(['
    component
    ', '
    error
    ']) expect(calls).toEqual(['top-level', 'component', 'error']) await expectValidModules(route.modules) @@ -331,7 +344,7 @@ export const Route = createFileRoute('/')({ }, ) expect(calls).toEqual(['namespace']) - expect((await route.load('component'))()).toBe('
    component
    ') + expect(await route.render('component')).toBe('
    component
    ') expect(calls).toEqual(['namespace', 'component']) }) @@ -347,12 +360,7 @@ export const Route = createFileRoute('/')({ name: 'a route module compiled for HMR', load: async (code: string, stubs: Record) => { const output = transformWithRouteHmrPlugin(code) - await evaluateModule(output, { - ...stubs, - '@tanstack/react-router': { - createFileRoute: () => (options: unknown) => ({ options }), - }, - }) + await evaluateModule(output, stubs) return output }, }, @@ -399,9 +407,11 @@ it.each([ ])( 'keeps a context created with $name as type argument working in the split component', async ({ type }) => { + // A user module stands for the context factory (React is never + // stubbed): it counts the contexts created. let created = 0 const route = await loadRoute( - `${head}import { createContext } from 'react' + `${head}import { createContext } from './context' type Item = { id: string } const item: Item = { id: 'item' } const Context = createContext<${type}>(null!) @@ -412,7 +422,7 @@ export const Route = createFileRoute('/')({ `, { stubs: { - react: { + './context': { createContext: (value: unknown) => { created++ return { value } @@ -424,7 +434,7 @@ export const Route = createFileRoute('/')({ await expectValidModules(route.modules) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), created, ]).toEqual(['item', '

    null

    ', 1]) }, @@ -467,7 +477,7 @@ export const Route = createFileRoute('/')({ ` const route = await loadRoute(code) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe(rendered) + expect(await route.render('component')).toBe(rendered) }, ) @@ -491,8 +501,8 @@ function Unused() { `, { stubs: { './heavy': { heavy: () => 'heavy' } } }, ) - expect(importSources(route.modules.reference!)).not.toContain('./heavy') - expect((await route.load('component'))()).toBe('

    heavy

    ') + expect(route.eager).not.toContain('./heavy') + expect(await route.render('component')).toBe('

    heavy

    ') }) // Bundlers drop unused calls of a function annotated #__NO_SIDE_EFFECTS__, @@ -546,7 +556,7 @@ export const Route = createFileRoute('/')({ ) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), calls, ]).toEqual(['loader', '

    component

    ', ['loader', 'component']]) }, @@ -563,7 +573,7 @@ export const Route = createFileRoute('/')({ component: () =>

    {f.length}{g.length}

    , }) `) - expect((await route.load('component'))()).toBe('

    22

    ') + expect(await route.render('component')).toBe('

    22

    ') }) // Source: React Router remove-exports-test.ts "iife with dependencies" @@ -593,16 +603,17 @@ export const Route = createFileRoute('/')({ }, ) const { modules } = route - expect(importSources(modules.reference!)).not.toContain('removed-lib') - for (const name of Object.keys(modules)) { - if (name.startsWith('lazy ')) { - expect(importSources(modules[name]!), name).not.toContain('kept-lib') - } - } + // Nothing that loads with the route requests what only the component + // reads, nothing the component loads requests what only the loader reads. + expect(route.eager).not.toContain('removed-lib') + expect( + (await route.loadedBy('component')).flatMap(importSources), + ).not.toContain('kept-lib') await expectValidModules(modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['shared:kept', '
    shared:removed
    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'shared:kept', + '
    shared:removed
    ', + ]) }) // Source: React Router route-chunks-test.ts "isolated exported variable @@ -636,41 +647,60 @@ export const Route = createFileRoute('/')({ }, }, ) - // The route module holds nothing of the component, the chunk nothing - // of the loader. + // What loads with the route holds nothing of the component, what the + // component loads nothing of the loader. + const eager = eagerModules(route) expect( - importedNames(route.modules.reference!, './messages'), + eager.flatMap((code) => importedNames(code, './messages')), ).not.toContain('chunkMessage') - expect(declaredNames(route.modules.reference!)).not.toContain('Page') - const chunk = route.chunkOf('component') - expect(importedNames(chunk, './messages')).not.toContain('mainMessage') - expect(declaredNames(chunk)).not.toContain('main') + expect(eager.flatMap((code) => [...declaredNames(code)])).not.toContain( + 'Page', + ) + const component = await route.loadedBy('component') + expect( + component.flatMap((code) => importedNames(code, './messages')), + ).not.toContain('mainMessage') + expect( + component.flatMap((code) => [...declaredNames(code)]), + ).not.toContain('main') await expectValidModules(route.modules) - expect([ - route.options.loader(), - (await route.load('component'))(), - ]).toEqual(['main', '
    chunk
    ']) + expect([route.options.loader(), await route.render('component')]).toEqual( + ['main', '
    chunk
    '], + ) }, ) // Source: React Router route-chunks-test.ts "exported variable declarations // sharing an export statement" + // The component records the instance of `Page` it runs as: the one the + // loader returns. it('keeps one component that a sibling declarator reads', async () => { + const seen: Array = [] const route = await loadRoute( - `${head}import { sharedMessage } from './messages' -const Page = () =>
    {sharedMessage}
    , + `${head}import { sharedMessage, see } from './messages' +const Page = () =>
    {see(Page)}{sharedMessage}
    , Preview = Page export const Route = createFileRoute('/')({ loader: () => Preview, component: Page, }) `, - { stubs: { './messages': { sharedMessage: 'shared' } } }, + { + stubs: { + './messages': { + sharedMessage: 'shared', + see: (value: unknown) => { + seen.push(value) + return '' + }, + }, + }, + }, ) await expectValidModules(route.modules) - const Component = await route.load('component') - expect(route.options.loader()).toBe(Component) - expect(Component()).toBe('
    shared
    ') + expect(await route.render('component')).toBe('
    shared
    ') + expect(seen).toHaveLength(1) + expect(seen[0]).toBe(route.options.loader()) }) }) @@ -699,10 +729,9 @@ export const Route = createFileRoute('/')({ // That the statement runs once is pinned in // known-bugs-code-splitter.test.ts ("a binding declared by a var in …"). await expectValidModules(route.modules) - expect([ - route.options.loader(), - (await route.load('component'))(), - ]).toEqual([value, `
    ${value}
    `]) + expect([route.options.loader(), await route.render('component')]).toEqual( + [value, `
    ${value}
    `], + ) }, ) @@ -718,7 +747,7 @@ export const Route = createFileRoute('/')({ const { flag } = await route.importFromRoute( `export { flag } from '${routeSpecifier}'`, ) - expect([flag, (await route.load('component'))()]).toEqual([ + expect([flag, await route.render('component')]).toEqual([ 'server', '
    server
    ', ]) @@ -760,7 +789,7 @@ export const Route = createFileRoute('/')({ await expectValidModules(route.modules) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), calls, ]).toEqual(['block', '

    flag3

    ', { compute: 1, connect: 1 }]) }) @@ -801,7 +830,7 @@ export const Route = createFileRoute('/')({ // The chain runs once, whichever module holds it. expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), calls, ]).toEqual([schema, '
    schema
    ', { builder: 1, add: 200 }]) }) diff --git a/packages/router-plugin/tests/code-splitter-determinism.test.ts b/packages/router-plugin/tests/code-splitter-determinism.test.ts index 6f7502d2a04..3667f4c5d34 100644 --- a/packages/router-plugin/tests/code-splitter-determinism.test.ts +++ b/packages/router-plugin/tests/code-splitter-determinism.test.ts @@ -69,14 +69,18 @@ async function compileUnder(moduleId: string) { ].sort((a, b) => b.length - a.length) const anonymous = (code: string) => spellings.reduce((text, id) => text.replaceAll(id, ''), code) + const lazy: Array = [] + for (const option of ['loader', 'component', 'errorComponent']) { + if (await loaded.isLazy(option)) { + lazy.push(option) + } + } return { - lazy: ['loader', 'component', 'errorComponent'].filter((option) => - loaded.isLazy(option), - ), + lazy, rendered: [ await (await loaded.load('loader'))(), - (await loaded.load('component'))(), - (await loaded.load('errorComponent'))(), + await loaded.render('component'), + await loaded.render('errorComponent'), ], modules: Object.fromEntries( Object.entries(loaded.modules).map(([label, code]) => [ 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 4f50b105f4b..86fb7748d7e 100644 --- a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts +++ b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import { compileRouteModules, - componentChunk, + componentModules, declaredNames, evaluateModule, expectValidModules, @@ -59,18 +59,22 @@ export const Route = createFileRoute('/')({ 'default', ]) for (const option of ['component', 'errorComponent', 'notFoundComponent']) { - const chunk = route.chunkOf(option) - expect(importedNames(chunk, './messages'), option).not.toContain( - 'otherMessage', - ) - expect(importedNames(chunk, './other'), option).not.toContain('default') + const loaded = await route.loadedBy(option) + expect( + loaded.flatMap((code) => importedNames(code, './messages')), + option, + ).not.toContain('otherMessage') + expect( + loaded.flatMap((code) => importedNames(code, './other')), + option, + ).not.toContain('default') } await expectValidModules(route.modules) expect([ route.options.loader(), - (await route.load('component'))(), - (await route.load('errorComponent'))(), - (await route.load('notFoundComponent'))(), + await route.render('component'), + await route.render('errorComponent'), + await route.render('notFoundComponent'), ]).toEqual([ ['other', 'otherDefault'], '
    defaultx
    ', @@ -87,13 +91,14 @@ export const Route = createFileRoute('/')({ component: Page, }) `, - { stubs: { './page': { Page: () => '

    page

    ', title: 'title' } } }, + { 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

    '], - ) + expect(await route.isLazy('component')).toBe(true) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'title', + 'page', + ]) }) it('moves a specifier with a string name into the chunk', async () => { @@ -106,9 +111,10 @@ export const Route = createFileRoute('/')({ `, { stubs: { './icons': { 'kebab-name': () => 'kebab' } } }, ) - expect(importSources(route.modules.reference!)).not.toContain('./icons') + // Nothing the route module loads requests what only the component reads. + expect(route.eager).not.toContain('./icons') await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('kebab') + expect(await route.render('component')).toBe('kebab') }) // Source: babel-dead-code-elimination dead-code-elimination.test.ts "import" > @@ -127,10 +133,12 @@ export const Route = createFileRoute('/')({ `, { hmr, stubs: { pkg: { default: 'a', b: 'b' }, './styles.css': {} } }, ) - expect(importSources(route.modules.reference!)).toContain('./styles.css') - expect(importSources(route.modules.reference!)).not.toContain('pkg') + // The side-effect import still loads with the route; what only the + // component reads does not. + expect(route.eager).toContain('./styles.css') + expect(route.eager).not.toContain('pkg') await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    ab

    ') + expect(await route.render('component')).toBe('

    ab

    ') }, ) @@ -190,44 +198,48 @@ export const Route = createFileRoute('/')({ ) expect(code).not.toMatch(/\bassert\s*\{/) } - expect((await route.load('component'))()).toBe('

    from json

    ') + expect(await route.render('component')).toBe('

    from json

    ') }) // With React's classic JSX runtime (`jsx: "react"` in tsconfig), JSX // compiles to `React.createElement`, so a React import that the TypeScript // code only uses in types must stay. it.each([ - { name: 'split chunk', compile: componentChunk }, + { name: 'split component modules', compile: componentModules }, { name: 'route module compiled for HMR', - compile: async (code: string) => transformWithRouteHmrPlugin(code), + compile: (code: string) => [transformWithRouteHmrPlugin(code)], }, ])( 'keeps the React import of the $name for classic JSX', async ({ compile }) => { - const output = await compile(`import * as React from 'react' + const outputs = await compile(`import * as React from 'react' ${head}function Page({ title = 'classic' }: { title?: React.ReactNode }) { return

    {title}

    } export const Route = createFileRoute('/')({ component: Page }) `) - const { code } = await transformWithOxc(output, 'module.tsx', { - jsx: { runtime: 'classic' }, - }) - // `React.createElement` reads an imported `React`. - expect(await getModuleErrors(code)).toEqual([]) + for (const output of outputs) { + const { code } = await transformWithOxc(output, 'module.tsx', { + jsx: { runtime: 'classic' }, + }) + // `React.createElement` reads an imported `React`. + expect(await getModuleErrors(code)).toEqual([]) + } }, ) - it('keeps the React import of a split chunk with inline JSX when only a top-level type reads React', async () => { - const chunk = await componentChunk(`import * as React from 'react' + it('keeps the React import of the split component modules with inline JSX when only a top-level type reads React', async () => { + const outputs = await componentModules(`import * as React from 'react' ${head}type Title = React.ReactNode export const Route = createFileRoute('/')({ component: () =>

    classic

    }) `) - const { code } = await transformWithOxc(chunk, 'module.tsx', { - jsx: { runtime: 'classic' }, - }) - expect(await getModuleErrors(code)).toEqual([]) + for (const output of outputs) { + const { code } = await transformWithOxc(output, 'module.tsx', { + jsx: { runtime: 'classic' }, + }) + expect(await getModuleErrors(code)).toEqual([]) + } }) }) @@ -238,7 +250,7 @@ describe('exports of the route file', () => { ])( 'are not redeclared when they re-export an import $name', async ({ exports, exported }) => { - const Foo = () => 'foo' + const Foo = () => 'foo' const route = await loadRoute( `${head}import { Foo } from './foo' ${exports} @@ -254,9 +266,9 @@ function Page() { const importer = await route.importFromRoute( `export { ${exported} } from '${routeSpecifier}'`, ) - expect([importer[exported], (await route.load('component'))()]).toEqual([ + expect([importer[exported], await route.render('component')]).toEqual([ Foo, - 'foo', + 'foo', ]) }, ) @@ -279,7 +291,7 @@ export const Route = createFileRoute('/')({ const importer = await route.importFromRoute( `export { a } from '${routeSpecifier}'`, ) - expect([importer.a, (await route.load('component'))()]).toEqual([ + expect([importer.a, await route.render('component')]).toEqual([ 'A', '
    AB
    ', ]) @@ -311,7 +323,7 @@ export default ${declaration} expect(type === 'class' ? new exported().x : exported()).toBe( type === 'class' ? 1 : null, ) - expect((await route.load('component'))()).toBe('
    0
    ') + expect(await route.render('component')).toBe('
    0
    ') }, ) @@ -334,40 +346,44 @@ export { Route, title } `export { Route, title } from '${routeSpecifier}'`, ) expect([other.title, other.Route]).toEqual(['title', route.Route]) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['value', '

    value

    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'value', + '

    value

    ', + ]) await expectValidModules(route.modules) }) + // `track` records each evaluation of the code-based route's options, i.e. + // each `createRoute` call. it("load the file route's split option without creating the code-based route of the module again", async () => { let created = 0 const route = await loadRoute( `import { createFileRoute, createRoute } from '@tanstack/react-router' import { Route as rootRoute } from './__root' -export const legacy = createRoute({ +import { track } from './track' +export const legacy = createRoute(track({ getParentRoute: () => rootRoute, path: '/legacy', component: () =>

    legacy

    , -}) +})) export const Route = createFileRoute('/')({ component: () =>

    file

    , }) `, { stubs: { - '@tanstack/react-router': { - createRoute: (options: unknown) => { + './track': { + track: (options: unknown) => { created++ - return { options } + return options }, }, - './__root': { Route: { options: {} } }, + './__root': { Route: {} }, }, }, ) expect(created).toBe(1) - expect((await route.load('component'))()).toBe('

    file

    ') + expect(await route.render('component')).toBe('

    file

    ') expect(created).toBe(1) }) @@ -386,7 +402,7 @@ export const Route = createFileRoute('/')({ `import { a, b } from '${routeSpecifier}' export const read = () => [a.n, b.n]`, ) - expect((await route.load('component'))()).toBe('
    11
    ') + expect(await route.render('component')).toBe('
    11
    ') // The component updated the object other modules import. expect(other.read()).toEqual([1, 10]) await expectValidModules(route.modules) @@ -408,7 +424,7 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect([route.exports.reset(), (await route.load('component'))()]).toEqual([ + expect([route.exports.reset(), await route.render('component')]).toEqual([ 0, '

    Home

    ', ]) @@ -432,22 +448,27 @@ export const Route = createFileRoute('/')({ export const read = () => [value, lib.value]`, ) expect(other.read()).toEqual(['lib value', 'lib value']) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [0, '
    0
    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 0, + '
    0
    ', + ]) await expectValidModules(route.modules) }) - // An exported split option keeps working and keeps its identity, the other - // options are still split, and when the exported option is not split the - // compiler tells the developer once, naming the file and the binding. + // An exported split option keeps working and keeps its identity (the + // component the route renders records itself with `see`: it is the one + // other modules import), the other options are still split, and when the + // exported option is not split the compiler tells the developer once, + // naming the file and the binding. // Splitting it anyway is the goal, decided by the build (pinned in // known-bugs-code-splitter.test.ts, "page code exported from the route // file loads with its chunk"). it.each([ { name: 'an exported component, with the errorComponent split', - code: `${head}export function Page() { + code: `${head}import { see } from './see' +export function Page() { + see(Page) return

    page

    } export const Route = createFileRoute('/')({ @@ -462,7 +483,8 @@ export const Route = createFileRoute('/')({ }, { name: 'a default-exported component, with the notFoundComponent split', - code: `${head}export const Route = createFileRoute('/home')({ + code: `${head}import { see } from './see' +export const Route = createFileRoute('/home')({ component: Home, notFoundComponent: NotFoundComponent, }) @@ -470,6 +492,7 @@ function NotFoundComponent() { return
    Not Found
    } export default function Home() { + see(Home) return

    home

    } `, @@ -499,7 +522,11 @@ export const Route = createFileRoute('/_layout')({ }, { name: 'a component default-exported by name, with the errorComponent split', - code: `${head}const Page = () =>

    page

    + code: `${head}import { see } from './see' +const Page = () => { + see(Page) + return

    page

    +} export default Page export const Route = createFileRoute('/')({ component: Page, @@ -521,15 +548,22 @@ export const Route = createFileRoute('/')({ const warnings = warn.mock.calls.map(([message]) => String(message)) warn.mockClear() - const route = await loadRoute(code) + const seen: Array = [] + const route = await loadRoute(code, { + stubs: { './see': { see: (value: unknown) => seen.push(value) } }, + }) await expectValidModules(route.modules) - expect(route.isLazy(split)).toBe(true) - const loaded = await route.load(option) - expect(loaded()).toEqual(value) - if (option !== 'loader') { - expect(loaded).toBe(route.exports[binding]) + if (option === 'loader') { + expect(await (await route.load(option))()).toEqual(value) + } else { + expect(await route.render(option)).toBe(value) + const importer = await route.importFromRoute( + `export { ${binding} as exported } from '${routeSpecifier}'`, + ) + expect(seen).toEqual([importer.exported]) } - if (!route.isLazy(option)) { + expect(await route.isLazy(split)).toBe(true) + if (!(await route.isLazy(option))) { const name = 'declared' in row ? row.declared @@ -565,8 +599,8 @@ function Page() { export const Route = createFileRoute('/')({ component: Page }) `) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    page

    ') }, ) @@ -588,7 +622,7 @@ export const Route = createFileRoute('/')({ notFoundComponent: NotFound, }) `) - expect((await route.load('component'))()).toBe('

    h

    ') + expect(await route.render('component')).toBe('

    h

    ') expect(route.exports.helper()).toBe('h') }, ) @@ -621,12 +655,12 @@ export const Route = createFileRoute('/')({ notFoundComponent: NotFound, }) `) - expect((await route.load('component'))()).toBe('

    page

    ') - expect((await route.load('notFoundComponent'))()).toBe('MARK_NF') - const chunk = route.chunkOf('component') + expect(await route.render('component')).toBe('

    page

    ') + expect(await route.render('notFoundComponent')).toBe('MARK_NF') + const loaded = await route.loadedBy('component') expect({ - declares: declaredNames(chunk).has('NotFound'), - marker: chunk.includes('MARK_NF'), + declares: loaded.some((code) => declaredNames(code).has('NotFound')), + marker: loaded.some((code) => code.includes('MARK_NF')), }).toEqual({ declares: false, marker: false }) }, ) @@ -660,9 +694,10 @@ export const Route = createFileRoute('/')({ ), ).toBe(true) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['defaults', '
    defaults
    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'defaults', + '
    defaults
    ', + ]) }) // Ambient declarations (`declare ...`) have no runtime binding: the @@ -711,10 +746,9 @@ export const Route = createFileRoute('/ambient')({ }) `) await expectValidModules(route.modules) - expect([ - route.options.loader(), - (await route.load('component'))(), - ]).toEqual([expected, ``]) + expect([route.options.loader(), await route.render('component')]).toEqual( + [expected, ``], + ) }, ) @@ -723,8 +757,8 @@ export const Route = createFileRoute('/ambient')({ // types reference it. (When only the loader's types do, the chunks import // it: pinned in known-bugs-code-splitter.test.ts.) Source: #8504. it('leaves an import only the split component references from types out of the route module', async () => { - const { modules } = - compileRouteModules(`${head}import { useState } from 'react' + const route = await loadRoute( + `${head}import { useState } from 'react' import { Framework } from './projects' export const Route = createFileRoute('/')({ loader: () => 'data', @@ -733,9 +767,12 @@ export const Route = createFileRoute('/')({ return

    {framework}

    }, }) -`) - expect(importSources(modules.reference!)).not.toContain('./projects') - await expectValidModules(modules) +`, + { stubs: { './projects': { Framework: 'Framework' } } }, + ) + expect(route.eager).not.toContain('./projects') + await expectValidModules(route.modules) + expect(await route.render('component')).toBe('

    react

    ') }) // Source: @babel/plugin-transform-typescript class/private-method-override; @@ -757,7 +794,7 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    3|7

    ') + expect(await route.render('component')).toBe('

    3|7

    ') }) // An ambient var merged with an interface has no runtime declaration: @@ -783,9 +820,10 @@ export const Route = createFileRoute('/')({ { verbatimModuleSyntax }, ) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['1.0', '

    1.0

    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + '1.0', + '

    1.0

    ', + ]) } it('reads an ambient var merged with an interface from the environment in a shared declaration', async () => { @@ -821,7 +859,7 @@ export const Route = createFileRoute('/')({ { stubs: { './lib': { Helper: { x: 'helper' } } } }, ) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('
    helper
    ') + expect(await route.render('component')).toBe('
    helper
    ') }) // Source: Qwik optimizer test.rs example_ts_enums, example_exports @@ -835,7 +873,7 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    LOUD|label:x

    ') + expect(await route.render('component')).toBe('

    LOUD|label:x

    ') }) it.each([ @@ -871,7 +909,7 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe(`

    ${expected}

    `) + expect(await route.render('component')).toBe(`

    ${expected}

    `) }, ) @@ -902,12 +940,7 @@ export const Route = createFileRoute('/posts')({ `export { Post } from '${routeSpecifier}'`, ) expect(other.Post('1')).toEqual({ id: '1' }) - const hmr = await evaluateModule(transformWithRouteHmrPlugin(code), { - ...stubs, - '@tanstack/react-router': { - createFileRoute: () => (options: unknown) => ({ options }), - }, - }) + const hmr = await evaluateModule(transformWithRouteHmrPlugin(code), stubs) expect(hmr.Post!('2')).toEqual({ id: '2' }) }, ) @@ -927,10 +960,8 @@ export const Route = createFileRoute('/')({ { stubs: { './server-thing': { secret: 'server' } } }, ) await expectValidModules(route.modules) - expect(importSources(route.modules.reference!)).not.toContain( - './server-thing', - ) - expect((await route.load('component'))()).toBe('

    server

    ') + expect(route.eager).not.toContain('./server-thing') + expect(await route.render('component')).toBe('

    server

    ') }) it('keeps decorators before `export` for legacy decorator transforms', () => { diff --git a/packages/router-plugin/tests/code-splitter-jsx.test.ts b/packages/router-plugin/tests/code-splitter-jsx.test.ts index 8d3ec4e3423..9140a2d65e7 100644 --- a/packages/router-plugin/tests/code-splitter-jsx.test.ts +++ b/packages/router-plugin/tests/code-splitter-jsx.test.ts @@ -24,11 +24,11 @@ export const Route = createFileRoute('/widget')({ { stubs: { './see': { see: (value: unknown) => rendered.push(value) } } }, ) await expectValidModules(route.modules) - expect([route.isLazy('loader'), route.isLazy('component')]).toEqual([ - true, - true, - ]) - const html = (await route.load('component'))() + expect([ + await route.isLazy('loader'), + await route.isLazy('component'), + ]).toEqual([true, true]) + const html = await route.render('component') const loaded = await route.options.loader() return [html, rendered.length === 1 && rendered[0] === loaded] } @@ -73,6 +73,6 @@ it('splits a component whose JSX text after a fragment starts with an em dash', }) `) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    first — second

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    first — second

    ') }) 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 dbcf7004b66..4892b72a5a1 100644 --- a/packages/router-plugin/tests/code-splitter-module-state.test.ts +++ b/packages/router-plugin/tests/code-splitter-module-state.test.ts @@ -3,7 +3,6 @@ import { declaredNames, expectValidModules, head, - importSources, loadRoute, } from './regression-helpers' import type { CodeSplitGroupings } from '../src/core/constants' @@ -35,9 +34,9 @@ export const Route = createFileRoute('/')({ await expectValidModules(route.modules) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), calls, - ]).toEqual(['A', '
    {"x":1}
    ', 1]) + ]).toEqual(['A', '
    {"x":1}
    ', 1]) }) // Source: Qwik optimizer test.rs root_level_self_referential_qrl, @@ -63,8 +62,8 @@ export const Route = createFileRoute('/')({ ) await expectValidModules(route.modules) expect([ - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('component'), + await route.render('errorComponent'), // Both chunks render the same `A` and the same `B` seen.size, ]).toEqual(['a', 'a', 2]) @@ -86,9 +85,10 @@ export const Route = createFileRoute('/')({ `) await expectValidModules(route.modules) // `#n in counter` only holds for the class that created `counter`. - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [1, '
    true
    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 1, + '
    true
    ', + ]) }) // Source: Turbopack tree-shaker analyzer/nanoid, analyzer/let-bug-1 @@ -102,9 +102,10 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [[0], '

    1

    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + [0], + '

    1

    ', + ]) }) // TypeScript erases type positions, so a declaration the split component @@ -132,7 +133,7 @@ export const Route = createFileRoute('/')({ }, ) await expectValidModules(route.modules) - expect([(await route.load('component'))(), calls]).toEqual([ + expect([await route.render('component'), calls]).toEqual([ '

    object

    ', 1, ]) @@ -180,15 +181,20 @@ export const Route = createFileRoute('/')({ ) await expectValidModules(route.modules) const loaded = await (await route.load('loader'))() - expect([loaded, (await route.load('component'))(), calls]).toEqual([ + expect([loaded, await route.render('component'), calls]).toEqual([ { extra: { flag: true } }, '

    /apix

    ', { init: 1, init2: 1 }, ]) - expect(route.exports.rest).toBe(loaded) - expect(route.exports.base).toBe(seen.base) - expect(route.exports.x).toBe(seen.x) - expect(route.exports.y).toEqual({ id: 'y' }) + // What the rest of the app imports from the route file is what the + // options read. + const imported = await route.importFromRoute( + `export { rest, base, x, y } from '#route'\n`, + ) + expect(imported.rest).toBe(loaded) + expect(imported.base).toBe(seen.base) + expect(imported.x).toBe(seen.x) + expect(imported.y).toEqual({ id: 'y' }) }) // Initializers whose side effects hide in their syntax, read by the loader @@ -217,6 +223,8 @@ const value = new Map()`, } const value = Store`, }, + // Creating the element does not call the component (no effect); the + // loader and the component still read one element. { name: 'JSX of a component that calls an import', declaration: `function Tracked() { @@ -224,12 +232,13 @@ const value = Store`, return 'tracked' } const value = { element: }`, + effects: 0, }, { name: 'a pure-annotated call (control)', declaration: 'const value = /* @__PURE__ */ register()', }, - ])('$name runs once', async ({ declaration }) => { + ])('$name runs once', async ({ declaration, effects: expected = 1 }) => { let effects = 0 const seen: Array = [] const effect = () => { @@ -267,8 +276,8 @@ export const Route = createFileRoute('/')({ ) await expectValidModules(route.modules) route.options.loader() - expect((await route.load('component'))()).toBe('

    1

    ') - expect(effects).toBe(1) + expect(await route.render('component')).toBe('

    1

    ') + expect(effects).toBe(expected) expect(seen).toHaveLength(2) expect(seen[0]).toBe(seen[1]) }) @@ -292,14 +301,14 @@ export const Route = createFileRoute('/')({ `, { stubs: { './next': { next: 'next' } } }, ) - expect(importSources(route.modules.reference!)).not.toContain('./next') + // Nothing the route module loads requests what only the component reads. + expect(route.eager).not.toContain('./next') await expectValidModules(route.modules) - const component = await route.load('component') - expect([route.options.loader(), component(), component()]).toEqual([ - 'data', - '

    initial-next

    ', - '

    next-next

    ', - ]) + expect([ + route.options.loader(), + await route.render('component'), + await route.render('component'), + ]).toEqual(['data', '

    initial-next

    ', '

    next-next

    ']) }) // A lazily initialised getter that replaces itself after the first call. @@ -344,12 +353,11 @@ export const Route = createFileRoute('/')({ }, ) await expectValidModules(route.modules) - const component = await route.load('component') expect([ route.options.loader(), - component(), + await route.render('component'), route.options.loader(), - component(), + await route.render('component'), created, seen.size, ]).toEqual([ @@ -376,9 +384,10 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['data', '

    7

    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'data', + '

    7

    ', + ]) }) }) @@ -559,7 +568,7 @@ ${options.join('\n')} } } if (readers.component) { - results.push((await route.load('component'))()) + results.push(await route.render('component')) } expect(results).toEqual( (['loader', 'beforeLoad', 'component'] as const) @@ -587,12 +596,16 @@ ${options.join('\n')} } } - // A function only the component reads is not declared in the route - // module; a binding only the loader reads is not declared in the - // component's chunk. - const componentChunk = route.isLazy('component') - ? route.chunkOf('component') - : undefined + // A function only the component reads is not declared by what loads + // with the route module; a binding only the loader reads is not + // declared by what loading the component loads. (These observers + // load the route again in a fork: the counts above are final.) + const eagerCode = route.eager + .filter((id) => route.graph.modules.has(id)) + .map((id) => route.graph.modules.get(id)!) + const componentCode = (await route.isLazy('component')) + ? await route.loadedBy('component') + : [] const readBy = (name: string) => (Object.keys(readers) as Array).filter((reader) => readers[reader]!.includes(name), @@ -603,12 +616,16 @@ ${options.join('\n')} readersOfName.join() === 'component' && declarations.includes(`function ${name}(`) ) { - expect(declaredNames(route.modules.reference!), name).not.toContain( + expect( + eagerCode.flatMap((code) => [...declaredNames(code)]), name, - ) + ).not.toContain(name) } - if (readersOfName.join() === 'loader' && componentChunk) { - expect(declaredNames(componentChunk), name).not.toContain(name) + if (readersOfName.join() === 'loader') { + expect( + componentCode.flatMap((code) => [...declaredNames(code)]), + name, + ).not.toContain(name) } } } @@ -645,13 +662,17 @@ export const Route = createFileRoute('/')({ `, { stubs: { './thing': { useThing: (options: unknown) => options } } }, ) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [1, '

    1x

    '], - ) + expect([route.options.loader(), await route.render('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 {} + const seen: Array = [] + const route = await loadRoute( + `${head}import { see } from './see' +export class AppError extends Error {} export class Cache { value = 'cached' } @@ -660,17 +681,23 @@ class Local { } export const Route = createFileRoute('/')({ loader: () => ({ AppError, cached: new Cache().value, local: new Local().value }), - component: () => AppError, + component: () => { + see(AppError) + return

    component

    + }, }) -`) +`, + { stubs: { './see': { see: (value: unknown) => seen.push(value) } } }, + ) await expectValidModules(route.modules) const imported = await route.importFromRoute( `export { AppError, Cache } from '#route'\n`, ) const loaded = route.options.loader() + expect(await route.render('component')).toBe('

    component

    ') expect({ sameInLoader: loaded.AppError === imported.AppError, - sameInComponent: (await route.load('component'))() === imported.AppError, + sameInComponent: seen.length === 1 && seen[0] === imported.AppError, cached: loaded.cached, local: loaded.local, cache: new imported.Cache().value, @@ -729,7 +756,7 @@ export const Route = createFileRoute('/')({ ) await expectValidModules(route.modules) const loaded = route.options.loader() - expect((await route.load('component'))()).toBe(`

    ${loaded}

    `) + expect(await route.render('component')).toBe(`

    ${loaded}

    `) expect(seen).toHaveLength(2) if (loader === component) { expect(seen[0]).toBe(seen[1]) @@ -756,12 +783,13 @@ const value = new Store()`, loader: 'first', component: 'rest', }, + // Each call creates a new element: both read the tag it renders. { name: 'an object read as a JSX member tag', declarations: `const Parts = init({ Title: () => 'title' }) const Header = () => `, - loader: 'Header()', - component: 'Header()', + loader: 'Header().type', + component: 'Header().type', }, { name: 'a helper with a block-scoped local of the same name in one branch', @@ -912,7 +940,7 @@ export const Route = createFileRoute('/')({ component: Panel }) stubs: { './panel': { createPanel: () => ({ - Panel: () => '

    panel

    ', + Panel: () => 'panel', usePanel: () => 'state', }), }, @@ -922,8 +950,8 @@ export const Route = createFileRoute('/')({ component: Panel }) await expectValidModules(route.modules) expect([ route.exports.usePanelState(), - (await route.load('component'))(), - ]).toEqual(['state', '

    panel

    ']) + await route.render('component'), + ]).toEqual(['state', 'panel']) }, ) @@ -949,7 +977,7 @@ export const Route = createFileRoute('/')({ ) await expectValidModules(route.modules) expect([ - (await route.load('component'))(), + await route.render('component'), calls, route.exports.title, ]).toEqual(['

    t

    ', 1, 't']) @@ -984,19 +1012,17 @@ ${options} }, }, ) + // When the store is created is free (initialiser timing is not pinned); + // what loads with the route module loads `./init` only to create it. + const atLoad = inits const rendered = [ - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('component'), + await route.render('errorComponent'), ] - // When the store is created is free (initialiser timing is not pinned); - // the route module imports `./init` only to create it. - const reference = route.modules.reference! expect({ rendered, inits, - importsInitWithoutStore: - importSources(reference).includes('./init') && - !declaredNames(reference).has('store'), + importsInitWithoutStore: route.eager.includes('./init') && atLoad === 0, }).toEqual({ rendered: ['

    shared

    ', '

    shared

    '], inits: 1, @@ -1025,14 +1051,13 @@ ${options} // Whether the route module creates it is free; loading the // notFoundComponent chunk does not (it holds only what it needs, D5). const atLoad = inits - expect((await route.load('notFoundComponent'))()).toBe('

    nf

    ') + expect(await route.render('notFoundComponent')).toBe('

    nf

    ') expect(inits).toBe(atLoad) - expect((await route.load('component'))()).toBe('

    shared

    ') + expect(await route.render('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' @@ -1046,16 +1071,15 @@ export const Route = createFileRoute('/')({ filename: 'route.jsx', stubs: { './store': { createStore: () => ({ get: () => 'v' }) }, - './chart': { - get Chart() { - loaded.push('./chart') - return ({ data }: { data: string }) => `

    ${data}

    ` - }, - }, + './chart': { Chart: ({ data }: { data: string }) => data }, }, }, ) - expect([route.options.loader(), loaded]).toEqual(['v', []]) - expect((await route.load('component'))()).toBe('

    v

    ') + // Nothing the route module loads requests `./chart`. + expect([route.options.loader(), route.eager.includes('./chart')]).toEqual([ + 'v', + false, + ]) + expect(await route.render('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 84d649aea82..18acbe11ba4 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -12,6 +12,7 @@ import { importSources, loadRoute, parseModule, + renderComponent, routeFile, transformWithRouteHmrPlugin, } from './regression-helpers' @@ -131,24 +132,20 @@ export const Route = createFileRoute('/other')({ component: () =>

    }) pluginOptions: { codeSplittingOptions: { splitBehavior } }, }) await expectValidModules(route.modules) - const lazy = routeModuleRequests( - route.modules.reference!, - route.graph.id, - ).lazy - // One lazy module provides the three options. - expect(new Set(lazy.map((request) => request.specifier)).size).toBe(1) - const evaluatedBefore = route.evaluated.length - const values = [] - for (const option of ['loader', 'component', 'errorComponent']) { - expect(route.isLazy(option), option).toBe(true) - values.push(await route.load(option)) - } - expect(route.evaluated.length).toBe(evaluatedBefore + 1) - expect([values[0](), values[1](), values[2]()]).toEqual([ + expect([ + await route.options.loader(), + await route.render('component'), + await route.render('errorComponent'), + ]).toEqual([ { name: 'behavior' }, '

    behavior

    ', '

    error in behavior

    ', ]) + // The three options load lazily, together: loading one makes the + // others available. + expect(await route.groups()).toEqual([ + ['component', 'errorComponent', 'loader'], + ]) }) it('rejects invalid splitBehavior groupings', async () => { @@ -180,10 +177,11 @@ export const Route = createFileRoute('/other')({ component: () =>

    }) ])('loads the split modules of %s', async (filename) => { const route = await loadRoute(routeSource('route'), { filename }) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [{ name: 'route' }, '

    route

    '], - ) + expect(await route.isLazy('component')).toBe(true) + expect([route.options.loader(), await route.render('component')]).toEqual([ + { name: 'route' }, + '

    route

    ', + ]) }) // The plugins of a real `@vitejs/plugin-react` instance, as Vite resolves @@ -253,7 +251,7 @@ export const Route = createFileRoute('/')({ component: () =>

    {label.text}

    , }) `) - expect((await before.load('component'))()).toBe('

    before

    ') + expect(await before.render('component')).toBe('

    before

    ') const after = await load(`${head}import { label } from './label' export const Route = createFileRoute('/')({ loader: () => 'data', @@ -261,23 +259,25 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(after.modules) - expect([after.options.loader(), (await after.load('component'))()]).toEqual( - ['data', '

    imported

    '], - ) + expect([after.options.loader(), await after.render('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. + // Vue routes load with the real vue-router; the Solid router cannot be + // linked in process (its server entry is JSX source for bundlers), so the + // Solid route is checked at compile level here and runs in real Solid + // builds in integration/build-frameworks.test.ts. describe.each(['solid', 'vue'] as const)('a %s route', (framework) => { const routerPackage = `@tanstack/${framework}-router` const otherRouters = ['react', 'solid', 'vue'] .filter((other) => other !== framework) .map((other) => `@tanstack/${other}-router`) // Route hooks return an accessor in Solid and a ref in Vue - const loaderData = - framework === 'solid' - ? { read: 'posts()', value: () => ['a'] } - : { read: 'posts.value', value: { value: ['a'] } } + const read = framework === 'solid' ? 'posts()' : 'posts.value' it.each([ { groupings: undefined, loaderSplit: false }, @@ -285,8 +285,7 @@ export const Route = createFileRoute('/')({ ])( 'loads its split options with groupings $groupings', async ({ groupings, loaderSplit }) => { - const route = await loadRoute( - `import { createFileRoute, Outlet } from '${routerPackage}' + const code = `import { createFileRoute, Outlet } from '${routerPackage}' import { fetchPosts } from './posts' export const Route = createFileRoute('/posts')({ ${groupings ? ` codeSplitGroupings: ${groupings},\n` : ''} loader: fetchPosts, @@ -294,32 +293,32 @@ ${groupings ? ` codeSplitGroupings: ${groupings},\n` : ''} loader: fetchPosts, }) const PostsComponent = () => { const posts = Route.useLoaderData() - return
      {${loaderData.read}.length}
    + return
      {${read}.length}
    } -`, - { - targetFramework: framework, - stubs: { - './posts': { fetchPosts: () => ['post'] }, - [routerPackage]: { - createFileRoute: () => (options: unknown) => ({ - options, - useLoaderData: () => loaderData.value, - }), - }, - }, - }, - ) - await expectValidModules(route.modules) - for (const [name, code] of Object.entries(route.modules)) { +` + const { modules } = compileRouteModules(code, { + targetFramework: framework, + routeId: '/posts', + }) + await expectValidModules(modules) + for (const [name, module] of Object.entries(modules)) { for (const other of otherRouters) { - expect(importSources(code), name).not.toContain(other) + expect(importSources(module), name).not.toContain(other) } } - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('
      1
    ') - expect(route.isLazy('loader')).toBe(loaderSplit) - expect(await route.options.loader()).toEqual(['post']) + if (framework === 'solid') { + return + } + const route = await loadRoute(code, { + targetFramework: framework, + routeId: '/posts', + stubs: { './posts': { fetchPosts: () => ['post'] } }, + }) + expect([ + (await route.renderRoute()).replace(//g, ''), + await route.isLazy('component'), + await route.isLazy('loader'), + ]).toEqual(['
      1
    ', true, loaderSplit]) }, ) }) @@ -496,6 +495,10 @@ export const Route = createFileRoute('/hmr')({ }) } + /** Server-rendered HTML without Vue's and Solid's hydration markers. */ + const html = (rendered: string) => + rendered.replace(/| data-hk="[^"]*"/g, '') + /** Stubs of the router package of `target`. */ const routerStubs = (target: 'solid' | 'vue') => ({ [`@tanstack/${target}-router`]: { @@ -510,6 +513,7 @@ export const Route = createFileRoute('/hmr')({ expect(await getModuleErrors(output)).toEqual([]) let accepted = 0 const { Route } = await evaluateModule(output, routerStubs(target), { + framework: target, importMeta: { hot: { data: {}, @@ -521,7 +525,11 @@ export const Route = createFileRoute('/hmr')({ }, }) expect(accepted).toBe(1) - expect((Route as any).options.component()).toBe('

    hmr

    ') + expect( + html( + await renderComponent((Route as any).options.component, {}, target), + ), + ).toBe('

    hmr

    ') }, ) @@ -541,11 +549,16 @@ export const Route = createFileRoute('/hmr')({ ) expect(await getModuleErrors(output)).toEqual([]) const { Route } = await evaluateModule(output, routerStubs('solid'), { + framework: 'solid', importMeta: { hot: { data: {}, accept: () => {}, dispose: () => {} }, }, }) - expect((Route as any).options.component()).toBe('

    hmr

    ') + expect( + html( + await renderComponent((Route as any).options.component, {}, 'solid'), + ), + ).toBe('

    hmr

    ') }) it('hot-updates a non-React route with webpack HMR', async () => { @@ -659,8 +672,8 @@ export const Route = (() => ) await expectValidModules(loaded.modules) expect({ - rendered: [loaded.options.loader(), (await loaded.load('component'))()], - splitOrReported: loaded.isLazy('component') || reported, + rendered: [loaded.options.loader(), await loaded.render('component')], + splitOrReported: (await loaded.isLazy('component')) || reported, }).toEqual({ rendered: ['data', '

    made

    '], splitOrReported: true }) }, ) @@ -798,14 +811,22 @@ ${route} ), (loaded) => loaded.ids.reference!, ) - expect({ - rendered: [ - await (await loaded.load('loader'))(), - (await loaded.load('component'))(), - ], - splitOrReported: - split.every((option) => loaded.isLazy(option)) || reported, - }).toEqual({ rendered: expected, splitOrReported: true }) + const rendered = [ + await (await loaded.load('loader'))(), + await loaded.render('component'), + ] + let splitOrReported = reported + if (!reported) { + const lazy = [] + for (const option of split) { + lazy.push(await loaded.isLazy(option)) + } + splitOrReported = lazy.every(Boolean) + } + expect({ rendered, splitOrReported }).toEqual({ + rendered: expected, + splitOrReported: true, + }) }, ) diff --git a/packages/router-plugin/tests/code-splitter-pragmas.test.ts b/packages/router-plugin/tests/code-splitter-pragmas.test.ts index 3240df016fa..8f765301aa6 100644 --- a/packages/router-plugin/tests/code-splitter-pragmas.test.ts +++ b/packages/router-plugin/tests/code-splitter-pragmas.test.ts @@ -2,7 +2,7 @@ import { transformWithOxc } from 'vite' import { describe, expect, it } from 'vitest' import { compileRouteModules, - componentChunk, + componentModules, head, loadRoute, transformWithRouteHmrPlugin, @@ -30,6 +30,17 @@ async function jsxRuntimeOf(code: string) { )?.[1] } +/** + * The JSX runtimes the modules loading the route's component import, once + * compiled (modules without JSX import none). + */ +async function componentJsxRuntimes(code: string) { + const runtimes = await Promise.all( + (await componentModules(code)).map(jsxRuntimeOf), + ) + return runtimes.filter((runtime) => runtime !== undefined) +} + // JSX pragmas apply per module: the code splitter runs before the bundler's // JSX transform, which then runs on each split module separately, so a module // without the pragma compiles its JSX with the project default runtime (e.g. @@ -43,27 +54,37 @@ describe('split modules keep file-level JSX pragmas', () => { `/**\n * Header\n * @jsxImportSource @emotion/react\n */\n${head}`, ], ])('keeps @jsxImportSource %s in effect', async (_, header) => { - const chunk = await componentChunk(styledRoute(header)) - expect(await jsxRuntimeOf(chunk)).toBe('@emotion/react/jsx-runtime') + expect(await componentJsxRuntimes(styledRoute(header))).toEqual([ + '@emotion/react/jsx-runtime', + ]) }) it('keeps @jsxImportSource written after the imports', async () => { // Oxc only reads leading pragmas; esbuild and Babel read them anywhere. - const chunk = await componentChunk( + const modules = await componentModules( styledRoute(`${head}/** @jsxImportSource @emotion/react */\n`), ) - expect(chunk).toContain('@jsxImportSource @emotion/react') + const withJsx = modules.filter((code) => code.includes('styled')) + expect(withJsx.length).toBeGreaterThan(0) + for (const code of withJsx) { + expect(code).toContain('@jsxImportSource @emotion/react') + } }) it('keeps @jsxRuntime classic and @jsx pragmas in effect', async () => { - const chunk = await componentChunk( + const modules = await componentModules( styledRoute(`/** @jsxRuntime classic */ /** @jsx jsx */ ${head}import { jsx } from '@emotion/react' `), ) - expect(await jsxRuntimeOf(chunk)).toBeUndefined() - expect(await compileJsx(chunk)).toMatch(/\bjsx\(\s*["']div["']/) + const compiled = await Promise.all(modules.map(compileJsx)) + expect(await Promise.all(modules.map(jsxRuntimeOf))).toEqual( + modules.map(() => undefined), + ) + expect(compiled.some((code) => /\bjsx\(\s*["']div["']/.test(code))).toBe( + true, + ) }) const sharedJsxRoute = `/** @jsxImportSource @emotion/react */ @@ -76,11 +97,14 @@ export const Route = createFileRoute('/pragma')({ it('keeps the pragma in effect for JSX the loader and the component share', async () => { const { modules } = compileRouteModules(sharedJsxRoute) + // Whichever modules hold the shared JSX compile it with the pragma. const holders = Object.values(modules).filter((code) => code.includes(' '@emotion/react/jsx-runtime'), + ) }) // Bug: a split chunk that imports a binding it shares with the route @@ -116,13 +140,17 @@ export const Route = createFileRoute('/pragma')({ // that imports it from the route module: pinned in // known-bugs-code-splitter.test.ts, "classic JSX pragmas".) describe('split modules keep the classic JSX factories their JSX compiles to', () => { - /** Classic JSX factory rendering intrinsic elements to text. */ + /** + * Classic JSX factory rendering intrinsic elements to text (`p(text)`), + * which React renders as is: JSX compiled with React's runtime instead + * renders `

    text

    `. + */ const render = (type: unknown, _props: unknown, ...children: Array) => { const text = children.flat(Infinity).join('') if (typeof type === 'function') { return type() } - return type === Fragment ? text : `<${String(type)}>${text}` + return type === Fragment ? text : `${String(type)}(${text})` } const Fragment = Symbol('Fragment') const stubs = { '@emotion/react': { jsx: render, Frag: Fragment } } @@ -131,27 +159,49 @@ describe('split modules keep the classic JSX factories their JSX compiles to', ( ${head}import { jsx } from '@emotion/react' ` - it('in the route module, when the code calling the factory is split', async () => { - const route = await loadRoute( - `${classicHead}function Page() { + // Bug: the route module the code splitter emits starts with the code it + // adds (the split component's importer and the import of the router's + // lazy helper), above the file's leading pragma comments, and drops the + // factory import once the route module's only use of it is its JSX + // (dead-code elimination does not count a classic JSX pragma as a use of + // its factory: same root cause as the "classic JSX pragmas" pins of + // known-bugs-code-splitter.test.ts). Oxc (Vite 8) only reads leading + // pragmas, so the JSX left in the route module compiles with the + // project's default runtime. Found when the in-process JSX stand-in (which + // compiled every module with its own classic factory) was replaced by + // React's real JSX transform. + // Source: #8504 (this test passed with the stand-in). + // Impact: with Vite 8, JSX that stays in the route module of a file with + // JSX pragmas (a pendingComponent, JSX shared with the loader) ignores them + // as soon as an option is split: Emotion's `css` props are dropped, and a + // classic factory would throw "jsx is not defined" once the pragma is read. + it.fails( + 'in the route module, when the code calling the factory is split', + async () => { + const route = await loadRoute( + `${classicHead}function Page() { return jsx('div', null, 'page') } export const Route = createFileRoute('/')({ component: Page, pendingComponent: () =>

    pending

    , })`, - { stubs }, - ) - expect([ - route.isLazy('pendingComponent'), - route.options.pendingComponent(), - (await route.load('component'))(), - ]).toEqual([false, '

    pending

    ', '
    page
    ']) - }) + { stubs }, + ) + expect([ + await route.render('pendingComponent'), + await route.render('component'), + await route.isLazy('pendingComponent'), + ]).toEqual(['p(pending)', 'div(page)', false]) + }, + ) }) // React Refresh and solid-refresh read these comments per module, so every -// module holding route code must keep them. +// module holding route code must keep them. Compile-level: the Solid router +// cannot be linked in process (its runtime under Solid is covered by +// integration/build-frameworks.test.ts); the component's module is the one +// holding its JSX. describe('split modules keep HMR pragma comments', () => { const solidHead = `import { createFileRoute } from '@tanstack/solid-router'\n` const page = `function Page() { @@ -183,14 +233,21 @@ export const Route = createFileRoute('/')({ component: Page }) code: `${solidHead}// @refresh reload\n${page}`, pragma: '@refresh reload', }, - ] as const)('keeps $name', async ({ target, code, pragma }) => { + ] as const)('keeps $name', ({ target, code, pragma }) => { expect(transformWithRouteHmrPlugin(code, { target })).toContain(pragma) - const route = await loadRoute(code, { + const { modules } = compileRouteModules(code, { hmr: true, targetFramework: target, }) - expect(route.modules.reference).toContain(pragma) - expect(route.chunkOf('component')).toContain(pragma) + expect(modules.reference).toContain(pragma) + const holders = Object.entries(modules).filter( + ([name, module]) => + name !== 'reference' && module.includes('

    page

    '), + ) + expect(holders.length).toBeGreaterThan(0) + for (const [name, module] of holders) { + expect(module, name).toContain(pragma) + } }) }) 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 14fbe3aec4d..e1e415a5896 100644 --- a/packages/router-plugin/tests/code-splitter-route-options.test.ts +++ b/packages/router-plugin/tests/code-splitter-route-options.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { compileRouteModules, declaredNames, + evaluateModule, expectValidModules, generatedRouteId, head, @@ -10,34 +11,13 @@ import { importedNames, loadRoute, parseModule, + renderComponent, } from './regression-helpers' -import type { CodeSplitGroupings } from '../src/core/constants' -/** - * The router package with a route that records how many times it was - * created and answers the hooks split components call on `Route`. - */ -function routeStub(hooks: Record = {}) { - const state = { created: 0 } - const route = (options: unknown) => { - state.created++ - return { options, ...hooks } - } - return { - state, - stubs: { - '@tanstack/react-router': { - createFileRoute: () => route, - createRootRoute: route, - createRootRouteWithContext: () => route, - }, - }, - } -} +import type { CodeSplitGroupings } from '../src/core/constants' describe('route options', () => { it('are split when declared in a variable', async () => { - const { state, stubs } = routeStub({ useLoaderData: () => ['a', 'b'] }) const route = await loadRoute( `${head}import { fetchPosts } from './api' function Posts() { @@ -49,19 +29,21 @@ const options = { } export const Route = createFileRoute('/posts')(options) `, - { stubs: { ...stubs, './api': { fetchPosts: () => 'posts' } } }, + { + routeId: '/posts', + stubs: { './api': { fetchPosts: () => ['a', 'b'] } }, + }, ) const { modules } = route - expect(route.isLazy('component')).toBe(true) + expect(await route.isLazy('component')).toBe(true) expect(declaredNames(modules.reference!)).not.toContain('Posts') - expect(importSources(route.chunkOf('component'))).not.toContain('./api') + expect( + (await route.loadedBy('component')).flatMap(importSources), + ).not.toContain('./api') await expectValidModules(modules) - // The split component reads the route the route module created. - expect([ - route.options.loader(), - (await route.load('component'))(), - state.created, - ]).toEqual(['posts', '
      2
    ', 1]) + // The split component reads the route the router loaded: the one the + // route module created. + expect(await route.renderRoute()).toBe('
      2
    ') }) it.each([ @@ -83,7 +65,7 @@ export const Route = createFileRoute('/posts')(options) await expectValidModules(route.modules) expect(option in route.options).toBe(true) expect(route.options[option]).toBe(value) - expect(route.isLazy(option)).toBe(false) + expect(await route.isLazy(option)).toBe(false) }) // A documented grouping: every option in one chunk. @@ -99,7 +81,7 @@ export const Route = createFileRoute('/posts')(options) }) `) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.render('component')).toBe('

    page

    ') expect([ await (await route.load('loader'))(), route.options.errorComponent, @@ -131,7 +113,11 @@ it('split the component, errorComponent and notFoundComponent, each on its own, 'pendingComponent', ] expect( - Object.fromEntries(options.map((option) => [option, route.isLazy(option)])), + Object.fromEntries( + await Promise.all( + options.map(async (option) => [option, await route.isLazy(option)]), + ), + ), ).toEqual({ loader: false, component: true, @@ -139,17 +125,11 @@ it('split the component, errorComponent and notFoundComponent, each on its own, notFoundComponent: true, pendingComponent: false, }) - const modulesLoaded = [route.evaluated.length] - for (const option of ['component', 'errorComponent', 'notFoundComponent']) { - await route.load(option) - modulesLoaded.push(route.evaluated.length) - } - // One module per lazy option. - expect(modulesLoaded).toEqual([ - modulesLoaded[0], - modulesLoaded[0]! + 1, - modulesLoaded[0]! + 2, - modulesLoaded[0]! + 3, + // Each loads on its own. + expect(await route.groups()).toEqual([ + ['component'], + ['errorComponent'], + ['notFoundComponent'], ]) }) @@ -162,9 +142,9 @@ it('split the component, errorComponent and notFoundComponent, each on its own, // function-declaration and conditional-properties. describe('route options given by reference', () => { const page = { - default: () => '

    page

    ', - ErrorView: () => '

    error

    ', - Pending: () => '

    pending

    ', + default: () => 'page', + ErrorView: () => 'error', + Pending: () => 'pending', loadData: () => 'data', fallback: () => 'fallback', } @@ -182,11 +162,11 @@ export const Route = createFileRoute('/')({ pendingComponent: Pending, })`, expected: { - loader: page.loadData, - component: page.default, - errorComponent: page.ErrorView, - pendingComponent: page.Pending, + component: 'page', + errorComponent: 'error', + pendingComponent: 'pending', }, + loaded: 'data', }, { name: 'a loader declared as a local function', @@ -199,7 +179,7 @@ export const Route = createFileRoute('/')({ loader: load, component: Page, })`, - expected: { component: page.default }, + expected: { component: 'page' }, loaded: 'data', }, { @@ -211,7 +191,7 @@ export const Route = createFileRoute('/')({ loader: isEnabled ? loadData : fallback, component: Page, })`, - expected: { component: page.default }, + expected: { component: 'page' }, loaded: 'data', }, ])('split $name', async ({ code, expected, loaded }) => { @@ -219,18 +199,17 @@ export const Route = createFileRoute('/')({ stubs: { './page': page, './flags': { isEnabled: true } }, }) await expectValidModules(route.modules) - expect(importSources(route.modules.reference!)).not.toContain('./page') - const lazy = Object.keys(expected).filter((option) => route.isLazy(option)) - expect([...lazy, ...(loaded ? [route.isLazy('loader')] : [])]).toEqual([ - ...Object.keys(expected), - ...(loaded ? [true] : []), - ]) - for (const [option, value] of Object.entries(expected)) { - expect(await route.load(option), option).toBe(value) + // Nothing the route module loads requests what only split options read. + expect(route.eager).not.toContain('./page') + const lazy = [] + for (const option of [...Object.keys(expected), 'loader']) { + lazy.push(await route.isLazy(option)) } - if (loaded) { - expect(await route.options.loader()).toBe(loaded) + expect(lazy).toEqual(lazy.map(() => true)) + for (const [option, html] of Object.entries(expected)) { + expect(await route.render(option), option).toBe(html) } + expect(await route.options.loader()).toBe(loaded) }) it('load the module a split loader is imported from with the loader only', async () => { @@ -254,7 +233,7 @@ export const Route = createFileRoute('/')({ }, }, ) - expect(route.isLazy('loader')).toBe(true) + expect(await route.isLazy('loader')).toBe(true) const before = [...log] expect([before, await route.options.loader(), log]).toEqual([ [], @@ -275,16 +254,13 @@ export const Route = createFileRoute('/')({ component: () =>

    page

    , }) `) - expect(route.isLazy('loader')).toBe(true) + expect(await route.isLazy('loader')).toBe(true) expect(await route.options.loader()).toBe('data') expect(warn.mock.calls).toEqual([]) }) }) describe('route option values', () => { - const react = { - memo: (component: unknown) => component, - } it.each([ { name: 'memo() of a local component', @@ -315,7 +291,7 @@ ${head}import { importedComponent } from './imported' export const Route = createFileRoute('/')({ component: React.memo(importedComponent), })`, - rendered: '
    imported
    ', + rendered: 'imported', }, { name: 'a lowercase function declaration', @@ -348,7 +324,7 @@ export const Route = createFileRoute('/posts')({ component: isEnabled ? TrueImport.Component : FalseComponent, loader: isEnabled ? TrueImport.loader : falseLoader, })`, - rendered: '
    false
    ', + rendered: 'false', loaded: 'false-loader', }, ])( @@ -356,23 +332,22 @@ export const Route = createFileRoute('/posts')({ async ({ code, rendered, others = {}, loaded }) => { const route = await loadRoute(`${code}\n`, { stubs: { - react: { default: react, ...react }, - './imported': { importedComponent: () => '
    imported
    ' }, + './imported': { importedComponent: () => 'imported' }, './feature-flags': { isEnabled: false }, './true-component': { default: { Component: () => 'true', loader: () => 'true-loader' }, }, './false-component': { - FalseComponent: () => '
    false
    ', + FalseComponent: () => 'false', falseLoader: () => 'false-loader', }, }, }) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe(rendered) + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe(rendered) for (const [option, html] of Object.entries(others)) { - expect((await route.load(option))()).toBe(html) + expect(await route.render(option)).toBe(html) } if (loaded) { expect(route.options.loader()).toBe(loaded) @@ -397,18 +372,21 @@ export const Route = createFileRoute('/posts')({ default: { Component: () => 'true', loader: () => 'true-loader' }, }, './false-component': { - FalseComponent: () => '
    false
    ', + FalseComponent: () => 'false', falseLoader: () => 'false-loader', }, }, }, ) expect( - importedNames(route.chunkOf('component'), './false-component'), + (await route.loadedBy('component')).flatMap((code) => + importedNames(code, './false-component'), + ), ).not.toContain('falseLoader') - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['false-loader', '
    false
    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'false-loader', + 'false', + ]) }) // TypeScript erases the cast: the value is the component. @@ -419,8 +397,8 @@ export const Route = createFileRoute('/posts')({ export const Route = createFileRoute('/')({ component: Page as any }) `) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    page

    ') }) // Whether such options are split is pinned in known-bugs-code-splitter.test.ts @@ -448,7 +426,7 @@ export const Route = createFileRoute('/about')({ createBits: () => { calls++ return { - component: () => '
    About
    ', + component: () => 'About', loader: () => ({ message: 'hello' }), } }, @@ -459,9 +437,9 @@ export const Route = createFileRoute('/about')({ await expectValidModules(route.modules) expect([ (await route.load('loader'))(), - (await route.load('component'))(), + await route.render('component'), calls, - ]).toEqual([{ message: 'hello' }, '
    About
    ', 1]) + ]).toEqual([{ message: 'hello' }, 'About', 1]) }) // Splitting a component written as a method is pinned in @@ -486,11 +464,11 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect(route.isLazy('errorComponent')).toBe(true) + expect(await route.isLazy('errorComponent')).toBe(true) expect([ route.options.loader().count, - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('component'), + await route.render('errorComponent'), ]).toEqual([1, '
    component 1
    ', '
    error
    ']) }) @@ -516,9 +494,9 @@ export const Route = createFileRoute('/')({ ['component']: C })`, ])('render a component given through $name', async ({ code, split }) => { const route = await loadRoute(`${head}${code}\n`) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    C

    ') + expect(await route.render('component')).toBe('

    C

    ') if (split) { - expect(route.isLazy('component')).toBe(true) + expect(await route.isLazy('component')).toBe(true) } }) }) @@ -530,8 +508,8 @@ describe('route factories', () => { component: () =>

    spaced

    , }) `) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    spaced

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    spaced

    ') }) // Source: utils.test.ts routeFactoryCallCodeFilter (type arguments and @@ -572,7 +550,7 @@ export const Route = createRootRouteWithContext()({ { hmr: true }, ) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    root

    ') + expect(await route.render('component')).toBe('

    root

    ') }) }) @@ -587,33 +565,20 @@ ${groupings} loader: () => 'data', ` /** - * Which options load lazily, grouped by the module they load: options - * loaded together load one module. + * Which options load lazily, grouped by what loads together (`groups`), + * once the route still works. */ async function lazyGroups( code: string, pluginOptions: Parameters[1] = {}, ) { const loaded = await loadRoute(code, pluginOptions) - const groups: Array> = [] - for (const option of ['loader', 'component', 'errorComponent']) { - if (!loaded.isLazy(option)) { - continue - } - const before = loaded.evaluated.length - await loaded.load(option) - if (loaded.evaluated.length > before) { - groups.push([option]) - } else { - groups.at(-1)!.push(option) - } - } expect([ - (await loaded.load('loader'))(), - (await loaded.load('component'))(), - (await loaded.load('errorComponent'))(), + await loaded.options.loader(), + await loaded.render('component'), + await loaded.render('errorComponent'), ]).toEqual(['data', '

    page

    ', '

    error

    ']) - return groups + return loaded.groups() } it('of the route split the options of a group into one module', async () => { @@ -621,7 +586,7 @@ ${groupings} loader: () => 'data', await lazyGroups( route(` codeSplitGroupings: [['loader', 'component']],\n`), ), - ).toEqual([['loader', 'component']]) + ).toEqual([['component', 'loader']]) }) it('of the route split nothing when empty', async () => { @@ -652,10 +617,7 @@ export const Route = createFileRoute('/')({ expect(String(error)).toMatch(/codeSplitGroupings/) return } - expect([loaded.isLazy('loader'), loaded.isLazy('component')]).toEqual([ - true, - true, - ]) + expect(await loaded.groups()).toEqual([['component'], ['loader']]) }) const defaultBehavior: CodeSplitGroupings = [['loader', 'errorComponent']] @@ -669,12 +631,12 @@ export const Route = createFileRoute('/')({ { name: 'splitBehavior over defaultBehavior', splitBehavior: () => [['loader', 'component']] as CodeSplitGroupings, - expected: [['loader', 'component']], + expected: [['component', 'loader']], }, { name: 'defaultBehavior when splitBehavior returns undefined', splitBehavior: () => undefined, - expected: [['loader', 'errorComponent']], + expected: [['errorComponent', 'loader']], }, ])('apply $name', async ({ groupings, splitBehavior, expected }) => { expect( @@ -687,9 +649,10 @@ export const Route = createFileRoute('/')({ }) }) -// Windows checkouts with `autocrlf` give route files CRLF line endings: -// template literals and JSX text keep their values (a template literal's -// line break is LF in its value, whatever the file's line ending). +// Windows checkouts with `autocrlf` give route files CRLF line endings: the +// split route keeps the values the file has untouched (a template literal's +// line break is LF in its value, whatever the file's line ending; JSX text +// and attributes as the JSX transform reads them). it('keeps the runtime values of a route file with CRLF line endings', async () => { const lines = (code: string) => code.replaceAll('\n', '\r\n') const source = `${head}const banner = \`first @@ -705,16 +668,18 @@ b"> ), }) ` - const [lf, crlf] = await Promise.all( - [source, lines(source)].map(async (code) => { - const route = await loadRoute(code) - await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - return [route.options.loader(), (await route.load('component'))()] - }), - ) - expect(crlf).toEqual(lf) - expect(lf![0]).toBe('first\nsecond') + for (const code of [source, lines(source)]) { + const route = await loadRoute(code) + await expectValidModules(route.modules) + expect(await route.isLazy('component')).toBe(true) + const untouched = ((await evaluateModule(code)) as Record) + .Route.options + expect([route.options.loader(), await route.render('component')]).toEqual([ + untouched.loader(), + await renderComponent(untouched.component), + ]) + expect(route.options.loader()).toBe('first\nsecond') + } }) // Source: TanStack/router#2361 and #3183, where non-ASCII text in a route @@ -729,8 +694,8 @@ export const Route = createFileRoute('/')({ component: () => Usuário 🤡, }) `) - expect(route.isLazy('component')).toBe(true) - expect([route.options.loader(), (await route.load('component'))()]).toEqual([ + expect(await route.isLazy('component')).toBe(true) + expect([route.options.loader(), await route.render('component')]).toEqual([ { data: 'Usuário🤠' }, '
    标题: Usuário 🤡
    ', ]) @@ -754,10 +719,6 @@ describe('split options that read the Route singleton', () => { ])( 'keep in the route module what only the loader reads, next to $name', async ({ component }) => { - const { stubs } = routeStub({ - useLoaderData: () => [1, 2], - useSearch: () => ({ page: 2 }), - }) const route = await loadRoute( `${head}const format = (value: unknown) => String(value) const prefix = 'post:' @@ -769,23 +730,25 @@ export const Route = createFileRoute('/posts')({ component: ${component}, }) `, - { stubs }, + { routeId: '/posts' }, ) const { modules } = route - const chunk = route.chunkOf('component') - expect(declaredNames(chunk)).not.toContain('fetchPosts') - expect(declaredNames(chunk)).not.toContain('prefix') + const chunk = (await route.loadedBy('component')).flatMap((code) => [ + ...declaredNames(code), + ]) + expect(chunk).not.toContain('fetchPosts') + expect(chunk).not.toContain('prefix') await expectValidModules(modules) + // The loader's data has two items; the search has `page=2`. expect([ route.options.loader(), - (await route.load('component'))(), + await route.renderRoute({ at: '/posts?page=2' }), ]).toEqual([[['post:1'], '0'], '

    2

    ']) }, ) // An imported binding is read-only: `label = ...` would throw at runtime. it('keep a reassigned loader-only binding writable', async () => { - const { stubs } = routeStub({ useLoaderData: () => 'loader data' }) const route = await loadRoute( `${head}let label = 'initial' label = 'updated' @@ -794,12 +757,10 @@ export const Route = createFileRoute('/label')({ component: () =>

    {Route.useLoaderData()}

    , }) `, - { stubs }, + { routeId: '/label' }, ) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['updated', '

    loader data

    '], - ) + expect(await route.renderRoute()).toBe('

    updated

    ') }) // That the loader and the component share one `cache` is pinned in @@ -814,9 +775,10 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [0, '
    0function
    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 0, + '
    0function
    ', + ]) }) it.each([ @@ -836,7 +798,6 @@ const HEADER = 'Page'`, ])( 'give the loader and the component a helper that reads Route $name, created once', async ({ helpers }) => { - const { state, stubs } = routeStub({ fullPath: '/about' }) const route = await loadRoute( `${head}${helpers} export const Route = createFileRoute('/about')({ @@ -845,19 +806,20 @@ export const Route = createFileRoute('/about')({ errorComponent: () =>

    {usePageTitle()}

    , }) `, - { stubs }, + { routeId: '/about' }, ) await expectValidModules(route.modules) + // The router gives the route it loads its path: every option reads it + // from that one route. expect([ + await route.renderRoute(), await route.options.loader(), - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('errorComponent'), ]).toEqual([ - { title: 'Page - /about' }, '
    Page - /about
    ', + { title: 'Page - /about' }, '

    Page - /about

    ', ]) - expect(state.created).toBe(1) }, ) }) @@ -894,9 +856,9 @@ export const Route = createRootRoute({ await expectValidModules(route.modules) expect([ route.options.beforeLoad(), - (await route.load('component'))(), - (await route.load('errorComponent'))(), - (await route.load('notFoundComponent'))(), + await route.render('component'), + await route.render('errorComponent'), + await route.render('notFoundComponent'), calls, ]).toEqual([42, '
    42
    ', '

    error 42

    ', '

    not found

    ', 1]) }) @@ -924,9 +886,10 @@ registerRoute(Route) }, }, ) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.render('component')).toBe('

    page

    ') expect(registered).toEqual([route.Route]) + // (After the check: `isLazy` evaluates the route module again in a fork.) + expect(await route.isLazy('component')).toBe(true) }) // A directive in a function body applies to that function, wherever it @@ -942,8 +905,9 @@ export const Route = createFileRoute('/')({ }) `) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    chart

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    chart

    ') + // Every module that declares `Chart` keeps the directive first in its // body. const declaring = Object.entries(route.modules).flatMap(([name, code]) => diff --git a/packages/router-plugin/tests/code-splitter-scope.test.ts b/packages/router-plugin/tests/code-splitter-scope.test.ts index 524f47ce4dd..aced19fb6c6 100644 --- a/packages/router-plugin/tests/code-splitter-scope.test.ts +++ b/packages/router-plugin/tests/code-splitter-scope.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import { expectValidModules, head, importSources, loadRoute, } from './regression-helpers' -import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' const pageRoute = (body: string) => `${head}${body} export const Route = createFileRoute('/')({ component: Page }) @@ -143,8 +143,8 @@ const Page = () =>

    {getComponentName({ Page })}

    `, ])('$name', async ({ body, expected }) => { const route = await loadRoute(pageRoute(body)) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))({})).toBe(expected) + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe(expected) }) }) @@ -160,6 +160,7 @@ describe('names a route option declares itself do not reference module bindings' return
    {label}{obj.a(1)}
    },`, unread: 'component', + rendered: '
    local1
    ', }, { // Source: Next.js ssg/getServerSideProps/query-usage @@ -169,12 +170,14 @@ describe('names a route option declares itself do not reference module bindings' return label }, component: () =>
    {label}{format()}
    ,`, + rendered: '
    moduleformat
    ', }, { // Source: Next.js ssg/getServerSideProps/query-usage name: 'destructured parameters', options: `loader: ({ label }: any) => [label, format()], component: ({ format }: any) =>
    {format}{label}
    ,`, + rendered: '
    module
    ', }, { // Source: Qwik optimizer test.rs should_not_auto_export_var_shadowed_in_{catch,do_while,switch,labeled_block} @@ -189,8 +192,9 @@ describe('names a route option declares itself do not reference module bindings' return

    {i}

    },`, unread: 'component', + rendered: '

    3

    ', }, - ])('$name', async ({ options, unread }) => { + ])('$name', async ({ options, unread, rendered }) => { vi.spyOn(console, 'log').mockImplementation(() => {}) onTestFinished(() => { vi.restoreAllMocks() @@ -216,15 +220,18 @@ export const Route = createFileRoute('/')({ }, ) await expectValidModules(route.modules) - // The option's own names do not make the split component's module load + route.options.loader({}) + expect(await route.render('component')).toBe(rendered) + expect(loads).toBe(1) + // The option's own names do not make the split component's modules load // the module-level `label`, which only the loader reads. (When only the - // split component reads it, when its initializer runs is free.) + // split component reads it, when its initializer runs is free.) (After + // the count: `loadedBy` evaluates the route module again in a fork.) if (unread) { - expect(importSources(route.chunkOf(unread))).not.toContain('./load') + expect( + (await route.loadedBy(unread)).flatMap(importSources), + ).not.toContain('./load') } - route.options.loader({}) - expect(typeof (await route.load('component'))({})).toBe('string') - expect(loads).toBe(1) }) // Source: Next.js ssg/getStaticProps/should-not-remove-import-used-in-render @@ -252,7 +259,7 @@ export const Route = createFileRoute('/')({ './ui': { A: () => 'A', B: { - Deep: { C: ({ icon }: { icon: string }) => `${icon}` }, + Deep: { C: ({ icon }: { icon: unknown }) => icon }, }, }, './load': { @@ -264,18 +271,23 @@ export const Route = createFileRoute('/')({ }, }, ) - expect(importSources(route.modules.reference!)).not.toContain('./ui') - expect(importSources(route.chunkOf('component'))).not.toContain('./load') + // Nothing the route module loads requests what only the component reads. + expect(route.eager).not.toContain('./ui') await expectValidModules(route.modules) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), loads, ]).toEqual([ ['loaded', 'loaded', 'loaded'], - '

    homeA

    ', + '

    homeA

    ', 3, ]) + // (After the count: `loadedBy` evaluates the route module again in a + // fork.) + expect( + (await route.loadedBy('component')).flatMap(importSources), + ).not.toContain('./load') }) // Source: React Router route-chunks-test.ts "computed object property" @@ -295,10 +307,13 @@ export const Route = createFileRoute('/')({ { stubs: { './load': { load: () => 'loaded' } } }, ) await expectValidModules(route.modules) - expect(importSources(route.chunkOf('component'))).not.toContain('./load') - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [['loaded', 'kind'], '
    12
    '], - ) + expect( + (await route.loadedBy('component')).flatMap(importSources), + ).not.toContain('./load') + expect([route.options.loader(), await route.render('component')]).toEqual([ + ['loaded', 'kind'], + '
    12
    ', + ]) }) it('neither do declarations in helpers that move into the chunk, named like a shared binding', async () => { @@ -331,9 +346,10 @@ export const Route = createFileRoute('/')({ }, ) await expectValidModules(route.modules) - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - ['shared', '

    var,function,class,enum,shared

    '], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 'shared', + '

    var,function,class,enum,shared

    ', + ]) }) }) @@ -394,9 +410,10 @@ export const Route = createFileRoute('/')({ `, { stubs: { './x': { x: 'x' } } }, ) - expect(importSources(route.modules.reference!)).not.toContain('./x') + // Nothing the route module loads requests what only the component reads. + expect(route.eager).not.toContain('./x') await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe(`

    ${rendered}

    `) + expect(await route.render('component')).toBe(`

    ${rendered}

    `) }) it.each([ @@ -440,10 +457,9 @@ export const Route = createFileRoute('/')({ loader: ${loader}, component: Page } { stubs: { './store': { store: { value: 'stored' } } } }, ) await expectValidModules(route.modules) - expect([ - route.options.loader({}), - (await route.load('component'))(), - ]).toEqual(['stored', 'page']) + expect([route.options.loader({}), await route.render('component')]).toEqual( + ['stored', 'page'], + ) }) }) @@ -465,9 +481,9 @@ export const Route = createFileRoute('/js')({ ) await expectValidModules(route.modules) expect([ - route.isLazy('component'), + await route.isLazy('component'), route.options.loader(), - (await route.load('component'))(), + await route.render('component'), ]).toEqual([true, 'label', '

    label

    ']) }, ) @@ -487,8 +503,8 @@ function Page() { { filename: 'route.js' }, ) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('body,second,first') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('body,second,first') }) }) @@ -508,8 +524,9 @@ export const Route = createFileRoute('/')({ for (const [name, code] of Object.entries(route.modules)) { expect(await getModuleErrors(code, 'module.ts'), name).toEqual([]) } - expect([route.options.loader(), (await route.load('component'))()]).toEqual( - [1, 'page'], - ) + expect([route.options.loader(), await route.render('component')]).toEqual([ + 1, + 'page', + ]) }) }) 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 591887d0c40..c7022e4b361 100644 --- a/packages/router-plugin/tests/known-bugs-code-splitter.test.ts +++ b/packages/router-plugin/tests/known-bugs-code-splitter.test.ts @@ -8,8 +8,6 @@ import { describe, expect, onTestFinished, test, vi } from 'vitest' import { compileRouteModules, declaredNames, - eagerImportSources, - eagerModuleIds, evaluateModule, expectValidModules, head, @@ -18,6 +16,7 @@ import { importedNames, loadRoute, parseModule, + renderComponent, routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' @@ -38,6 +37,17 @@ function reportsNotSplit( }) } +/** + * The code of each compiled module that loads with the route module (the + * route module and what it imports statically, facades included). + */ +function eagerModules(route: Awaited>) { + return route.eager.flatMap((id) => { + const code = route.graph.modules.get(id) + return code === undefined ? [] : [code] + }) +} + /** Stubs `./state`, counting the calls of `init` and `register`. */ function stateStub() { const state = { @@ -73,8 +83,8 @@ export const Route = createFileRoute('/')({ const callsBeforeChunks = state.calls expect([ route.options.loader(), - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('component'), + await route.render('errorComponent'), callsBeforeChunks, state.calls, ]).toEqual([1, '

    1

    ', '

    error

    ', 1, 1]) @@ -102,7 +112,7 @@ export const Route = createFileRoute('/')({ `, { stubs: { './state': state } }, ) - expect([(await route.load('component'))(), state.calls]).toEqual([ + expect([await route.render('component'), state.calls]).toEqual([ '

    1

    ', 1, ]) @@ -172,7 +182,7 @@ export const Route = createFileRoute('/')({ ) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), state.calls, ]).toEqual([1, '

    1

    ', 1]) }, @@ -289,7 +299,7 @@ export const Route = createFileRoute('/')({ ) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), state.calls, ]).toEqual([...expected, inits]) }, @@ -400,10 +410,7 @@ ${options} component: () =>

    ${render}

    , })`, { stubs: { './state': state } }, ) - expect([(await route.load('component'))(), state.calls]).toEqual([ - html, - 1, - ]) + expect([await route.render('component'), state.calls]).toEqual([html, 1]) }, ) @@ -435,15 +442,13 @@ export const Route = createFileRoute('/')({ }, ) const callsBeforeChunks = calls - const loaded: Array = [] - for (const option of ['component', 'errorComponent']) { - if (typeof (await route.load(option)) === 'function') { - loaded.push(option) - } - } + const rendered = [ + await route.render('component'), + await route.render('errorComponent'), + ] // Both chunks loaded, and the effect ran once, with the route module. - expect([loaded.join(), callsBeforeChunks, calls]).toEqual([ - 'component,errorComponent', + expect([rendered, callsBeforeChunks, calls]).toEqual([ + ['

    index

    ', '

    error

    '], 1, 1, ]) @@ -578,16 +583,14 @@ export const v = x`, .map(({ specifier }) => specifier) expect({ reExported: importer.v, - rendered: (await route.load('component'))(), - chunksRequestingLib: Object.keys(route.modules).filter( - (name) => - name.startsWith('lazy ') && - requested(route.modules[name]!).includes('./lib'), - ), + rendered: await route.render('component'), + componentRequestsLib: (await route.loadedBy('component')) + .flatMap(requested) + .includes('./lib'), }).toEqual({ reExported: 1, rendered: '

    index

    ', - chunksRequestingLib: [], + componentRequestsLib: false, }) }, ) @@ -611,7 +614,7 @@ export const Route = createFileRoute('/')({ })`) expect([ loaded.options.loader(), - (await loaded.load('component'))(), + await loaded.render('component'), ]).toEqual(['1.0', '

    1.0

    ']) } finally { vi.unstubAllGlobals() @@ -658,7 +661,7 @@ export const Route = createFileRoute('/')({ }, }, ) - expect([calls, (await route.load('component'))()]).toEqual([ + expect([calls, await route.render('component')]).toEqual([ registered, '

    component

    ', ]) @@ -688,9 +691,6 @@ export const Route = createFileRoute('/')({ return value }, }, - '@tanstack/react-router': { - createFileRoute: () => (options: unknown) => ({ options }), - }, }, ) expect(calls).toEqual(['analytics']) @@ -709,8 +709,8 @@ describe('page code left in the route module', () => { stubs: { './heavy': { heavy: () => 'heavy' } }, }) return { - heavyLoadsEagerly: eagerImportSources(route).includes('./heavy'), - rendered: (await route.load('component'))(), + heavyLoadsEagerly: route.eager.includes('./heavy'), + rendered: await route.render('component'), } } @@ -834,12 +834,11 @@ export const Route = createFileRoute('/')({ component: () =>

    index

    , }) `) - // The route module and what it imports statically load with the entry. - const eager = eagerModuleIds(route).map( - (id) => route.graph.modules.get(id)!, - ) + // What loads with the route module loads with the entry. expect({ - eagerHandler: eager.some((code) => code.includes('MARK_handler')), + eagerHandler: eagerModules(route).some((code) => + code.includes('MARK_handler'), + ), loaded: await route.options.loader.handler(), }).toEqual({ eagerHandler: false, loaded: 'MARK_handler' }) }, @@ -940,10 +939,10 @@ export const Route = createFileRoute('/')({ expect({ loaded: await loaderHandler(route.options.loader)(), staleReloadMode: route.options.loader.staleReloadMode, - rendered: (await route.load('component'))(), + rendered: await route.render('component'), created, ...('apiLoadsEagerly' in expected && { - apiLoadsEagerly: eagerImportSources(route).includes('./api'), + apiLoadsEagerly: route.eager.includes('./api'), }), }).toEqual(expected) }, @@ -987,8 +986,8 @@ function Page() { return

    page

    } `) - expect(loaded.isLazy('component')).toBe(true) - expect((await loaded.load('component'))()).toBe('

    page

    ') + expect(await loaded.isLazy('component')).toBe(true) + expect(await loaded.render('component')).toBe('

    page

    ') }) // Controls for the pins above and in "route factories written in other @@ -1006,7 +1005,7 @@ const makeOptions = () => ({ component: Page }) export const Route = ${route} `) await expectValidModules(loaded.modules) - expect((await loaded.load('component'))()).toBe('

    page

    ') + expect(await loaded.render('component')).toBe('

    page

    ') }) // Bug: the virtual compiler accepts only a fixed list of expression types @@ -1025,41 +1024,35 @@ export const Route = ${route} value: string declarations?: string filename?: string - render: (value: any) => unknown expected: unknown }>([ { name: 'a member of a namespace import', value: 'Pages.Home', - render: (component) => component(), - expected: '

    home

    ', + expected: 'home', }, { name: 'a member of a local object (loader)', option: 'loader', value: 'api.load', - render: (loader) => loader(), expected: 'data', }, { name: 'a logical fallback', declarations: `const overrides: { Home?: () => string } = {}\n`, value: 'overrides.Home || Fallback', - render: (component) => component(), - expected: '

    fallback

    ', + expected: 'fallback', }, { name: 'a nullish fallback', declarations: `const overrides: { Home?: () => string } = {}\n`, value: 'overrides.Home ?? Fallback', - render: (component) => component(), - expected: '

    fallback

    ', + expected: 'fallback', }, { name: 'a tagged template', value: 'styled`home`', - render: (component) => component(), - expected: '

    styled home

    ', + expected: 'styled home', }, { // JavaScript: the route types of React accept function components @@ -1072,7 +1065,6 @@ export const Route = ${route} return

    class

    } }`, - render: (component) => new component().render(), expected: '

    class

    ', }, ])( @@ -1082,13 +1074,12 @@ export const Route = ${route} value, declarations = '', filename, - render, expected, }) => { const route = await loadRoute( `${head}import * as Pages from './pages' import { styled } from './styled' -${declarations}const Fallback = () => '

    fallback

    ' +${declarations}const Fallback = () => 'fallback' const api = { load: () => 'data' } export const Route = createFileRoute('/')({ codeSplitGroupings: [['loader'], ['component']], @@ -1099,17 +1090,22 @@ export const Route = createFileRoute('/')({ { filename, stubs: { - react: { Component: class {} }, - './pages': { Home: () => '

    home

    ' }, + './pages': { Home: () => 'home' }, './styled': { styled: (strings: TemplateStringsArray) => () => - `

    styled ${strings[0]}

    `, + `styled ${strings[0]}`, }, }, }, ) - expect(route.isLazy(option)).toBe(true) - expect(render(await route.load(option))).toBe(expected) + expect(await route.isLazy(option)).toBe(true) + expect( + option === 'loader' + ? await ( + await route.load('loader') + )() + : await route.render(option), + ).toBe(expected) }, ) @@ -1130,25 +1126,23 @@ export const Route = createFileRoute('/')({ test.fails.each<{ name: string component: string - render: (component: any) => unknown + rendered?: string imports?: string targetFramework?: TargetFramework }>([ { name: 'memo of an inline arrow', component: `memo(() =>

    page

    )`, - render: (component) => component(), }, { name: 'memo of an inline named function', component: `memo(function Page() {\n return

    page

    \n})`, - render: (component) => component(), }, { name: 'a call with a JSX argument', imports: `${head}import { withFallback } from './hoc'\nimport { Page } from './page'`, component: `withFallback(Page,

    loading

    )`, - render: (component) => component(), + rendered: 'page', }, { name: 'a Vue defineComponent with a render function', @@ -1156,31 +1150,30 @@ export const Route = createFileRoute('/')({ imports: `import { createFileRoute } from '@tanstack/vue-router' import { defineComponent } from 'vue'`, component: `defineComponent({\n setup: () => () =>

    page

    ,\n})`, - render: (component) => component.setup()(), }, { name: 'a call of a typed arrow function', - component: `memo((): string => '

    page

    ')`, - render: (component) => component(), + component: `memo((): string => 'page')`, + rendered: 'page', }, { name: 'a call with a type assertion', imports: `${head}import { memo } from 'react'\nimport { Page } from './page'`, component: `memo(Page as any)`, - render: (component) => component(), + rendered: 'page', }, { name: 'a call with a type argument', imports: `${head}import { memo } from 'react'\nimport { Page } from './page'`, component: `memo(Page)`, - render: (component) => component(), + rendered: 'page', }, ])( 'a component written as $name is split and renders', async ({ imports = `${head}import { memo } from 'react'`, component, - render, + rendered = '

    page

    ', targetFramework, }) => { const route = await loadRoute( @@ -1190,15 +1183,13 @@ export const Route = createFileRoute('/')({ component: ${component} }) { targetFramework, stubs: { - react: { memo: (value: unknown) => value }, - vue: { defineComponent: (value: unknown) => value }, './hoc': { withFallback: (component: unknown) => component }, - './page': { Page: () => '

    page

    ' }, + './page': { Page: () => 'page' }, }, }, ) - expect(route.isLazy('component')).toBe(true) - expect(render(await route.load('component'))).toBe('

    page

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe(rendered) }, ) }) @@ -1219,8 +1210,8 @@ function Page() { return

    page

    } `) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    page

    ') }, ) @@ -1238,8 +1229,8 @@ function Page() { } export const Route = createFileRoute<'/'>('/')({ component: Page }) `) - expect(route.isLazy('component')).toBe(true) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.isLazy('component')).toBe(true) + expect(await route.render('component')).toBe('

    page

    ') }, ) @@ -1259,9 +1250,9 @@ export const Route = createFileRoute<'/'>('/')({ component: Page }) const makeOptions = () => ({ component: Page }) export const Route = createFileRoute('/')(makeOptions()) `) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.render('component')).toBe('

    page

    ') expect({ - split: route.isLazy('component'), + split: await route.isLazy('component'), warned: reportsNotSplit(warn, route.ids.reference!), }).not.toEqual({ split: false, warned: false }) } finally { @@ -1312,11 +1303,11 @@ export const Route = createFileRoute('/')({ /** Renders a route option, loading it first when it is lazy. */ const render = async (component: any) => { await component.preload?.() - return component() + return renderComponent(component) } expect([ await render(route.exports.Legacy.options.component), - await render(route.options.component), + await route.render('component'), ]).toEqual(['

    legacy

    ', '

    index

    ']) } finally { warn.mockRestore() @@ -1347,13 +1338,14 @@ export const Route = createFileRoute('/')({ component: Page }) `, { stubs: { './heavy': { heavy: () => 'heavy' } } }, ) - expect((await route.load('component'))()).toBe('
    page
    ') - const chunk = route.chunkOf('component') + expect(await route.render('component')).toBe('
    page
    ') + // What loading the component evaluates + const loaded = await route.loadedBy('component') expect({ declares: ['helper', 'Api', 'Def'].filter((name) => - declaredNames(chunk).has(name), + loaded.some((code) => declaredNames(code).has(name)), ), - importsHeavy: importSources(chunk).includes('./heavy'), + importsHeavy: loaded.flatMap(importSources).includes('./heavy'), }).toEqual({ declares: [], importsHeavy: false }) }, ) @@ -1429,7 +1421,8 @@ import { jsx } from '@emotion/react'`, }, { name: '@jsxFrag, in a chunk without fragments', - header: `/** @jsx h */ + header: `/** @jsxRuntime classic */ +/** @jsx h */ /** @jsxFrag Fragment */ import { h, Fragment } from 'preact'`, library: 'preact', @@ -1458,13 +1451,13 @@ ${head}export const Route = createFileRoute('/')({ // does what loads with it); the component chunk needs the element // factory, not the fragment one. expect({ - rendered: (await route.load('component'))(), + rendered: await route.render('component'), ...(loader === undefined - ? { - libraryLoadsEagerly: eagerImportSources(route).includes(library), - } + ? { libraryLoadsEagerly: route.eager.includes(library) } : { - chunkImports: importedNames(route.chunkOf('component'), library), + chunkImports: (await route.loadedBy('component')).flatMap( + (code) => importedNames(code, library), + ), }), }).toEqual({ rendered: 'rendered', @@ -1482,7 +1475,7 @@ ${head}export const Route = createFileRoute('/')({ await loadRoute(`${head}const { Page } = { Page: () =>
    page
    } export const Route = createFileRoute('/')({ component: Page }) `) - expect((await route.load('component'))()).toBe('
    page
    ') + expect(await route.render('component')).toBe('
    page
    ') }) // Bug: a split option that names a binding declared by an array @@ -1504,12 +1497,12 @@ export const Route = createFileRoute('/')({ { stubs: { './factory': { - createPair: () => [() => '
    page
    ', () => 'error'], + createPair: () => [() => 'page', () => 'error'], }, }, }, ) - expect((await route.load('component'))()).toBe('
    page
    ') + expect(await route.render('component')).toBe('page') }, ) }) @@ -1540,9 +1533,9 @@ export const Route = createFileRoute('/')({ { hmr: true }, ) return [ - await (await route.load('loader'))(), - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.options.loader(), + await route.render('component'), + await route.render('errorComponent'), ] } const expected = ['value', '

    value

    ', '

    value

    '] @@ -1582,8 +1575,8 @@ export const Route = createFileRoute('/')({ name: 'lazyRouteComponent', option: `pendingComponent: lazyRouteComponent(() => import('./spinner'))`, read: async (route: Awaited>) => - (await route.load('pendingComponent'))(), - expected: '

    spinner

    ', + await route.render('pendingComponent'), + expected: 'spinner', }, { name: 'lazyFn', @@ -1605,17 +1598,17 @@ export const Route = createFileRoute('/')({ `, { stubs: { - './spinner': { default: () => '

    spinner

    ' }, + './spinner': { default: () => 'spinner' }, './load-data': { loadData: () => 'data' }, }, }, ) await expectValidModules(route.modules) expect([ - route.isLazy('component'), + await route.isLazy('component'), await read(route), - (await route.load('component'))(), - (await route.load('errorComponent'))(), + await route.render('component'), + await route.render('errorComponent'), ]).toEqual([true, expected, '

    page

    ', '

    error

    ']) }, ) @@ -1646,10 +1639,10 @@ describe('code-split groupings', () => { ${order === 'before' ? `${codeRoute}\n${fileRoute}` : `${fileRoute}\n${codeRoute}`} `, ) - expect([route.isLazy('loader'), route.isLazy('component')]).toEqual([ - false, - true, - ]) + expect([ + await route.isLazy('loader'), + await route.isLazy('component'), + ]).toEqual([false, true]) }, ) @@ -1672,17 +1665,10 @@ export const Route = createFileRoute('/')({ component: () =>

    page

    , }) `) - const evaluated = [route.evaluated.length] - expect(await (await route.load('loader'))()).toBe('data') - evaluated.push(route.evaluated.length) - expect((await route.load('component'))()).toBe('

    page

    ') - evaluated.push(route.evaluated.length) - // The loader and the component each load their own module. - expect(evaluated).toEqual([ - evaluated[0], - evaluated[0]! + 1, - evaluated[0]! + 2, - ]) + expect(await route.options.loader()).toBe('data') + expect(await route.render('component')).toBe('

    page

    ') + // The loader and the component each load on their own. + expect(await route.groups()).toEqual([['component'], ['loader']]) }, ) }) @@ -1705,10 +1691,9 @@ export const Route = createFileRoute('/')({ component: () =>
    {state.count}
    , }) `) - expect([ - route.options.loader(), - (await route.load('component'))(), - ]).toEqual([1, '
    1
    ']) + expect([route.options.loader(), await route.render('component')]).toEqual( + [1, '
    1
    '], + ) }, ) }) @@ -1752,7 +1737,7 @@ export const Route = createFileRoute('/')({ ) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), created, ]).toEqual([1, '
    1title
    ', 1]) }, @@ -1760,28 +1745,24 @@ export const Route = createFileRoute('/')({ }) describe('route options read from the environment', () => { - /** - * The component the route renders: the route option itself, or what the - * lazy option loads when it was split. - */ - async function routeComponent( + /** What the route's component renders, loaded first when it is lazy. */ + async function renderRouteComponent( code: string, stubs: Record> = {}, ) { - return (await loadRoute(code, { stubs })).load('component') + return (await loadRoute(code, { stubs })).render('component') } // Control for the pins below (same harness). test('a split component imported by the route file renders', async () => { - const Page = () => null expect( - await routeComponent( + await renderRouteComponent( `import { Page } from './page' ${head}export const Route = createFileRoute('/')({ component: Page }) `, - { './page': { Page } }, + { './page': { Page: () => 'page' } }, ), - ).toBe(Page) + ).toBe('page') }) // Bug: a split option that is a global (no declaration in the module) or @@ -1793,18 +1774,17 @@ ${head}export const Route = createFileRoute('/')({ component: Page }) { name: 'an undeclared global', declaration: '' }, { name: 'an ambient declare const', - declaration: 'declare const TsrGlobalPage: () => null\n', + declaration: 'declare const TsrGlobalPage: () => string\n', }, ])('a component read from $name renders', async ({ declaration }) => { - const TsrGlobalPage = () => null - vi.stubGlobal('TsrGlobalPage', TsrGlobalPage) + vi.stubGlobal('TsrGlobalPage', () => 'global page') try { expect( - await routeComponent(`${head}${declaration}export const Route = createFileRoute('/')({ + await renderRouteComponent(`${head}${declaration}export const Route = createFileRoute('/')({ component: TsrGlobalPage, }) `), - ).toBe(TsrGlobalPage) + ).toBe('global page') } finally { vi.unstubAllGlobals() } @@ -1812,10 +1792,13 @@ ${head}export const Route = createFileRoute('/')({ component: Page }) }) describe('classic JSX pragmas', () => { - /** Emotion's `jsx`, rendering intrinsic elements as ``. */ + /** + * Emotion's `jsx`, rendering intrinsic elements as the text + * `emotion:tag(children)`. + */ const emotion = { jsx: (type: string, _props: unknown, ...children: Array) => - `${children.join('')}`, + `emotion:${type}(${children.join('')})`, } const pragma = `/** @jsxRuntime classic */ /** @jsx jsx */ @@ -1830,7 +1813,7 @@ import { jsx } from '@emotion/react' `${header}export const Page = () =>
    page
    `, { '@emotion/react': emotion }, ) - expect(Page!()).toBe('page') + expect(await renderComponent(Page)).toBe('emotion:div(page)') }, ) @@ -1851,9 +1834,7 @@ import { jsx } from '@emotion/react' })`, { stubs: { '@emotion/react': emotion } }, ) - expect((await route.load('component'))()).toBe( - 'page', - ) + expect(await route.render('component')).toBe('emotion:div(page)') }, ) @@ -1866,7 +1847,8 @@ import { jsx } from '@emotion/react' test.fails.each([ { name: 'the fragments of a chunk, with @jsxFrag', - code: `/** @jsx jsx */ + code: `/** @jsxRuntime classic */ +/** @jsx jsx */ /** @jsxFrag Frag */ ${head}import { jsx, Frag } from '@emotion/react' export const Route = createFileRoute('/')({ @@ -1891,7 +1873,7 @@ export const Route = createFileRoute('/')({ errorComponent: () => jsx('b', null, 'error'), })`, stub: emotion, - rendered: 'page', + rendered: 'emotion:div(page)', }, ])( 'a split component renders through $name', @@ -1899,7 +1881,7 @@ export const Route = createFileRoute('/')({ const route = await loadRoute(code, { stubs: { '@emotion/react': stub }, }) - expect((await route.load('component'))()).toBe(rendered) + expect(await route.render('component')).toBe(rendered) }, ) @@ -1941,15 +1923,15 @@ export const Route = createFileRoute('/')({ }, }, ) + // Every module is valid in a project whose default JSX runtime is + // classic (what `@jsx` alone requires); the in-process transform + // compiles a module with `@jsx` alone with the automatic runtime. + await expectValidModules(route.modules) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), creations, - ]).toEqual([ - '', - 'page', - 1, - ]) + ]).toEqual(['emotion:span()', 'emotion:div(page)', 1]) }, ) }) @@ -2013,9 +1995,9 @@ export const { component: Page, loader } = createBits()`, export const Route = createFileRoute('/')({ ${options}component: ${binding} }) `) const file = route.ids.reference! - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.render('component')).toBe('

    page

    ') expect({ - split: route.isLazy('component'), + split: await route.isLazy('component'), warned: warn.mock.calls.some(([message]) => { const text = String(message) return ( @@ -2051,7 +2033,7 @@ function Page() { export const Route = createFileRoute('/')({ title: 'title', component: Page }) `) try { - expect((await route.load('component'))()).toBe('

    title

    ') + expect(await route.render('component')).toBe('

    title

    ') expect((globalThis as any).__calledUseTitle).toBe( route.exports.useTitle, ) @@ -2072,11 +2054,6 @@ export const Route = createFileRoute('/')({ title: 'title', component: Page }) test.fails( 'a split component reads the route the route module exports through export { Route }', async () => { - let created = 0 - const createRoute = (options: unknown) => { - created++ - return { options, id: '/' } - } const route = await loadRoute( `${head}const Route = createFileRoute('/')({ component: Page, @@ -2086,16 +2063,10 @@ function Page() { } export { Route } `, - { - stubs: { - '@tanstack/react-router': { createFileRoute: () => createRoute }, - }, - }, ) - expect([(await route.load('component'))(), created]).toEqual([ - '

    /

    ', - 1, - ]) + // The router mounted the exported route (which sets its id): a second + // route object has none. + expect(await route.renderRoute()).toBe('

    /

    ') }, ) @@ -2123,7 +2094,7 @@ export const Route = createFileRoute('/')({ component: Bar, errorComponent: NoFo `, { stubs: { './see': { see: (value: unknown) => seen.push(value) } } }, ) - expect((await route.load('errorComponent'))({ noForget: 'x' })).toBe( + expect(await route.render('errorComponent', { noForget: 'x' })).toBe( '
    x
    ', ) expect(seen).toEqual([route.exports.default]) @@ -2157,7 +2128,7 @@ export const Route = createFileRoute('/')({ notFoundComponent: NotFound, }) `) - expect((await route.load('component'))()).toBe('

    nf

    ') + expect(await route.render('component')).toBe('

    nf

    ') }, ) @@ -2194,7 +2165,7 @@ export const Route = createFileRoute('/')({ ) expect([ route.options.loader(), - (await route.load('component'))(), + await route.render('component'), route.exports.default(), inits, ]).toEqual([1, '

    2

    ', 3, 1]) @@ -2230,30 +2201,34 @@ export const Route = createFileRoute('/')({ * Loads a route whose errorComponent option is the exported * `PostErrorComponent`, written as `value`, next to another module of the * app that imports `PostErrorComponent` from the route file (ruling P1: - * through a real importer). `withAuth` (`./with-auth`) and `memo` - * (`react`) are identity stubs, so the option's value is the component - * itself. Returns whether the route renders the importer's instance, and - * whether the option is split or the user is told it is not (a warning - * naming the route file and the option or the component). + * through a real importer). `withAuth` (`./with-auth`) is an identity + * stub. The component records the instance it runs as (`./see`). Returns + * what the route's errorComponent renders, whether it ran as the + * importer's instance, and whether the option is split or the user is told + * it is not (a warning naming the route file and the option or the + * component). */ async function exportedErrorComponent(value: string) { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const seen: Array = [] try { const route = await loadRoute( `${head}import { withAuth } from './with-auth' +import { see } from './see' import { memo } from 'react' export const Route = createFileRoute('/posts/$postId')({ errorComponent: ${value}, component: () =>

    post

    , }) export function PostErrorComponent() { + see(PostErrorComponent) return

    error

    } `, { stubs: { './with-auth': { withAuth: (component: unknown) => component }, - react: { memo: (component: unknown) => component }, + './see': { see: (value: unknown) => seen.push(value) }, }, }, ) @@ -2261,13 +2236,13 @@ export function PostErrorComponent() { `import { PostErrorComponent } from '${routeSpecifier}' export const imported = PostErrorComponent`, ) - const errorComponent = await route.load('errorComponent') + const rendered = await route.render('errorComponent') const file = route.ids.reference! return { - rendered: errorComponent(), - sameInstance: errorComponent === importer.imported, + rendered, + sameInstance: seen.length === 1 && seen[0] === importer.imported, splitOrReported: - route.isLazy('errorComponent') || + (await route.isLazy('errorComponent')) || warn.mock.calls.some(([message]) => { const text = String(message) return ( @@ -2441,7 +2416,7 @@ export const Route = createFileRoute('/')({ component: () =>

    {Labels.quiet}

    , }) `) - expect((await route.load('component'))()).toBe('

    quiet

    ') + expect(await route.render('component')).toBe('

    quiet

    ') }, ) @@ -2461,7 +2436,7 @@ function Page() { } export const Route = createFileRoute('/')({ component: Page }) `) - expect((await route.load('component'))()).toBe(1) + expect(await route.render('component')).toBe('1') }, ) @@ -2489,7 +2464,7 @@ function Page() { export const Route = createFileRoute('/')({ component: Page }) ` await expectValidModules(compileRouteModules(code).modules) - expect((await (await loadRoute(code)).load('component'))()).toBe( + expect(await (await loadRoute(code)).render('component')).toBe( '

    -1

    ', ) }, @@ -2506,7 +2481,7 @@ export const Route = createFileRoute('/')({ component: Page }) export const Route = createFileRoute('/')({ component: Page }) ` await expectValidModules(compileRouteModules(code).modules) - return (await (await loadRoute(code)).load('component'))() + return (await loadRoute(code)).render('component') } test('parenthesized JSX keeps working in a split component', async () => { @@ -2579,10 +2554,10 @@ export function helper(value: string | number) { return value }` describe('code that reads a split binding', () => { /** * Loads a route whose component is `Page`, declared next to `exports`, in - * a production compile, and returns what the route renders, what `read` - * gets from the route module's exports, and whether `Page` is split or - * the user is told it is not (a warning naming the route file and `Page` - * or `component`). + * a production compile, and returns what the route renders, what the + * component `read` gets from the route module's exports renders, and + * whether `Page` is split or the user is told it is not (a warning naming + * the route file and `Page` or `component`). */ async function renderPage( exports: string, @@ -2601,9 +2576,12 @@ export const Route = createFileRoute('/')({ component: Page }) { stubs: { './wrap': { wrap: (component: unknown) => component } } }, ) return { - rendered: [(await route.load('component'))(), read(route.exports)], + rendered: [ + await route.render('component'), + await renderComponent(read(route.exports)), + ], splitOrReported: - route.isLazy('component') || + (await route.isLazy('component')) || reportsNotSplit(warn, route.ids.reference!), } } finally { @@ -2616,8 +2594,8 @@ export const Route = createFileRoute('/')({ component: Page }) test('a split component renders next to another export', async () => { expect( await renderPage( - `export const label = wrap('label')`, - (moduleExports) => moduleExports.label, + `export const Label = wrap(() => 'label')`, + (moduleExports) => moduleExports.Label, ), ).toEqual({ rendered: ['

    page

    ', 'label'], splitOrReported: true }) }) @@ -2637,33 +2615,33 @@ export const Route = createFileRoute('/')({ component: Page }) { name: 'an exported object', exports: 'export const meta = { fn: Page }', - read: (moduleExports: Record) => moduleExports.meta.fn(), + read: (moduleExports: Record) => moduleExports.meta.fn, }, { name: 'an exported function', exports: 'export function Preview() { return }', - read: (moduleExports: Record) => moduleExports.Preview(), + read: (moduleExports: Record) => moduleExports.Preview, }, { name: 'a default export', exports: 'export default wrap(Page)', - read: (moduleExports: Record) => moduleExports.default(), + read: (moduleExports: Record) => moduleExports.default, }, { name: 'an export list', exports: 'export { Page }', - read: (moduleExports: Record) => moduleExports.Page(), + read: (moduleExports: Record) => moduleExports.Page, }, { name: 'an export alias', exports: 'export { Page as View }', - read: (moduleExports: Record) => moduleExports.View(), + read: (moduleExports: Record) => moduleExports.View, }, { // Source: #8504 (lost case L4). name: 'a default export by specifier', exports: 'export { Page as default }', - read: (moduleExports: Record) => moduleExports.default(), + read: (moduleExports: Record) => moduleExports.default, }, ])( 'a split component that $name reads renders from both', @@ -2733,8 +2711,10 @@ export const Route = createFileRoute('/')({ }, }, ) - const loaded = route.options.loader() - const rendered = (await route.load('component'))() + // The loader returns the element `Preview` renders + const element = route.options.loader() + const loaded = await renderComponent(() => element) + const rendered = await route.render('component') expect([loaded, rendered, runs]).toEqual([ '

    page

    ', '

    page

    ', @@ -2762,8 +2742,8 @@ function Page() { } `) expect([ - (await route.load('pendingComponent'))(), - (await route.load('component'))(), + await route.render('pendingComponent'), + await route.render('component'), ]).toEqual(['

    object

    ', '

    object

    ']) }, ) @@ -2790,7 +2770,7 @@ function Page() { `) expect([ route.options.beforeLoad(), - (await route.load('component'))(), + await route.render('component'), ]).toEqual(['function', '

    object

    ']) }, ) @@ -2803,7 +2783,7 @@ function Page() { test.fails( 'a split component that a top-level call reads renders from both', async () => { - const registered: Array<() => string> = [] + const registered: Array = [] const route = await loadRoute( `${head}import { register } from './registry' function Page() { @@ -2815,18 +2795,20 @@ register(Page) { stubs: { './registry': { - register: (component: () => string) => { + register: (component: unknown) => { registered.push(component) }, }, }, }, ) - const rendered = (await route.load('component'))() - expect([registered.map((component) => component()), rendered]).toEqual([ - ['
    page
    '], - '
    page
    ', - ]) + const rendered = await route.render('component') + expect([ + await Promise.all( + registered.map((component) => renderComponent(component)), + ), + rendered, + ]).toEqual([['
    page
    '], '
    page
    ']) }, ) @@ -2842,7 +2824,7 @@ export const Route = createFileRoute('/posts')({ head: () => ({ meta: [{ title: PostsPage.title }] }), }) `) - expect([route.options.head(), (await route.load('component'))()]).toEqual([ + expect([route.options.head(), await route.render('component')]).toEqual([ { meta: [{ title: 'Posts' }] }, '

    posts

    ', ]) @@ -2872,16 +2854,17 @@ export const Route = createFileRoute('/posts')({ { stubs: { './posts-page': { - PostsPage: Object.assign(() => '

    posts

    ', { + PostsPage: Object.assign(() => 'posts', { title: 'Posts', }), }, }, }, ) - expect([route.options.head(), (await route.load('component'))()]).toEqual( - [{ meta: [{ title: 'Posts' }] }, '

    posts

    '], - ) + expect([route.options.head(), await route.render('component')]).toEqual([ + { meta: [{ title: 'Posts' }] }, + 'posts', + ]) }, ) @@ -2889,15 +2872,17 @@ export const Route = createFileRoute('/posts')({ // the imported split one, used by another module of the app (ruling P1: // through a real importer, not the export list). Adapted from the // repository's - // examples/solid/start-supabase-basic/src/routes/_authed/posts.$postId.tsx. + // examples/solid/start-supabase-basic/src/routes/_authed/posts.$postId.tsx + // (written for React here: Solid cannot run in process; the code splitter + // handles the import the same way for every framework). // Impact: rendering `PostErrorComponent` throws "ErrorComponent is not // defined". test.fails( 'an imported split errorComponent that an exported component renders works for an importer', async () => { const route = await loadRoute( - `import { ErrorComponent, createFileRoute } from '@tanstack/solid-router' -import type { ErrorComponentProps } from '@tanstack/solid-router' + `import { ErrorComponent, createFileRoute } from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' export const Route = createFileRoute('/posts/$postId')({ loader: ({ params: { postId } }) => postId, errorComponent: ErrorComponent, @@ -2910,26 +2895,17 @@ function PostComponent() { return

    post

    } `, - { - targetFramework: 'solid', - stubs: { - '@tanstack/solid-router': { - ErrorComponent: ({ error }: { error: Error }) => - `

    ${error.message}

    `, - }, - }, - }, ) const importer = await route.importFromRoute( - `import { PostErrorComponent } from '${routeSpecifier}' -export const render = (error) => PostErrorComponent({ error })`, + `export { PostErrorComponent } from '${routeSpecifier}'`, ) const error = new Error('boom') + const routeError = await route.render('errorComponent', { error }) + expect(routeError).toContain('boom') expect([ - importer.render(error), - (await route.load('errorComponent'))({ error }), - (await route.load('component'))(), - ]).toEqual(['

    boom

    ', '

    boom

    ', '

    post

    ']) + await renderComponent(importer.PostErrorComponent, { error }), + await route.render('component'), + ]).toEqual([routeError, '

    post

    ']) }, ) @@ -2948,12 +2924,12 @@ export const Route = createFileRoute('/')({ component: () =>

    page

    , }) `, - { stubs: { './spinner': { Spinner: () => 'spin' } } }, + { stubs: { './spinner': { Spinner: () => 'spin' } } }, ) expect([ - (await route.load('errorComponent'))(), - (await route.load('pendingComponent'))(), - ]).toEqual(['
    spin
    ', 'spin']) + await route.render('errorComponent'), + await route.render('pendingComponent'), + ]).toEqual(['
    spin
    ', 'spin']) }, ) }) @@ -2972,7 +2948,7 @@ describe('class components', () => { } export const Route = createFileRoute('/')({ component: Page }) `) - expect((await route.load('component'))()).toBe('page') + expect(await route.render('component')).toBe('page') }) // Bug: a route option that names a class cannot be split: the virtual @@ -2999,15 +2975,14 @@ export const Route = createFileRoute('/')({ component: Page }) ${declaration} export const Route = createFileRoute('/')({ component: Page }) `, - { filename: 'route.jsx', stubs: { react: { Component: class {} } } }, + { filename: 'route.jsx' }, ) - const Page = await route.load('component') expect({ - rendered: new Page().render(), + rendered: await route.render('component'), split: exported - ? route.isLazy('component') || + ? (await route.isLazy('component')) || reportsNotSplit(warn, route.ids.reference!) - : route.isLazy('component'), + : await route.isLazy('component'), }).toEqual({ rendered: '

    page

    ', split: true }) } finally { warn.mockRestore() @@ -3039,16 +3014,14 @@ export const Route = createFileRoute('/')({ { filename: 'route.jsx', stubs: { - react: { Component: class {} }, - './format': { format: (value: string) => `<${value}>` }, + './format': { format: (value: string) => `[${value}]` }, }, }, ) - const Page = await route.load('component') expect([ - eagerImportSources(route).includes('./format'), - new Page().render(), - ]).toEqual([false, '

    ']) + route.eager.includes('./format'), + await route.render('component'), + ]).toEqual([false, '

    [home]

    ']) }, ) }) @@ -3056,7 +3029,7 @@ export const Route = createFileRoute('/')({ describe('side-effect imports', () => { /** Stubs of `./setup` and `./Page` that record when each module runs. */ function recordingStubs(log: Array) { - const Page = () => '

    page

    ' + const Page = () => 'page' return { './setup': { get ready() { @@ -3085,7 +3058,7 @@ export const Route = createFileRoute('/')({ component: Page }) { stubs: recordingStubs(log) }, ) expect(log).toEqual(['./setup']) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.render('component')).toBe('page') }) // Bug: when the split component is imported (`import { Page } from @@ -3106,7 +3079,7 @@ export const Route = createFileRoute('/')({ component: Page }) { stubs: recordingStubs(log) }, ) expect(log).toEqual(['./Page']) - expect((await route.load('component'))()).toBe('

    page

    ') + expect(await route.render('component')).toBe('page') }, ) }) @@ -3123,13 +3096,9 @@ describe('server-only route options on the client', () => { * emitted module imports `./secret-db`, which server-only options the * route still has, and what it renders. */ - async function compileForClient( - code: string, - targetFramework: TargetFramework = 'react', - ) { + async function compileForClient(code: string) { const route = await loadRoute(code, { deleteNodes, - targetFramework, stubs: { './secret-db': db, './opts': { makeOpts: (options: unknown) => options }, @@ -3140,7 +3109,7 @@ describe('server-only route options on the client', () => { importSources(module).includes('./secret-db'), ).length, serverOptions: deleteNodes.filter((key) => key in route.options), - rendered: (await route.load('component'))(), + rendered: await route.render('component'), } } @@ -3161,15 +3130,6 @@ export const Route = createFileRoute('/')({ ${serverOptions} })`, }, - { - name: 'a Solid file route', - code: `import { createFileRoute } from '@tanstack/solid-router' -import { db } from './secret-db' -export const Route = createFileRoute('/')({ -${serverOptions} -})`, - targetFramework: 'solid' as const, - }, { name: 'a root route', code: `import { createRootRoute } from '@tanstack/react-router' @@ -3188,11 +3148,31 @@ ${serverOptions} }, ])( 'server-only options of $name written as properties are removed with their imports', - async ({ code, targetFramework }) => { - expect(await compileForClient(code, targetFramework)).toEqual(stripped) + async ({ code }) => { + expect(await compileForClient(code)).toEqual(stripped) }, ) + // The Solid router cannot run in process (it ships client-only APIs to + // Node): what the client compile of a Solid route still imports is + // checked here, the built Solid app runs in + // integration/build-frameworks.test.ts. + test('server-only options of a Solid file route written as properties are removed with their imports', () => { + const { modules } = compileRouteModules( + `import { createFileRoute } from '@tanstack/solid-router' +import { db } from './secret-db' +export const Route = createFileRoute('/')({ +${serverOptions} +})`, + { deleteNodes, targetFramework: 'solid' }, + ) + expect( + Object.values(modules).filter((module) => + importSources(module).includes('./secret-db'), + ), + ).toEqual([]) + }) + // Without `deleteNodes` (a Router app without Start), the options are the // route's own and stay. test('server-only options stay without deleteNodes', async () => { diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index e34d6de3789..9a4196aabee 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -48,7 +48,11 @@ import { createFileRoute } from '@tanstack/react-router' const ${name} = { value: ${JSON.stringify(value)} } export const Route = createFileRoute('/changing')({ loader: () => ${name}, - component: () => ${name}, + component: () => ( +

    + {${name}.value} {String(Route.useLoaderData() === ${name})} +

    + ), })` // One plugin instance per split setting, each compiling every version of // the file, as a dev server does. @@ -62,29 +66,41 @@ export const Route = createFileRoute('/changing')({ const separate = load([['component'], ['loader']]) const combined = load([['component', 'loader']]) - /** The loader's data and the object the component reads. */ - const read = async (route: Awaited>) => { - expect([route.isLazy('loader'), route.isLazy('component')]).toEqual([ - true, - true, - ]) - const data = await (await route.load('loader'))() - expect((await route.load('component'))()).toBe(data) - return data + /** + * The route rendered by the router: the value the component reads and + * whether the loader's data is that same object. Then checks which + * options load together: activating an option of a group makes the + * whole group available, another group stays unrequested. + */ + const read = async ( + route: Awaited>, + groups: Array>, + ) => { + const html = await route.renderRoute() + expect(await route.groups()).toEqual(groups) + return html } + const separateGroups = [['component'], ['loader']] expect( - await read(await separate(source('firstState', 'first version'))), - ).toEqual({ value: 'first version' }) + await read( + await separate(source('firstState', 'first version')), + separateGroups, + ), + ).toBe('

    first version true

    ') const second = await separate(source('secondState', 'second version')) for (const output of Object.values(second.modules)) { expect(output).not.toContain('firstState') } - expect(await read(second)).toEqual({ value: 'second version' }) + expect(await read(second, separateGroups)).toBe( + '

    second version true

    ', + ) expect( - await read(await combined(source('secondState', 'second version'))), - ).toEqual({ value: 'second version' }) + await read(await combined(source('secondState', 'second version')), [ + ['component', 'loader'], + ]), + ).toBe('

    second version true

    ') expect( (await separate(source('secondState', 'second version'))).modules, ).toEqual(second.modules) From ac80b9d3a310e02ce4c5c03cefb40e4b0d9a1311 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:53:14 +0200 Subject: [PATCH 0429/1425] test(router-plugin): keep the route HMR suites passing on the real router Minimal edits in W2-g's files (FIX-PLAN A1 rewrites them on real pages): components render with real React (`renderComponent`, `render`), `isLazy` is awaited, compile-only checks of Solid routes use `compileRouteModules` (the Solid router cannot be linked in process), and "React Refresh registers the split component" compares what a registered component renders with what the route renders (the component a lazy option loads is not reachable through the router's public API). All 16 pins of known-bugs-route-hmr were run without `.fails`: each fails for its own bug. Co-Authored-By: Claude Opus 5.5 --- .../tests/known-bugs-route-hmr.test.ts | 107 +++++++++++------- .../react-refresh-route-components.test.ts | 27 +++-- .../router-plugin/tests/route-hmr.test.ts | 17 +-- 3 files changed, 89 insertions(+), 62 deletions(-) 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 7aa1f79fb3b..81cae80b5ba 100644 --- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts @@ -18,21 +18,41 @@ import { head, hotUpdate, loadRoute, + renderComponent, routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' + import { editRouteFile } from './vite-hot-update' import type { PluginOption } from 'vite' +/** + * Whether a component React Refresh registered renders what the route's + * `option` renders (the component a split option loads is not reachable + * through the router's public API). + */ +async function rendersRegistered( + route: Awaited>, + option: string, +) { + const rendered = await route.render(option) + for (const type of route.registered.keys()) { + const html = await renderComponent(type).catch(() => undefined) + if (html === rendered) { + return true + } + } + return false +} + describe('React Refresh registration', () => { // Inline components that are not plain functions (`memo(...)`, ...): // pinned in a real page below ("route HMR in a real page"). /** * Loads, with React HMR and React Refresh, a route whose `component` option - * is `name`, declared by `declaration`, and returns the component the route - * renders once its lazy option loaded, and the components React Refresh - * registers. + * is `name`, declared by `declaration`, and returns whether a component + * React Refresh registers renders what the route's component renders. */ async function splitComponentRegistration(declaration: string, name: string) { const route = await loadRoute( @@ -41,9 +61,7 @@ export const Route = createFileRoute('/')({ component: ${name} })`, { hmr: true, refresh: true }, ) await expectValidModules(route.modules) - const binding = await route.load('component') - expect(binding).toBeDefined() - return { binding, registered: [...route.registered.keys()] } + return rendersRegistered(route, 'component') } // Source: add-hmr snapshot fixture createFileRoute-lowercase-components: @@ -69,27 +87,22 @@ function component() { { hmr: true, refresh: true }, ) await expectValidModules(route.modules) - expect( - ['component', 'errorComponent', 'pendingComponent'].map(route.isLazy), - ).toEqual([true, true, false]) + expect(await route.groups()).toEqual([['component'], ['errorComponent']]) for (const [option, text] of [ ['component', 'Hello'], ['errorComponent', 'Error'], ['pendingComponent', 'Pending'], ]) { - const component = await route.load(option!) - expect(component()).toBe(`
    ${text}
    `) - expect(route.registered.has(component), option).toBe(true) + expect(await route.render(option!)).toBe(`
    ${text}
    `) + expect(await rendersRegistered(route, option!), option).toBe(true) } }) // Control for the lowercase pin below (same harness). test('registers a PascalCase split component', async () => { - const { binding, registered } = await splitComponentRegistration( - `const Page = () =>

    hi

    `, - 'Page', - ) - expect(registered).toContain(binding) + expect( + await splitComponentRegistration(`const Page = () =>

    hi

    `, 'Page'), + ).toBe(true) }) // Control for the lowercase pin below: lowercase function declarations @@ -108,8 +121,8 @@ function ${option}() { { hmr: true, refresh: true }, ) await expectValidModules(route.modules) - expect(route.isLazy(option)).toBe(true) - expect(route.registered.has(await route.load(option))).toBe(true) + expect(await route.isLazy(option)).toBe(true) + expect(await rendersRegistered(route, option)).toBe(true) }, ) @@ -128,11 +141,7 @@ function ${option}() { ])( 'registers a lowercase split component declared as %s', async (_, declaration) => { - const { binding, registered } = await splitComponentRegistration( - declaration, - 'page', - ) - expect(registered).toContain(binding) + expect(await splitComponentRegistration(declaration, 'page')).toBe(true) }, ) }) @@ -217,7 +226,7 @@ export const Route = createFileRoute('/')({ { hmr: true }, ) await expectValidModules(route.modules) - expect((await route.load('component'))()).toBe('

    hot

    ') + expect(await route.render('component')).toBe('

    hot

    ') if (exported) { // A module importing it gets the user's value. const { hot } = await route.importFromRoute( @@ -259,9 +268,11 @@ export const Route = createFileRoute('/')({ }, }, ) - const component = await route.load('component') - const notFound = await route.load('notFoundComponent') - return [component(), notFound(), inits] + return [ + await route.render('component'), + await route.render('notFoundComponent'), + inits, + ] } // Control for the lowercase shared component pin below (same harness). @@ -312,11 +323,13 @@ export const Route = createFileRoute('/')({ component: page, pendingComponent: p { hmr: true, refresh: true }, ) await expectValidModules(route.modules) - const component = await route.load('component') - const pending = await route.load('pendingComponent') - expect([component(), pending()]).toEqual(['

    hi

    ', '

    hi

    ']) - expect(route.registered.has(component)).toBe(true) - expect(route.registered.has(pending)).toBe(true) + expect([ + await route.render('component'), + await route.render('pendingComponent'), + ]).toEqual(['

    hi

    ', '

    hi

    ']) + expect(await rendersRegistered(route, 'component')).toBe(true) + // Not split: the option is the component React renders + expect(route.registered.has(route.options.pendingComponent)).toBe(true) }, ) @@ -341,10 +354,10 @@ export const Route = createFileRoute('/')({ component: page })`, { hmr: true, refresh: true }, ) await expectValidModules(route.modules) - const component = await route.load('component') - expect(route.exports.page).toBe(component) - expect(component()).toBe('

    hi

    ') - expect(route.registered.has(component)).toBe(true) + expect(await route.render('component')).toBe('

    hi

    ') + expect(await renderComponent(route.exports.page)).toBe('

    hi

    ') + expect(route.registered.has(route.exports.page)).toBe(true) + expect(await rendersRegistered(route, 'component')).toBe(true) }, ) @@ -385,9 +398,9 @@ export const Route = createFileRoute('/')({ { hmr: true }, ) await expectValidModules(route.modules) - expect(route.isLazy('loader')).toBe(true) + expect(await route.isLazy('loader')).toBe(true) expect(await route.options.loader()).toBe('data') - expect((await route.load('component'))()).toBe('

    data

    ') + expect(await route.render('component')).toBe('

    data

    ') }) /** A lowercase error fallback, and an import of `ErrorBoundary`. */ @@ -406,7 +419,9 @@ export const Route = createFileRoute('/')({ expect(Route.options.staticData.errorFallback).toBe( Route.options.errorComponent, ) - expect(Route.options.errorComponent()).toBe('

    error

    ') + expect(await renderComponent(Route.options.errorComponent)).toBe( + '

    error

    ', + ) }) // Control for the hoisted inline component pin below (same harness). @@ -419,7 +434,9 @@ export const Route = createFileRoute('/')({ ), })`) - expect(Route.options.pendingComponent()).toBe('

    error

    ') + expect(await renderComponent(Route.options.pendingComponent)).toBe( + '

    error

    ', + ) }) // Bug: React HMR hoists inline component options into declarations it @@ -442,8 +459,12 @@ export const Route = createFileRoute('/')({ ), errorComponent: errorFallback, })`) - expect(Route.options.pendingComponent()).toBe('

    error

    ') - expect(Route.options.errorComponent()).toBe('

    error

    ') + expect(await renderComponent(Route.options.pendingComponent)).toBe( + '

    error

    ', + ) + expect(await renderComponent(Route.options.errorComponent)).toBe( + '

    error

    ', + ) }, ) }) 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 17d31d10c64..b9395a60740 100644 --- a/packages/router-plugin/tests/react-refresh-route-components.test.ts +++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts @@ -23,9 +23,11 @@ import { head, loadRoute, reactRefresh, + renderComponent, routeFile, transformWithRouteHmrPlugin, } from './regression-helpers' + import type { ExportedRouteOptionsCase } from './dev-server' /** Compiles every module the code splitter emits, with React HMR. */ @@ -93,10 +95,8 @@ ${options const bindings = new Set() for (const option of options) { bindings.add(await expectRegisteredRouteOption(reference!, option)) - const component = (await getRouteOption(reference!, option)) as ( - props: object, - ) => string - expect(component({})).toBe(`
    ${option}
    `) + const component = await getRouteOption(reference!, option) + expect(await renderComponent(component)).toBe(`
    ${option}
    `) } expect(bindings.size).toBe(options.length) }) @@ -202,7 +202,9 @@ export const Route = createFileRoute('/')({ component: page })`, createFileRoute: () => (options: unknown) => ({ options }), }, }) - expect((Route as any).options.component()).toBe('

    inner-marker

    ') + expect(await renderComponent((Route as any).options.component)).toBe( + '

    inner-marker

    ', + ) }) // Source: ReactFreshIntegration-test "preserves state ..." (families are @@ -252,9 +254,12 @@ export const Route = createFileRoute('/')({ expect((await reactRefresh(hmr)).signatures.get(hoisted)).toBe(expected) const route = await loadRoute(source, { hmr: true }) - const chunkSignatures = (await reactRefresh(route.chunkOf('component'))) - .signatures - expect([...chunkSignatures.values()]).toContain(expected) + const chunkSignatures = await Promise.all( + (await route.loadedBy('component')).map(async (code) => [ + ...(await reactRefresh(code)).signatures.values(), + ]), + ) + expect(chunkSignatures.flat()).toContain(expected) }) }) @@ -278,7 +283,7 @@ export const Route = createRootRoute({ component: () =>
    }) // stay in the function to read it. const { exports } = await evaluateRouteModule(reference!) const child = (exports.makeChild as (label: string) => any)('x') - expect(child.options.component()).toBe('
    x
    ') + expect(await renderComponent(child.options.component)).toBe('
    x
    ') // React Refresh can only register components bound to top-level names. await expectRegisteredRouteOption(reference!, 'component') }) @@ -302,8 +307,8 @@ describe('route HMR leaves options React Refresh does not handle as they are', ( })`, ) expect(await getModuleErrors(code)).toEqual([]) - const component = (await getRouteOption(code, 'component')) as () => string - expect(component()).toBe('

    hi

    ') + const component = await getRouteOption(code, 'component') + expect(await renderComponent(component)).toBe('

    hi

    ') }) }) diff --git a/packages/router-plugin/tests/route-hmr.test.ts b/packages/router-plugin/tests/route-hmr.test.ts index c54d13027f3..158a2ae7932 100644 --- a/packages/router-plugin/tests/route-hmr.test.ts +++ b/packages/router-plugin/tests/route-hmr.test.ts @@ -207,11 +207,9 @@ export const Route = createFileRoute('/')({ 'component': Page, 'errorComponent': { hmr: true }, ) await expectValidModules(route.modules) - expect([route.isLazy('component'), route.isLazy('errorComponent')]).toEqual( - [true, true], - ) - expect((await route.load('component'))()).toBe('

    page

    ') - expect((await route.load('errorComponent'))()).toBe('

    error

    ') + expect(await route.groups()).toEqual([['component'], ['errorComponent']]) + expect(await route.render('component')).toBe('

    page

    ') + expect(await route.render('errorComponent')).toBe('

    error

    ') }) // Source: add-hmr snapshot fixture explicit-undefined-component. @@ -223,7 +221,7 @@ export const Route = createFileRoute('/')({ 'component': Page, 'errorComponent': { hmr: true, refresh: true }, ) await expectValidModules(route.modules) - expect(route.isLazy('component')).toBe(false) + expect(await route.isLazy('component')).toBe(false) expect('component' in route.options).toBe(true) expect(route.options.component).toBeUndefined() }) @@ -316,8 +314,11 @@ function RootComponent() { ] as const)('in $mode', ({ options }) => { it.each(routes)( 'have no HMR code ($name)', - async ({ route, targetFramework }) => { - const loaded = await loadRoute(route, { targetFramework, ...options }) + ({ route, targetFramework }) => { + const loaded = compileRouteModules(route, { + targetFramework, + ...options, + }) for (const [name, code] of Object.entries(loaded.modules)) { expect(code, name).not.toMatch(/import\.meta\.(?:webpackHot|hot)\b/) } From a01737851f95cc14255acd1fe0b8e9a6d02c1d23 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:59:40 +0200 Subject: [PATCH 0430/1425] test(compiler-test-utils): shared oracles for diagnostics, importers, determinism and source maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX-PLAN A9-A12 helpers, outcome-only (SPEC §5.7, §5.14, G4, G9): - diagnostics: read the real channel (thrown errors, aggregated build errors and warnings, console) and assert severity, the file named (absolute, relative or trailing segments, unambiguous among others), the original position (any frame layout, 0- or 1-based column) and API/option names; never prose. expectEnvRestrictionThrow and envRestrictionError for J4.10: a deliberate error naming the owning environment, any wording. - importers: a real second module importing named/default/odd-name exports as live bindings; load markers instead of import inventories. - determinism: reorderings, expectDeterministic (same root) versus expectRelocatable (per-root checks, portable projections only). - source-maps: @jridgewell/trace-mapping, parsed token offsets, stack tracing, CRLF-aware positions. Integration control (tests/integration/oracles.test.ts): every bundler's real build error names file, line and column; a throw in a built chunk resolves through its stack. The build harness now keeps esbuild's line:column (it dropped the location the real channel carries). Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/package.json | 4 + .../compiler-test-utils/src/build-harness.ts | 5 +- .../compiler-test-utils/src/determinism.ts | 176 +++++ .../compiler-test-utils/src/diagnostics.ts | 710 ++++++++++++++++++ packages/compiler-test-utils/src/importers.ts | 117 +++ .../compiler-test-utils/src/source-maps.ts | 336 +++++++++ .../tests/determinism.test.ts | 115 +++ .../tests/diagnostics.test.ts | 343 +++++++++ .../tests/importers.test.ts | 98 +++ .../tests/integration/oracles.test.ts | 105 +++ .../tests/source-maps.test.ts | 160 ++++ 11 files changed, 2167 insertions(+), 2 deletions(-) create mode 100644 packages/compiler-test-utils/src/determinism.ts create mode 100644 packages/compiler-test-utils/src/diagnostics.ts create mode 100644 packages/compiler-test-utils/src/importers.ts create mode 100644 packages/compiler-test-utils/src/source-maps.ts create mode 100644 packages/compiler-test-utils/tests/determinism.test.ts create mode 100644 packages/compiler-test-utils/tests/diagnostics.test.ts create mode 100644 packages/compiler-test-utils/tests/importers.test.ts create mode 100644 packages/compiler-test-utils/tests/integration/oracles.test.ts create mode 100644 packages/compiler-test-utils/tests/source-maps.test.ts diff --git a/packages/compiler-test-utils/package.json b/packages/compiler-test-utils/package.json index bdee7f091fb..2923a4da2a1 100644 --- a/packages/compiler-test-utils/package.json +++ b/packages/compiler-test-utils/package.json @@ -8,10 +8,14 @@ "./browser": "./src/browser.ts", "./budget-reporter": "./src/budget-reporter.ts", "./build-harness": "./src/build-harness.ts", + "./determinism": "./src/determinism.ts", "./dev-server": "./src/dev-server.ts", + "./diagnostics": "./src/diagnostics.ts", + "./importers": "./src/importers.ts", "./module-linker": "./src/module-linker.ts", "./rsbuild-dev-server": "./src/rsbuild-dev-server.ts", "./run-node": "./src/run-node.ts", + "./source-maps": "./src/source-maps.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 e32d110a650..1e4911b7567 100644 --- a/packages/compiler-test-utils/src/build-harness.ts +++ b/packages/compiler-test-utils/src/build-harness.ts @@ -1369,12 +1369,13 @@ function statsMessage(item: { message: string; moduleName?: string }) { interface EsbuildMessage { text: string - location?: { file: string } | null + location?: { file: string; line: number; column: number } | null } +/** An esbuild message as esbuild prints it: `file:line:column: text`. */ function esbuildText(message: EsbuildMessage) { return message.location - ? `${message.location.file}: ${message.text}` + ? `${message.location.file}:${message.location.line}:${message.location.column}: ${message.text}` : message.text } diff --git a/packages/compiler-test-utils/src/determinism.ts b/packages/compiler-test-utils/src/determinism.ts new file mode 100644 index 00000000000..ee630ce4b12 --- /dev/null +++ b/packages/compiler-test-utils/src/determinism.ts @@ -0,0 +1,176 @@ +/** + * Determinism versus relocation (FIX-PLAN A11; SPEC G4, J1.5, J4.4, J5.4, + * §9). + * + * - Determinism: with the same effective root and configuration, repeated + * runs and runs that differ only in incidental order (file discovery, + * request order, write order) give the same observable output: ids, + * module graph, diagnostics. `expectDeterministic` compares what the test + * observes across such runs. + * - Relocation: the same project at another root must compile correctly + * and agree with itself (callers and providers share ids within a + * build), but opaque ids and bytes need not equal those of the other + * root. `expectRelocatable` checks each root on its own and compares + * only a `portable` projection the test chooses (route ids and paths, + * rendered output), never opaque ids. + * - Edits change the input: nothing is required to stay stable across an + * edit (J4.4), so neither helper takes one. + * + * API: + * - `reorderings(items)` and `reorderedRecords(record)`: the given order, + * the reverse, and an interleaving, as named variants. + * - `expectDeterministic(variants, observe)`: runs `observe` once per + * variant, one after another, and asserts every observation equals the + * first. Returns the observations. + * - `expectRelocatable(roots, observe, { check, portable? })`: runs + * `observe` at each root, applies `check(observation, root)` to each and, + * when given, asserts `portable(observation, root)` agrees across roots. + * - `withoutRoot(value, root)`: `value` with every spelling of `root` + * (posix, Windows separators, `file://` URL) in its strings replaced by + * ``, for portable projections of paths and diagnostics. + */ +import { pathToFileURL } from 'node:url' +import { expect } from 'vitest' + +/** A named order of the same items. */ +export interface Ordering { + name: string + items: Array +} + +/** + * The given order, the reverse, and an interleaving (odd positions first), + * deduplicated when they coincide (e.g. fewer than three items). + */ +export function reorderings(items: Array): Array> { + const orders: Array> = [ + { name: 'given order', items: [...items] }, + { name: 'reversed', items: [...items].reverse() }, + { + name: 'interleaved', + items: [ + ...items.filter((_, index) => index % 2 === 1), + ...items.filter((_, index) => index % 2 === 0), + ], + }, + ] + return orders.filter( + (order, index) => + orders.findIndex( + (other) => + other.items.length === order.items.length && + other.items.every((item, position) => item === order.items[position]), + ) === index, + ) +} + +/** + * `reorderings` of a record's entries (e.g. app files, whose key order is + * the order they are written in). + */ +export function reorderedRecords( + record: Record, +): Array & { record: Record }> { + return reorderings(Object.entries(record)).map((order) => ({ + ...order, + record: Object.fromEntries(order.items), + })) +} + +/** + * Runs `observe` for each variant, one after another, and asserts every + * observation deep-equals the first. Variants must share the effective + * root and configuration: only repetition or incidental order may differ. + */ +export async function expectDeterministic( + variants: Array, + observe: ( + variant: TVariant, + index: number, + ) => TObservation | Promise, +): Promise> { + const observations: Array = [] + for (const [index, variant] of variants.entries()) { + observations.push(await observe(variant, index)) + } + for (const [index, observation] of observations.entries()) { + expect(observation, `variant ${index} vs variant 0`).toEqual( + observations[0], + ) + } + return observations +} + +/** + * Runs `observe` at each root, one after another, and checks each + * observation on its own (`check`: correct results, ids agreeing within + * the build). Only `portable` projections are compared across roots. + */ +export async function expectRelocatable( + roots: Array, + observe: (root: string) => TObservation | Promise, + options: { + check: (observation: TObservation, root: string) => void | Promise + portable?: (observation: TObservation, root: string) => unknown + }, +): Promise> { + const observations: Array = [] + for (const root of roots) { + const observation = await observe(root) + await options.check(observation, root) + observations.push(observation) + } + if (options.portable) { + const portable = observations.map((observation, index) => + options.portable!(observation, roots[index]!), + ) + for (const [index, projection] of portable.entries()) { + expect(projection, `${roots[index]} vs ${roots[0]}`).toEqual(portable[0]) + } + } + return observations +} + +/** + * `value` with each spelling of `root` in its strings (and object keys) + * replaced by ``. + */ +export function withoutRoot(value: T, root: string): T { + const posix = root.replaceAll('\\', '/').replace(/\/+$/, '') + const spellings = [ + ...new Set([ + pathToFileURL(posix).href, + posix, + posix.replaceAll('/', '\\'), + root.replace(/[\\/]+$/, ''), + ]), + ].sort((a, b) => b.length - a.length) + // A spelling counts only where the root ends (not `/app` in `/apple`). + const pattern = new RegExp( + `(?:${spellings.map((spelling) => spelling.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?![\\w.-])`, + 'g', + ) + const replace = (text: string) => text.replace(pattern, '') + const visit = (current: unknown): unknown => { + if (typeof current === 'string') { + return replace(current) + } + if (Array.isArray(current)) { + return current.map(visit) + } + if ( + typeof current === 'object' && + current !== null && + Object.getPrototypeOf(current) === Object.prototype + ) { + return Object.fromEntries( + Object.entries(current).map(([key, item]) => [ + replace(key), + visit(item), + ]), + ) + } + return current + } + return visit(value) as T +} diff --git a/packages/compiler-test-utils/src/diagnostics.ts b/packages/compiler-test-utils/src/diagnostics.ts new file mode 100644 index 00000000000..a559027c9cc --- /dev/null +++ b/packages/compiler-test-utils/src/diagnostics.ts @@ -0,0 +1,710 @@ +/** + * One diagnostic oracle (FIX-PLAN A9; SPEC G5, J2.8, J4.10, §5.7). + * + * Tests capture what the user actually sees (a thrown or rejected error, a + * bundler's errors and warnings, console output) and assert outcomes only: + * the severity, the file named, the original position, the API or option + * names the user acts on, and, for `createServerOnlyFn`/`createClientOnlyFn`, + * the environment restriction. Never the prose, the code-frame layout or + * context length, nor whether a file is spelled absolute or project-relative + * (SPEC §5.7). + * + * API: + * - `diagnosticsOf(value, severity?)`: the diagnostics a captured value + * carries: an error (one diagnostic, including the errors it aggregates, + * e.g. a `BuildError`, plus one per warning it carries), a build result + * or stats object without a message (one per error and per warning), a + * Rollup, esbuild or webpack message object, a string, or an array. + * - `captureThrow(run)`: the diagnostics of what `run` throws or rejects + * with; fails when it completes. + * - `captureConsole(run)`: what `run` logs through `console`, by severity + * (`error`; `warn` is `warning`; `log`/`info`/`debug` are `info`), silenced. + * - `findDiagnostics(diagnostics, expectation)` and + * `expectDiagnostic(diagnostics, expectation)`: the diagnostics meeting a + * `DiagnosticExpectation`; the latter fails, listing why each diagnostic + * does not match, unless one (or exactly `times`) does. + * - `mentionsFile(text, file, options?)`: whether a text names a file. + * - `diagnosticPosition(source, needle)`: the 1-based line and column of + * the first occurrence of `needle` in `source`. + * - `identifiesEnvRestriction(error, env)`, `envRestrictionError(env)` (an + * asymmetric matcher) and `expectEnvRestrictionThrow(run, env)`: J4.10. + * + * Accepted spellings (any one suffices): + * - File: an absolute path, a `file://` URL, a Vite `/@fs/` URL, a + * `webpack:///` source, or any trailing run of whole path + * segments of the file (`src/routes/posts.tsx`, `routes/posts.tsx`, + * `posts.tsx`), with `/` or `\` separators, a query or a position suffix. + * A trailing run that also names a file of `distinctFrom` is ambiguous and + * does not count. + * - Position: the file followed by `:line`, `:line:column`, `(line,column)` + * or ` line:column`; a code frame line for `line` marked with `>` or + * followed by a `^` marker (the marker gives the column when `source` is + * known); a bracketed `(line:column)` or `[line:column]`; `line ` (and + * `column `); or a structured `loc`. Tools disagree on 0- or + * 1-based columns, so both are accepted. + * - Names: each API or option name as a whole identifier, anywhere. + */ +import path from 'node:path' +import { realpathSync } from 'node:fs' +import { formatWithOptions, inspect, stripVTControlCharacters } from 'node:util' +import { expect, vi } from 'vitest' + +export type Severity = 'error' | 'warning' | 'info' + +/** One message a user sees. */ +export interface Diagnostic { + severity: Severity + /** + * Everything the user reads, ANSI colours removed: the message, a code + * frame, and the structured fields some tools carry (Rollup's `id`, + * `loc`, `frame`), printed as `file:line:column` (1-based column). + */ + text: string + /** The captured value this diagnostic comes from. */ + raw: unknown +} + +export interface DiagnosticExpectation { + /** Accepted severities (any by default). */ + severity?: Severity | Array + /** + * The file the diagnostic must name: absolute, or relative to `root` + * (relative without `root`: matched as trailing path segments). + */ + file?: string + /** The project root `file` is relative to. */ + root?: string + /** Other files the named file must be distinguishable from. */ + distinctFrom?: Array + /** + * The original position the diagnostic must report: 1-based line, and + * optionally 1-based column (a 0-based spelling is accepted too). + */ + position?: { line: number; column?: number } + /** + * The original source of `file`; lets a code frame's `^` marker give the + * column of `position`. + */ + source?: string + /** API or option names the diagnostic must name (whole identifiers). */ + names?: Array + /** How many diagnostics must match (default: at least one). */ + times?: number +} + +/** The diagnostics a captured value carries. */ +export function diagnosticsOf( + value: unknown, + severity: Severity = 'error', +): Array { + if (value === undefined || value === null) { + return [] + } + if (Array.isArray(value)) { + return value.flatMap((item) => diagnosticsOf(item, severity)) + } + if (typeof value === 'string') { + return [{ severity, text: stripVTControlCharacters(value), raw: value }] + } + if (typeof value !== 'object') { + return [{ severity, text: String(value), raw: value }] + } + const record = value as Record + const errors = Array.isArray(record.errors) ? record.errors : [] + const warnings = Array.isArray(record.warnings) ? record.warnings : [] + if (!hasMessage(record) && (errors.length > 0 || warnings.length > 0)) { + // A build result or stats: its errors and warnings are the diagnostics. + return [ + ...diagnosticsOf(errors, 'error'), + ...diagnosticsOf(warnings, 'warning'), + ] + } + // An error (with the errors it aggregates, e.g. a `BuildError` or an + // esbuild failure, read as one) and the warnings it carries. + return [ + { severity, text: describe(record), raw: value }, + ...diagnosticsOf(warnings, 'warning'), + ] +} + +function hasMessage(record: Record) { + return typeof record.message === 'string' || typeof record.text === 'string' +} + +/** + * The text of an error or a warning object: its message, its frame, the + * file and location fields tools add (Rollup/Vite `id`/`loc`/`frame`, + * esbuild `text`/`location`, Oxc `codeframe`, webpack `moduleName`/`loc`), + * the errors it aggregates and its cause. + */ +function describe(record: Record, depth = 0): string { + const lines: Array = [] + const message = [record.message, record.text].find( + (candidate): candidate is string => typeof candidate === 'string', + ) + if (message !== undefined) { + lines.push(message) + } else if (!(record instanceof Error)) { + lines.push(inspect(record, { depth: 3 })) + } + for (const frame of [record.frame, record.codeframe]) { + if (typeof frame === 'string') { + lines.push(frame) + } + } + const loc = (record.loc ?? record.location) as + | { file?: unknown; line?: unknown; column?: unknown } + | string + | undefined + const file = [ + typeof loc === 'object' ? loc.file : undefined, + record.id, + record.file, + record.moduleName, + ].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate !== '', + ) + if (typeof loc === 'object' && typeof loc.line === 'number') { + // Rollup, Vite, Babel and esbuild locations have 0-based columns. + const column = typeof loc.column === 'number' ? `:${loc.column + 1}` : '' + lines.push(`${file ?? ''}:${loc.line}${column}`) + } else if (typeof loc === 'string') { + // webpack: `line:column-column`. + lines.push(`${file ?? ''} ${loc}`) + } else if (file !== undefined) { + lines.push(file) + } + if (depth < 3) { + for (const nested of [ + ...(Array.isArray(record.errors) ? record.errors : []), + record.cause, + ]) { + if (typeof nested === 'string') { + lines.push(nested) + } else if (nested && typeof nested === 'object') { + lines.push(describe(nested as Record, depth + 1)) + } + } + } + return stripVTControlCharacters(lines.join('\n')) +} + +/** + * The diagnostics of what `run` throws or rejects with. Fails when `run` + * completes. + */ +export async function captureThrow( + run: () => unknown, +): Promise> { + let result: unknown + try { + result = await run() + } catch (error) { + return diagnosticsOf(error, 'error') + } + throw new Error( + `expected a throw or a rejection, got ${inspect(result, { depth: 2 })}`, + ) +} + +const consoleSeverities = { + error: 'error', + warn: 'warning', + log: 'info', + info: 'info', + debug: 'info', +} as const + +/** + * Runs `run` and collects what it logs through `console` (silenced), as + * diagnostics. Console methods are restored afterwards, also when `run` + * throws. + */ +export async function captureConsole( + run: () => T | Promise, +): Promise<{ result: T; diagnostics: Array }> { + const diagnostics: Array = [] + const spies = ( + Object.keys(consoleSeverities) as Array + ).map((method) => + vi.spyOn(console, method).mockImplementation((...args: Array) => { + diagnostics.push({ + severity: consoleSeverities[method], + text: stripVTControlCharacters( + args + .map((arg) => + arg instanceof Error + ? describe(arg as unknown as Record) + : typeof arg === 'string' + ? arg + : formatWithOptions({ colors: false }, '%o', arg), + ) + .join(' '), + ), + raw: args, + }) + }), + ) + try { + return { result: await run(), diagnostics } + } finally { + for (const spy of spies) { + spy.mockRestore() + } + } +} + +/** Why `diagnostic` does not meet `expected` (empty when it does). */ +export function mismatches( + diagnostic: Diagnostic, + expected: DiagnosticExpectation, +): Array { + const reasons: Array = [] + if (expected.severity !== undefined) { + const severities = ([] as Array).concat(expected.severity) + if (!severities.includes(diagnostic.severity)) { + reasons.push( + `severity ${diagnostic.severity}, expected ${severities.join(' or ')}`, + ) + } + } + const fileMentions = + expected.file === undefined + ? [] + : fileTokens(diagnostic.text, expected.file, expected) + if (expected.file !== undefined && fileMentions.length === 0) { + reasons.push(`does not name ${expected.file} unambiguously`) + } + if ( + expected.position !== undefined && + !reportsPosition( + diagnostic.text, + expected.position, + fileMentions, + expected.source, + ) + ) { + const { line, column } = expected.position + reasons.push( + `does not report line ${line}${column === undefined ? '' : `, column ${column}`}`, + ) + } + for (const name of expected.names ?? []) { + if (!namesIdentifier(diagnostic.text, name)) { + reasons.push(`does not name \`${name}\``) + } + } + return reasons +} + +/** + * The diagnostics that meet `expected` (its `times` is ignored). `captured` + * is diagnostics, or any value `diagnosticsOf` reads (an error, warnings). + */ +export function findDiagnostics( + captured: unknown, + expected: DiagnosticExpectation, +): Array { + return toDiagnostics(captured).filter( + (diagnostic) => mismatches(diagnostic, expected).length === 0, + ) +} + +function isDiagnostic(value: unknown): value is Diagnostic { + return ( + typeof value === 'object' && + value !== null && + 'severity' in value && + 'raw' in value && + typeof (value as Diagnostic).text === 'string' + ) +} + +/** + * Diagnostics as given, or the diagnostics a captured value carries + * (`diagnosticsOf`). + */ +function toDiagnostics(captured: unknown): Array { + if (isDiagnostic(captured)) { + return [captured] + } + if (Array.isArray(captured) && captured.every(isDiagnostic)) { + return captured + } + return diagnosticsOf(captured) +} + +/** + * Asserts that at least one diagnostic of `captured` (diagnostics, or any + * value `diagnosticsOf` reads; exactly `times`, when given) meets + * `expected`, and returns the first that does. The failure lists every + * diagnostic with the reasons it does not match. + */ +export function expectDiagnostic( + captured: unknown, + expected: DiagnosticExpectation, +): Diagnostic { + const all = toDiagnostics(captured) + const matching = findDiagnostics(all, expected) + const ok = + expected.times === undefined + ? matching.length > 0 + : matching.length === expected.times + if (!ok) { + const report = all + .map((diagnostic, index) => { + const reasons = mismatches(diagnostic, expected) + return `#${index} [${diagnostic.severity}] ${reasons.length ? reasons.join('; ') : 'matches'}\n${indent(diagnostic.text)}` + }) + .join('\n') + const wanted = + expected.times === undefined + ? 'at least one diagnostic' + : `exactly ${expected.times} diagnostic(s)` + expect.fail( + `expected ${wanted} meeting ${inspect(expected, { depth: 2 })}, ${matching.length} of ${all.length} do:\n${report}`, + ) + } + return matching[0]! +} + +function indent(text: string) { + return text.replace(/^/gm, ' ') +} + +/** Whether `text` names `name` as a whole identifier. */ +function namesIdentifier(text: string, name: string) { + return new RegExp( + `(? +} + +/** + * A path token in a message: path characters (no quotes, spaces, colons, + * queries or parentheses), after an optional drive letter. + */ +const pathToken = /(?:\/{0,3}[A-Za-z]:(?=[\\/]))?[\w@.~+\-/\\%$#]+/g + +/** + * A token as a path: `/` separators, without URL scheme remnants (the + * `///` of `file:///`, Vite's `/@fs`), hash, trailing sentence dots or URI + * encoding. + */ +function normaliseToken(raw: string) { + let token = raw.replaceAll('\\', '/') + token = token.replace(/^\/+(?=[A-Za-z]:\/)/, '') + token = token.replace(/^\/{2,}/, '/') + token = token.replace(/^\/@fs(?=\/)/, '') + token = token.replace(/#.*$/, '') + token = token.replace(/\.+$/, '') + try { + token = decodeURI(token) + } catch { + // Not URI-encoded. + } + return token +} + +/** `file` as an absolute path (posix separators), when `root` is known. */ +function absoluteOf(file: string, root: string | undefined) { + const posix = file.replaceAll('\\', '/') + if (path.posix.isAbsolute(posix) || /^[A-Za-z]:\//.test(posix)) { + return posix + } + return root === undefined + ? undefined + : path.posix.join(root.replaceAll('\\', '/'), posix) +} + +function realOf(file: string | undefined) { + if (file === undefined) { + return undefined + } + try { + return realpathSync(file).replaceAll('\\', '/') + } catch { + return undefined + } +} + +/** Whether a normalised token denotes `file` (see the file header). */ +function denotes(token: string, file: string, root: string | undefined) { + if (!/[\w$]\.[\w]+$/.test(token) && !token.includes('/')) { + return false + } + // Leading `./` and `../` (a source relative to its map) are dropped: the + // rest must be trailing segments of the file. + const relative = path.posix.normalize(token).replace(/^(?:\.\.?\/)+/, '') + const absolute = absoluteOf(file, root) + const fileRelative = path.posix + .normalize(file.replaceAll('\\', '/')) + .replace(/^(?:\.\/)+/, '') + const tokenIsAbsolute = token.startsWith('/') || /^[A-Za-z]:\//.test(token) + const isSuffix = (whole: string, part: string) => + whole === part || whole.endsWith(`/${part}`) + if (tokenIsAbsolute) { + if (absolute !== undefined) { + const normal = path.posix.normalize(token) + return normal === absolute || normal === realOf(absolute) + } + return isSuffix(path.posix.normalize(token), fileRelative) + } + // `webpack:///./src/a.ts` leaves `/./src/a.ts` once + // the scheme is gone; its path after the namespace is project-relative. + const candidates = [relative] + const namespaced = /^[^/]*\/\.\/(.*)$/.exec(token) + if (namespaced) { + candidates.push(path.posix.normalize(namespaced[1]!)) + } + return candidates.some((candidate) => + absolute !== undefined + ? isSuffix(absolute, candidate) + : isSuffix(fileRelative, candidate) || isSuffix(candidate, fileRelative), + ) +} + +/** + * The tokens of `text` that name `file` unambiguously, with the index just + * after each (where a position suffix starts). + */ +function fileTokens(text: string, file: string, options: FileOptions = {}) { + const found: Array<{ token: string; end: number }> = [] + for (const match of text.matchAll(pathToken)) { + const start = match.index + // `webpack:///` sources: the scheme and its slashes go. + const token = normaliseToken( + text.slice(Math.max(0, start - 8), start) === 'webpack:' + ? match[0].replace(/^\/\//, '') + : match[0], + ) + if (!denotes(token, file, options.root)) { + continue + } + if ( + options.distinctFrom?.some((other) => denotes(token, other, options.root)) + ) { + continue + } + // The end of the token in `text`, before trailing dots of a sentence. + const end = start + match[0].replace(/\.+$/, '').length + found.push({ token, end }) + } + return found +} + +/** + * Whether `text` names `file`: absolute, project-relative (to `root`) or as + * trailing path segments, unambiguous among `distinctFrom`. + */ +export function mentionsFile( + text: string, + file: string, + options: FileOptions = {}, +) { + return fileTokens(stripVTControlCharacters(text), file, options).length > 0 +} + +// --------------------------------------------------------------------------- +// Positions + +/** Line terminators of JavaScript (and of editors). */ +const lineTerminator = /\r\n|[\n\r\u2028\u2029]/ + +/** + * The 1-based line and column of the first occurrence of `needle` (or of + * an offset) in `source`, counting every JavaScript line terminator. + */ +export function diagnosticPosition(source: string, needle: string | number) { + const offset = typeof needle === 'number' ? needle : source.indexOf(needle) + if (offset < 0) { + throw new Error(`${JSON.stringify(needle)} is not in the source`) + } + const lines = source.slice(0, offset).split(lineTerminator) + return { line: lines.length, column: lines.at(-1)!.length + 1 } +} + +function columnMatches(reported: number, column: number | undefined) { + return column === undefined || reported === column || reported === column - 1 +} + +function reportsPosition( + text: string, + position: { line: number; column?: number }, + fileMentions: Array<{ end: number }>, + source: string | undefined, +) { + const { line, column } = position + // The file followed by its position. + for (const { end } of fileMentions) { + const after = text.slice(end, end + 24) + const suffix = /^(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\)| (\d+):(\d+))/.exec( + after, + ) + if (suffix) { + const reportedLine = Number(suffix[1] ?? suffix[3] ?? suffix[5]) + const reportedColumn = suffix[2] ?? suffix[4] ?? suffix[6] + if ( + reportedLine === line && + (column === undefined || + (reportedColumn !== undefined && + columnMatches(Number(reportedColumn), column))) + ) { + return true + } + } + } + // A bracketed `(line:column)` (Babel) or `[line:column]` (a miette + // frame header, as SWC prints it). + for (const match of text.matchAll(/[([](\d+):(\d+)[)\]]/g)) { + if (Number(match[1]) === line && columnMatches(Number(match[2]), column)) { + return true + } + } + // `line 12` (and `column 5`). + if ( + new RegExp(`\\bline:?\\s+${line}\\b`, 'i').test(text) && + (column === undefined || + [column, column - 1].some((candidate) => + new RegExp(`\\bcol(?:umn)?:?\\s+${candidate}\\b`, 'i').test(text), + )) + ) { + return true + } + return frameReportsPosition(text, line, column, source) +} + +/** + * Whether a code frame in `text` marks `line` (a `>` marker, or a `^` + * marker line below it) and, when `column` and `source` are known, puts a + * `^` under that column. + */ +function frameReportsPosition( + text: string, + line: number, + column: number | undefined, + source: string | undefined, +) { + const lines = text.split(/\r?\n/) + for (let index = 0; index < lines.length; index++) { + const frameLine = /^\s*(>)?\s*(\d+)\s*[|│]/.exec(lines[index]!) + if (!frameLine || Number(frameLine[2]) !== line) { + continue + } + const markerLine = lines[index + 1] ?? '' + const caret = /^\s*>?\s*[|│]\s*\^/.test(markerLine) + ? markerLine.indexOf('^') + : -1 + if (!frameLine[1] && caret < 0) { + continue + } + if (column === undefined) { + return true + } + if (caret < 0 || source === undefined) { + continue + } + // Where the quoted source line starts in the frame line. + const sourceLine = source.split(lineTerminator)[line - 1] ?? '' + const gutter = lines[index]!.search(/[|│]/) + const start = lines[index]!.indexOf(sourceLine, gutter + 1) + if (start >= 0 && columnMatches(caret - start + 1, column)) { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Environment restrictions (J4.10) + +export type Environment = 'server' | 'client' + +/** How a message may name each environment. */ +const environmentNames: Record = { + server: /server/i, + client: /client|browser/i, +} + +/** Errors the engine throws when code crashes, not deliberate errors. */ +const engineErrors = new Set([ + 'TypeError', + 'ReferenceError', + 'SyntaxError', + 'RangeError', +]) + +/** + * Whether `error` is a deliberate error (not an engine `TypeError`, + * `ReferenceError`, …) whose message names the environment `env` the + * called function is restricted to (`client` may be called `browser`). + * The prose is free (SPEC §5.7, J4.10). + */ +export function identifiesEnvRestriction(error: unknown, env: Environment) { + if (typeof error !== 'object' || error === null) { + return false + } + const { name, message } = error as { name?: unknown; message?: unknown } + if (typeof message !== 'string') { + return false + } + if (typeof name === 'string' && engineErrors.has(name)) { + return false + } + return environmentNames[env].test(stripVTControlCharacters(message)) +} + +/** + * An asymmetric matcher of an error restricting a function to `env` + * (`identifiesEnvRestriction`), for `toEqual` and deep equality. + */ +export function envRestrictionError(env: Environment) { + return expect.toSatisfy( + (value: unknown) => identifiesEnvRestriction(value, env), + `an error restricting the call to the ${env}`, + ) +} + +/** + * Calls `run` and asserts it throws (or, when it returns a promise, + * rejects with) an error restricting it to `env`. Returns the error. + */ +export function expectEnvRestrictionThrow( + run: () => unknown, + env: Environment, +): unknown { + let result: unknown + try { + result = run() + } catch (error) { + return check(error) + } + if (result instanceof Promise) { + return result.then( + (value: unknown) => { + throw new Error( + `expected a call restricted to the ${env} to reject, it resolved with ${inspect(value)}`, + ) + }, + (error: unknown) => check(error), + ) + } + expect.fail( + `expected a call restricted to the ${env} to throw, it returned ${inspect(result, { depth: 2 })}`, + ) + + function check(error: unknown) { + expect(error, `an error restricting the call to the ${env}`).toEqual( + envRestrictionError(env), + ) + return error + } +} diff --git a/packages/compiler-test-utils/src/importers.ts b/packages/compiler-test-utils/src/importers.ts new file mode 100644 index 00000000000..81ad36dc496 --- /dev/null +++ b/packages/compiler-test-utils/src/importers.ts @@ -0,0 +1,117 @@ +/** + * Real importers and behavioural probes instead of inventories (FIX-PLAN + * A10; SPEC G9, §5.3, §5.6). A compiled module's export list, import list + * and generated names are free; what is observable is what a real importer + * gets (the same value and the same instance the module itself uses) and + * which modules actually load. + * + * API: + * - `importerSource(specifier, names)`: the source of a module that + * imports `names` from `specifier` (named, `default`, or names that are + * not identifiers) and exports them as live getters on `bindings`. Write + * it into a real app (a build entry, a dev-server module) or add it to a + * module graph. + * - `importBindings(graph, specifier, names, options?)`: adds such a module + * to a `createModuleGraph` graph, evaluates it and returns its live + * `bindings`: compare them (`toBe`) with what the module uses, call them. + * - `loadMarker(name)`: a statement that records `name` when the module it + * is in evaluates; put it in a stub or a fixture file. `loadedModules()` + * and `resetLoadedModules()` read and clear the records of this realm; + * `loadedModulesExpression` reads them in another realm (a child Node + * run, a browser page). + * - `expectNotLoaded(loaded, forbidden)`: asserts no forbidden module is + * in a list of loaded modules (`loadedModules()`, `graph.evaluated`). + */ +import { expect } from 'vitest' +import type { ModuleGraph } from './module-linker' + +const identifier = /^[A-Za-z_$][\w$]*$/ + +/** + * The source of a module importing `names` from `specifier` and exporting + * them, live, as getters of `bindings` (`bindings.default` for the default + * export). + */ +export function importerSource(specifier: string, names: Array) { + const quoted = JSON.stringify(specifier) + const local = (index: number) => `__imported${index}` + const imports = names.map((name, index) => + name === 'default' + ? `import ${local(index)} from ${quoted}` + : `import { ${identifier.test(name) ? name : JSON.stringify(name)} as ${local(index)} } from ${quoted}`, + ) + const getters = names.map( + (name, index) => + ` get ${JSON.stringify(name)}() { return ${local(index)} },`, + ) + return `${imports.join('\n')} +export const bindings = { +${getters.join('\n')} +} +` +} + +let importers = 0 + +/** + * Adds a module importing `names` from `specifier` to `graph` (as + * `options.id`, by default a fresh `/importer-.ts`), evaluates it and + * returns its live bindings. `specifier` resolves as the graph resolves + * imports of a module at `options.id`. + */ +export async function importBindings( + graph: ModuleGraph, + specifier: string, + names: Array, + options: { id?: string } = {}, +): Promise> { + const id = options.id ?? `/importer-${++importers}.ts` + graph.add(id, importerSource(specifier, names)) + const { bindings } = await graph.import(id) + return bindings as Record +} + +const loadedKey = 'tanstack.compiler-test-utils.loaded' + +/** + * An expression evaluating to the names `loadMarker` recorded in its realm, + * in load order (an empty array when none). + */ +export const loadedModulesExpression = `(globalThis[Symbol.for(${JSON.stringify(loadedKey)})] ?? [])` + +/** + * A statement recording `name` in its realm when the module containing it + * evaluates. Valid in any JavaScript or TypeScript module. + */ +export function loadMarker(name: string) { + return `;(globalThis[Symbol.for(${JSON.stringify(loadedKey)})] ??= []).push(${JSON.stringify(name)});\n` +} + +function records(): Array { + const global = globalThis as unknown as Record> + return (global[Symbol.for(loadedKey)] ??= []) +} + +/** The names `loadMarker` recorded in this realm, in load order. */ +export function loadedModules(): Array { + return [...records()] +} + +/** Clears the records of `loadMarker` in this realm. */ +export function resetLoadedModules() { + records().length = 0 +} + +/** + * Asserts that none of `forbidden` is in `loaded` (names recorded by + * `loadMarker`, or module ids such as `graph.evaluated`). + */ +export function expectNotLoaded( + loaded: ReadonlyArray, + forbidden: Array, +) { + expect( + forbidden.filter((name) => loaded.includes(name)), + 'forbidden modules that loaded', + ).toEqual([]) +} diff --git a/packages/compiler-test-utils/src/source-maps.ts b/packages/compiler-test-utils/src/source-maps.ts new file mode 100644 index 00000000000..d6264c7ed1a --- /dev/null +++ b/packages/compiler-test-utils/src/source-maps.ts @@ -0,0 +1,336 @@ +/** + * Source-map assertions through a standard consumer (FIX-PLAN A12; SPEC G6, + * §5.14): maps are read with `@jridgewell/trace-mapping`, never a hand + * decoder, and tests locate code by parsed tokens or executed throws, never + * by printed syntax. Only "this generated position resolves to the user's + * file, line (and column)" is asserted: map layout, `names`, + * `sourcesContent`, granularity and mappings of generated support code are + * free. + * + * Positions follow the source-map convention: `line` 1-based, `column` + * 0-based. Lines are counted with every JavaScript line terminator (`\n`, + * `\r\n`, a lone `\r`, U+2028, U+2029), so CRLF sources work. + * + * API: + * - `inlineSourceMap(code)`: the map of a `//# sourceMappingURL=data:` + * comment (what dev servers serve), or `undefined`. + * - `positionAt(code, offset)` and `offsetAt(code, position)`. + * - `findNodes(code, predicate, options?)` and `tokenOffsets(code, query, + * options?)`: offsets of parsed syntax (identifiers, string values, a + * `throw`), whatever the printing. + * - `originalPositionOf(map, generated)`: the original `{ source, line, + * column }` of a generated position, or `undefined` when unmapped. + * - `originalPositions(map)`: every original position the map points at. + * - `traceStack(stack, mapOf)`: the frames of an executed throw's stack, + * each resolved through the map `mapOf` gives for its file. + * - `expectMapsTo(map, generated, expected)`: asserts a generated position + * resolves to `expected.file` (any spelling `mentionsFile` accepts), + * `expected.line` and, when given, `expected.column`. + */ +import { + TraceMap, + eachMapping, + originalPositionFor, +} from '@jridgewell/trace-mapping' +import { parseSync } from 'vite' +import { expect } from 'vitest' +import { mentionsFile } from './diagnostics' +import type { SourceMapInput } from '@jridgewell/trace-mapping' + +export type { SourceMapInput } from '@jridgewell/trace-mapping' + +/** A position in a module: `line` 1-based, `column` 0-based. */ +export interface Position { + line: number + column: number +} + +/** An original position a map points at. */ +export interface OriginalPosition extends Position { + /** The source as the map names it (resolved against `sourceRoot`). */ + source: string +} + +/** A map, in any form trace-mapping reads, or a map it already read. */ +export type AnySourceMap = SourceMapInput | TraceMap | LooseSourceMap + +/** + * A version 3 map as tools type it (Vite, Babel, the build harness: a + * `number` version, possibly `null` sources). + */ +export interface LooseSourceMap { + version: number + mappings: string + sources: ReadonlyArray + names?: ReadonlyArray + sourceRoot?: string + sourcesContent?: ReadonlyArray + file?: string | null +} + +function traceMap(map: AnySourceMap) { + return map instanceof TraceMap ? map : new TraceMap(map as SourceMapInput) +} + +/** + * The source map of a `//# sourceMappingURL=data:…` comment in `code`, or + * `undefined` when there is none (an external map is fetched by the test). + */ +export function inlineSourceMap(code: string): SourceMapInput | undefined { + const comments = [ + ...code.matchAll( + /[#@] sourceMappingURL=data:application\/json(?:;charset=[\w-]+)?(;base64)?,([^\s*'"]+)/g, + ), + ] + const last = comments.at(-1) + if (!last) { + return undefined + } + const json = last[1] + ? Buffer.from(last[2]!, 'base64').toString('utf8') + : decodeURIComponent(last[2]!) + return JSON.parse(json) as SourceMapInput +} + +const lineTerminators = /\r\n|[\n\r\u2028\u2029]/g + +/** The position of `offset` in `code`. */ +export function positionAt(code: string, offset: number): Position { + let line = 1 + let lineStart = 0 + for (const match of code.slice(0, offset).matchAll(lineTerminators)) { + line++ + lineStart = match.index + match[0].length + } + return { line, column: offset - lineStart } +} + +/** The offset of `position` in `code`. */ +export function offsetAt(code: string, position: Position): number { + let line = 1 + let lineStart = 0 + for (const match of code.matchAll(lineTerminators)) { + if (line === position.line) { + break + } + line++ + lineStart = match.index + match[0].length + } + if (line !== position.line) { + throw new Error(`line ${position.line} is not in the code`) + } + return lineStart + position.column +} + +/** A parsed syntax node (ESTree, with offsets). */ +export interface SyntaxNode { + type: string + start: number + end: number + [key: string]: unknown +} + +function isNode(value: unknown): value is SyntaxNode { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SyntaxNode).type === 'string' && + typeof (value as SyntaxNode).start === 'number' + ) +} + +/** + * The nodes of `code` (parsed as `filename`, default `module.tsx`) that + * `predicate` accepts, in source order. + */ +export function findNodes( + code: string, + predicate: (node: SyntaxNode) => boolean, + options: { filename?: string } = {}, +): Array { + const { program, errors } = parseSync( + options.filename ?? 'module.tsx', + code, + { sourceType: 'module' }, + ) + if (errors.length > 0) { + throw new Error( + `cannot parse the code: ${errors.map((error) => error.message).join('; ')}`, + ) + } + const found: Array = [] + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (typeof value !== 'object' || value === null) { + return + } + if (isNode(value) && predicate(value)) { + found.push(value) + } + for (const [key, child] of Object.entries(value)) { + if (key !== 'parent') { + visit(child) + } + } + } + visit(program) + return found.sort((a, b) => a.start - b.start) +} + +/** What `tokenOffsets` looks for. */ +export type TokenQuery = + /** An identifier (binding, reference, property or JSX name). */ + | { identifier: string } + /** A string literal or a template part with this value, any quotes. */ + | { string: string } + /** A `throw` statement. */ + | { throw: true } + +/** Start offsets of the tokens of `code` matching `query`. */ +export function tokenOffsets( + code: string, + query: TokenQuery, + options: { filename?: string } = {}, +): Array { + const matches = (node: SyntaxNode) => { + if ('identifier' in query) { + return ( + /Identifier$/.test(node.type) && + (node.name === query.identifier || + (node.type === 'PrivateIdentifier' && + `#${String(node.name)}` === query.identifier)) + ) + } + if ('string' in query) { + return ( + (node.type === 'Literal' && node.value === query.string) || + (node.type === 'JSXText' && node.value === query.string) || + (node.type === 'TemplateElement' && + (node.value as { cooked?: string } | undefined)?.cooked === + query.string) + ) + } + return node.type === 'ThrowStatement' + } + return findNodes(code, matches, options).map((node) => node.start) +} + +/** + * The original position a generated position maps to, or `undefined` when + * the map has no mapping for it. + */ +export function originalPositionOf( + map: AnySourceMap, + generated: Position, +): OriginalPosition | undefined { + const original = originalPositionFor(traceMap(map), generated) + if (original.source === null) { + return undefined + } + return { + source: original.source, + line: original.line, + column: original.column, + } +} + +/** Every original position the mappings of `map` point at. */ +export function originalPositions(map: AnySourceMap): Array { + const positions: Array = [] + eachMapping(traceMap(map), (mapping) => { + if (mapping.source !== null) { + positions.push({ + source: mapping.source, + line: mapping.originalLine, + column: mapping.originalColumn, + }) + } + }) + return positions +} + +/** A frame of a stack trace, resolved through its file's map. */ +export interface TracedFrame { + /** The file (path or URL) the frame names. */ + location: string + /** The frame's generated position. */ + generated: Position + /** Where the map of `location` resolves it, when it has one. */ + original?: OriginalPosition +} + +/** + * The frames of `stack` (V8, SpiderMonkey and JavaScriptCore formats), + * each resolved through the map `mapOf(location)` returns for its file + * (`undefined`: no map, the frame stays unresolved). + */ +export function traceStack( + stack: string, + mapOf: (location: string) => AnySourceMap | undefined, +): Array { + const frames: Array = [] + const maps = new Map() + for (const line of stack.split('\n')) { + const frame = + /((?:[a-z][\w+.-]*:\/\/?)?[^\s()@]+?):(\d+):(\d+)\)?\s*$/.exec(line) + if (!frame) { + continue + } + const location = frame[1]! + if (!maps.has(location)) { + const map = mapOf(location) + maps.set(location, map === undefined ? undefined : traceMap(map)) + } + // Stack columns are 1-based. + const generated = { line: Number(frame[2]), column: Number(frame[3]) - 1 } + const map = maps.get(location) + const original = map ? originalPositionOf(map, generated) : undefined + frames.push({ location, generated, ...(original && { original }) }) + } + return frames +} + +/** What `expectMapsTo` requires of an original position. */ +export interface ExpectedOriginal { + /** The user's file: absolute, relative to `root`, or trailing segments. */ + file: string + root?: string + /** Other files the source must be distinguishable from. */ + distinctFrom?: Array + /** 1-based line. */ + line: number + /** 0-based column; checked only when given. */ + column?: number +} + +/** + * Asserts that `generated` resolves through `map` to `expected`, and + * returns the original position. + */ +export function expectMapsTo( + map: AnySourceMap, + generated: Position, + expected: ExpectedOriginal, +): OriginalPosition { + const original = originalPositionOf(map, generated) + const description = `generated ${generated.line}:${generated.column}` + expect(original, `${description} has a mapping`).toBeDefined() + expect( + mentionsFile(original!.source, expected.file, expected), + `${description} maps to ${original!.source}, expected ${expected.file}`, + ).toBe(true) + expect( + expected.column === undefined + ? { line: original!.line } + : { line: original!.line, column: original!.column }, + description, + ).toEqual( + expected.column === undefined + ? { line: expected.line } + : { line: expected.line, column: expected.column }, + ) + return original! +} diff --git a/packages/compiler-test-utils/tests/determinism.test.ts b/packages/compiler-test-utils/tests/determinism.test.ts new file mode 100644 index 00000000000..34d9eaaa607 --- /dev/null +++ b/packages/compiler-test-utils/tests/determinism.test.ts @@ -0,0 +1,115 @@ +/** + * Controls of the determinism and relocation helpers (determinism.ts): + * reorderings really reorder, determinism compares every variant, and + * relocation checks each root on its own while comparing only the portable + * projection. + */ +import { describe, expect, test } from 'vitest' +import { + expectDeterministic, + expectRelocatable, + reorderedRecords, + reorderings, + withoutRoot, +} from '../src/determinism' + +describe('reorderings', () => { + test('give the order, the reverse and an interleaving', () => { + expect(reorderings([1, 2, 3, 4])).toEqual([ + { name: 'given order', items: [1, 2, 3, 4] }, + { name: 'reversed', items: [4, 3, 2, 1] }, + { name: 'interleaved', items: [2, 4, 1, 3] }, + ]) + }) + + test('drop orders that coincide', () => { + expect(reorderings([1, 2]).map(({ name }) => name)).toEqual([ + 'given order', + 'reversed', + ]) + }) + + test('reorder the entries of a record', () => { + expect( + reorderedRecords({ a: 1, b: 2, c: 3 }).map(({ record }) => + Object.keys(record), + ), + ).toEqual([ + ['a', 'b', 'c'], + ['c', 'b', 'a'], + ['b', 'a', 'c'], + ]) + }) +}) + +describe('expectDeterministic', () => { + test('passes when every variant observes the same and returns the observations', async () => { + const observations = await expectDeterministic( + reorderings(['b', 'a']), + ({ items }) => [...items].sort(), + ) + expect(observations).toHaveLength(2) + }) + + test('fails when one variant observes something else', async () => { + await expect( + expectDeterministic(reorderings(['b', 'a']), ({ items }) => items[0]), + ).rejects.toThrow() + }) +}) + +describe('expectRelocatable', () => { + const observe = (root: string) => ({ + // An opaque id may differ between roots. + id: `${root.length}`, + callerId: `${root.length}`, + file: `${root}/src/a.ts`, + }) + + test('checks each root and compares only the portable projection', async () => { + const checked: Array = [] + await expectRelocatable(['/a', '/deeper/b'], observe, { + check: (observation, root) => { + checked.push(root) + expect(observation.callerId).toBe(observation.id) + }, + portable: (observation, root) => withoutRoot(observation.file, root), + }) + expect(checked).toEqual(['/a', '/deeper/b']) + }) + + test('fails when a root fails its own check or the portable projections differ', async () => { + await expect( + expectRelocatable(['/a'], observe, { + check: (observation) => expect(observation.id).toBe('wrong'), + }), + ).rejects.toThrow() + await expect( + expectRelocatable(['/a', '/b'], observe, { + check: () => {}, + portable: (observation) => observation.file, + }), + ).rejects.toThrow() + }) +}) + +test('withoutRoot replaces every spelling of the root, in values and keys', () => { + expect( + withoutRoot( + { + '/app/src/a.ts': [ + 'file:///app/src/a.ts:1:2', + '\\app\\src\\a.ts', + '/application/x', + ], + }, + '/app', + ), + ).toEqual({ + '/src/a.ts': [ + '/src/a.ts:1:2', + '\\src\\a.ts', + '/application/x', + ], + }) +}) diff --git a/packages/compiler-test-utils/tests/diagnostics.test.ts b/packages/compiler-test-utils/tests/diagnostics.test.ts new file mode 100644 index 00000000000..5fb540f1e10 --- /dev/null +++ b/packages/compiler-test-utils/tests/diagnostics.test.ts @@ -0,0 +1,343 @@ +/** + * Controls of the diagnostic oracle (diagnostics.ts): it reads the real + * channels (thrown errors of real transforms, console output, aggregated + * build errors and warnings), accepts every spelling SPEC §5.7 frees (file + * absolute or relative, any frame layout, 0- or 1-based columns, any prose) + * and still rejects a diagnostic that names another file, another line or + * not the API/option, or the wrong environment. + */ +import * as esbuild from 'esbuild' +import { transformWithOxc } from 'vite' +import { describe, expect, test } from 'vitest' +import { + captureConsole, + captureThrow, + diagnosticPosition, + diagnosticsOf, + envRestrictionError, + expectDiagnostic, + expectEnvRestrictionThrow, + findDiagnostics, + identifiesEnvRestriction, + mentionsFile, +} from '../src/diagnostics' + +const broken = `const a = 1 +export const b = ( +` + +describe('real channels', () => { + test('an Oxc parse error names the file and the line, whatever its path spelling', async () => { + const diagnostics = await captureThrow(() => + transformWithOxc(broken, '/app/src/a.ts', { lang: 'ts' }), + ) + expect(diagnostics).toHaveLength(1) + expectDiagnostic(diagnostics, { + severity: 'error', + file: 'src/a.ts', + root: '/app', + position: { line: 2 }, + }) + expect( + findDiagnostics(diagnostics, { file: 'src/a.ts', root: '/other' }), + ).toEqual([]) + expect( + findDiagnostics(diagnostics, { + file: 'src/a.ts', + root: '/app', + position: { line: 1 }, + }), + ).toEqual([]) + }) + + test('an esbuild failure reads as one error with its structured location', async () => { + const diagnostics = await captureThrow(() => + esbuild.transform(broken, { loader: 'ts', sourcefile: '/app/src/a.ts' }), + ) + expectDiagnostic(diagnostics, { + severity: 'error', + file: '/app/src/a.ts', + position: { line: 3, column: 1 }, + times: 1, + }) + }) + + test('captureThrow fails when the run completes', async () => { + await expect(captureThrow(() => 'fine')).rejects.toThrow() + await expect(captureThrow(() => Promise.resolve('fine'))).rejects.toThrow() + }) + + test('captureConsole reads console output by severity and restores the console', async () => { + const before = console.warn + const { result, diagnostics } = await captureConsole(() => { + console.warn('[plugin] could not split `loader` in src/routes/a.tsx') + console.error(new Error('broken')) + console.info('done') + return 'result' + }) + expect(console.warn).toBe(before) + expect(result).toBe('result') + expect(diagnostics.map((diagnostic) => diagnostic.severity)).toEqual([ + 'warning', + 'error', + 'info', + ]) + expectDiagnostic(diagnostics, { + severity: 'warning', + file: 'src/routes/a.tsx', + names: ['loader'], + times: 1, + }) + }) + + test('an aggregated build error is one error; its warnings and a result’s messages are their own', () => { + const failure = Object.assign( + new Error('vite build failed:\nsrc/a.ts (2:3): Unexpected token'), + { + errors: ['src/a.ts (2:3): Unexpected token'], + warnings: ['src/b.ts: a warning'], + }, + ) + expect(diagnosticsOf(failure).map(({ severity }) => severity)).toEqual([ + 'error', + 'warning', + ]) + expect( + diagnosticsOf({ warnings: ['one', 'two'], errors: [] }).map( + ({ severity }) => severity, + ), + ).toEqual(['warning', 'warning']) + expectDiagnostic(diagnosticsOf(failure), { + severity: 'warning', + file: 'src/b.ts', + }) + }) + + test('Rollup-style locations count with 0-based columns', () => { + const [diagnostic] = diagnosticsOf( + { + message: 'Use of eval is strongly discouraged', + id: '/app/src/a.ts', + loc: { file: '/app/src/a.ts', line: 4, column: 2 }, + }, + 'warning', + ) + expectDiagnostic(diagnostic!, { + severity: 'warning', + file: 'src/a.ts', + root: '/app', + position: { line: 4, column: 3 }, + }) + }) +}) + +describe('files', () => { + const file = 'src/routes/posts.tsx' + const root = '/app' + test.each([ + '/app/src/routes/posts.tsx', + 'file:///app/src/routes/posts.tsx', + '/@fs/app/src/routes/posts.tsx', + 'src/routes/posts.tsx', + './src/routes/posts.tsx', + '../../app/src/routes/posts.tsx', + 'routes/posts.tsx', + 'posts.tsx', + 'src\\routes\\posts.tsx', + 'webpack://my-app/./src/routes/posts.tsx', + '/app/src/routes/posts.tsx?tsr-split=component', + '"/app/src/routes/posts.tsx"', + 'in src/routes/posts.tsx.', + ])('accepts %s', (spelling) => { + expect(mentionsFile(`warning: ${spelling}`, file, { root })).toBe(true) + }) + + test.each([ + '/other/src/routes/posts.tsx', + 'src/routes/posts.tsx.bak', + 'src/routes/myposts.tsx', + 'src/routes/blog/posts.tsx', + 'a sentence about posts', + ])('rejects %s', (spelling) => { + expect(mentionsFile(`warning: ${spelling}`, file, { root })).toBe(false) + }) + + test('a spelling that also names another file is ambiguous', () => { + const distinctFrom = ['src/routes/blog/posts.tsx'] + expect(mentionsFile('in posts.tsx', file, { root, distinctFrom })).toBe( + false, + ) + expect( + mentionsFile('in routes/posts.tsx', file, { root, distinctFrom }), + ).toBe(true) + expect( + mentionsFile('in blog/posts.tsx', file, { root, distinctFrom }), + ).toBe(false) + }) + + test('without a root, a relative file matches trailing segments either way', () => { + expect(mentionsFile('/tmp/x/src/a.ts:1:1', 'src/a.ts')).toBe(true) + expect(mentionsFile('a.ts', 'src/a.ts')).toBe(true) + expect(mentionsFile('/tmp/x/src/b.ts', 'src/a.ts')).toBe(false) + }) +}) + +describe('positions', () => { + const source = `import { a } from './a' +export const value = a( + createServerFn().handler(() => 1), +) +` + const position = diagnosticPosition(source, 'createServerFn') + + test('diagnosticPosition gives a 1-based line and column, counting every line terminator', () => { + expect(position).toEqual({ line: 3, column: 3 }) + expect(diagnosticPosition('a\r\nb\rc\u2028d\u2029e', 'e')).toEqual({ + line: 5, + column: 1, + }) + }) + + test.each([ + ['file:line:column', 'src/a.ts:3:3: nested server fn'], + ['0-based column', 'src/a.ts:3:2: nested server fn'], + ['file(line,column)', 'src/a.ts(3,3): nested server fn'], + ['webpack location', 'ERROR in ./src/a.ts 3:2-16'], + ['Babel location', 'src/a.ts: nested server fn (3:2)'], + ['words', 'src/a.ts, line 3, column 3: nested server fn'], + [ + 'Babel code frame', + `src/a.ts: nested server fn + 2 | export const value = a( +> 3 | createServerFn().handler(() => 1), + | ^^^^^^^^^^^^^^ + 4 | )`, + ], + [ + 'unmarked frame with a caret line', + `src/a.ts +2 | export const value = a( +3 | createServerFn().handler(() => 1), + | ^ +4 | )`, + ], + ])('accepts a %s', (_, text) => { + expectDiagnostic(text, { file: 'src/a.ts', position, source }) + }) + + test.each([ + ['another line', 'src/a.ts:2:3: nested server fn'], + ['another column', 'src/a.ts:3:9: nested server fn'], + ['a position of another file', 'src/b.ts:3:3 and src/a.ts'], + [ + 'a context line of a frame', + `src/a.ts +> 2 | export const value = a( + 3 | createServerFn().handler(() => 1),`, + ], + [ + 'a caret under another column', + `src/a.ts + 2 | export const value = a( +> 3 | createServerFn().handler(() => 1), + | ^`, + ], + ])('rejects %s', (_, text) => { + expect( + findDiagnostics(diagnosticsOf(text), { + file: 'src/a.ts', + position, + source, + }), + ).toEqual([]) + }) + + test('without a column, a frame line marked with > is enough', () => { + expectDiagnostic(`src/a.ts\n> 3 | createServerFn()`, { + file: 'src/a.ts', + position: { line: 3 }, + }) + }) +}) + +describe('names, severity and counts', () => { + test('names match whole identifiers only', () => { + expect( + findDiagnostics('`loaderDeps` of src/a.tsx', { + names: ['loader'], + }), + ).toEqual([]) + expectDiagnostic('`loader` of src/a.tsx stays in the route', { + names: ['loader'], + file: 'src/a.tsx', + }) + }) + + test('times counts the matching diagnostics exactly', () => { + const warnings = diagnosticsOf(['src/a.tsx: loader', 'src/a.tsx: loader']) + expectDiagnostic(warnings, { file: 'src/a.tsx', times: 2 }) + expect(() => + expectDiagnostic(warnings, { file: 'src/a.tsx', times: 1 }), + ).toThrow() + }) + + test('the failure lists each diagnostic with why it does not match', () => { + expect(() => + expectDiagnostic(diagnosticsOf('src/b.tsx', 'warning'), { + severity: 'error', + file: 'src/a.tsx', + }), + ).toThrow(/#0 \[warning\] severity warning, expected error; does not name/) + }) +}) + +describe('environment restrictions', () => { + const thrower = (error: unknown) => () => { + throw error + } + + test.each([ + 'createServerOnlyFn() functions can only be called on the server!', + 'createServerOnlyFn() is restricted to the server; move this call to the server environment.', + 'This function is server-only.', + ])('accepts any prose naming the server: %s', (message) => { + expect(identifiesEnvRestriction(new Error(message), 'server')).toBe(true) + expectEnvRestrictionThrow(thrower(new Error(message)), 'server') + expect({ value: new Error(message) }).toEqual({ + value: envRestrictionError('server'), + }) + }) + + test('the client may be called the browser', () => { + expectEnvRestrictionThrow( + thrower(new Error('only callable in the browser')), + 'client', + ) + }) + + test.each([ + ['the other environment', new Error('only callable on the client')], + [ + 'an engine crash', + new TypeError("Cannot read properties of undefined (reading 'server')"), + ], + ['a thrown string', 'server only'], + ])('rejects %s', (_, error) => { + expect(identifiesEnvRestriction(error, 'server')).toBe(false) + expect(() => expectEnvRestrictionThrow(thrower(error), 'server')).toThrow() + }) + + test('fails when the call returns', () => { + expect(() => expectEnvRestrictionThrow(() => 'value', 'server')).toThrow() + }) + + test('a returned promise must reject with the restriction', async () => { + await expectEnvRestrictionThrow( + () => Promise.reject(new Error('server only')), + 'server', + ) + await expect( + expectEnvRestrictionThrow(() => Promise.resolve(1), 'server'), + ).rejects.toThrow() + }) +}) diff --git a/packages/compiler-test-utils/tests/importers.test.ts b/packages/compiler-test-utils/tests/importers.test.ts new file mode 100644 index 00000000000..45bcb645c71 --- /dev/null +++ b/packages/compiler-test-utils/tests/importers.test.ts @@ -0,0 +1,98 @@ +/** + * Controls of the importer and load probes (importers.ts), linked by the + * module linker as real ES modules: an importer gets the instance the + * module uses (named, default and non-identifier exports, live bindings), + * and load markers record exactly the modules that evaluated. + */ +import { afterEach, describe, expect, test } from 'vitest' +import { + expectNotLoaded, + importBindings, + importerSource, + loadMarker, + loadedModules, + loadedModulesExpression, + resetLoadedModules, +} from '../src/importers' +import { createModuleGraph } from '../src/module-linker' + +afterEach(() => { + resetLoadedModules() +}) + +describe('importBindings', () => { + test('a second module gets the instance the module itself uses, live', async () => { + const graph = createModuleGraph({ + modules: { + '/route.tsx': `export const store = { count: 0 } +export let version = 1 +export function bump() { version++ } +const Page = () => store +export default Page +const odd = 'odd' +export { odd as 'not-an-identifier' } +export const Route = { component: Page, store } +`, + }, + }) + const route = await graph.import('/route.tsx') + const bindings = await importBindings(graph, './route', [ + 'store', + 'default', + 'version', + 'bump', + 'not-an-identifier', + ]) + expect(bindings.store).toBe(route.Route.store) + expect(bindings.default).toBe(route.Route.component) + expect(bindings['not-an-identifier']).toBe('odd') + bindings.bump() + expect(bindings.version).toBe(2) + }) + + test('an import of a missing export fails to link, as in a bundle', async () => { + const graph = createModuleGraph({ + modules: { '/route.tsx': `export const Route = {}` }, + }) + await expect( + importBindings(graph, './route', ['missing']), + ).rejects.toThrow() + }) + + test('importerSource is a plain module any real app can contain', () => { + expect(importerSource('./route', ['Route'])).toContain(`from "./route"`) + }) +}) + +describe('load markers', () => { + test('record exactly the modules that evaluated, in order', async () => { + const graph = createModuleGraph({ + modules: { + '/entry.ts': `import './eager' +export const later = () => import('./lazy') +`, + '/eager.ts': `${loadMarker('eager')}export {}`, + '/lazy.ts': `${loadMarker('lazy')}export {}`, + }, + stubs: { + './db.server': `${loadMarker('db.server')}export const db = 1`, + }, + }) + const entry = await graph.import('/entry.ts') + expect(loadedModules()).toEqual(['eager']) + expectNotLoaded(loadedModules(), ['lazy', 'db.server']) + expectNotLoaded(graph.evaluated, ['/lazy.ts']) + await entry.later() + expect(loadedModules()).toEqual(['eager', 'lazy']) + expect(() => expectNotLoaded(loadedModules(), ['lazy'])).toThrow() + }) + + test('loadedModulesExpression reads the records in any realm', () => { + const read = new Function(`return ${loadedModulesExpression}`) as () => [ + string, + ] + expect(read()).toEqual([]) + new Function(loadMarker('x'))() + expect(read()).toEqual(['x']) + }) +}) diff --git a/packages/compiler-test-utils/tests/integration/oracles.test.ts b/packages/compiler-test-utils/tests/integration/oracles.test.ts new file mode 100644 index 00000000000..83d6e1bd3fb --- /dev/null +++ b/packages/compiler-test-utils/tests/integration/oracles.test.ts @@ -0,0 +1,105 @@ +/** + * The diagnostic and source-map oracles on real bundler output: every + * bundler's build error for a broken module names the file and its line + * (whatever the bundler's format), and a throw executed in a built, + * source-mapped chunk resolves through its stack to the line of the throw. + */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, onTestFinished, test } from 'vitest' +import { bundleApp as bundleAppIn, bundlers } from '../../src/build-harness' +import { + captureThrow, + expectDiagnostic, + mentionsFile, +} from '../../src/diagnostics' +import { traceStack } from '../../src/source-maps' +import type { BundleOptions } from '../../src/build-harness' + +const timeout = 60_000 + +const testsDir = path.dirname(fileURLToPath(import.meta.url)) + +function bundleApp(options: Omit) { + return bundleAppIn({ + dir: testsDir, + registerCleanup: onTestFinished, + ...options, + }) +} + +// One unexpected token, so every parser reports the same position: line 2, +// column 26 (1-based), the `*`. +const broken = `export const ok = 1 +export const value = 1 + * 2 +export const after = 3 +` + +const failing = `interface Failure { + message: string +} +type Reason = Failure['message'] +export function fail(reason: Reason = 'MARK_FAIL'): string { + throw new Error(reason) +} +` + +describe.each(bundlers)('%s', (bundler) => { + test( + 'a build error names the broken file and its line', + async () => { + const diagnostics = await captureThrow(() => + bundleApp({ + bundler, + files: { + 'entry.ts': `export { value } from './src/broken'\n`, + 'src/broken.ts': broken, + }, + }), + ) + expectDiagnostic(diagnostics, { + severity: 'error', + file: 'src/broken.ts', + position: { line: 2, column: 26 }, + source: broken, + }) + }, + timeout, + ) + + test( + 'a throw in a built chunk resolves through its stack to the line of the throw', + async () => { + const app = await bundleApp({ + bundler, + files: { + 'entry.ts': `export async function load() { + const { fail } = await import('./src/failing') + return fail() +} +`, + 'src/failing.ts': failing, + }, + sourcemap: true, + }) + const { result: stack } = await app.run( + `try { await entry.load() } catch (error) { return error.stack }`, + ) + expect(stack).toContain('MARK_FAIL') + const frames = traceStack(stack as string, (location) => { + const file = location.startsWith('file:') + ? fileURLToPath(location) + : location + return app.chunks.find( + (chunk) => path.join(app.outDir, chunk.fileName) === file, + )?.map as never + }) + const resolved = frames.find((frame) => frame.original) + expect(resolved?.original?.line).toBe(6) + expect(mentionsFile(resolved!.original!.source, 'src/failing.ts')).toBe( + true, + ) + }, + timeout, + ) +}) diff --git a/packages/compiler-test-utils/tests/source-maps.test.ts b/packages/compiler-test-utils/tests/source-maps.test.ts new file mode 100644 index 00000000000..a0df2637f99 --- /dev/null +++ b/packages/compiler-test-utils/tests/source-maps.test.ts @@ -0,0 +1,160 @@ +/** + * Controls of the source-map helpers (source-maps.ts) on maps real + * transforms emit (Oxc through Vite, esbuild): positions resolve through + * `@jridgewell/trace-mapping`, executed throws resolve through their + * stack, tokens are found by parsing whatever the printing, and CRLF + * sources count lines like editors. + */ +import vm from 'node:vm' +import * as esbuild from 'esbuild' +import { transformWithOxc } from 'vite' +import { describe, expect, test } from 'vitest' +import { + expectMapsTo, + findNodes, + inlineSourceMap, + offsetAt, + originalPositionOf, + originalPositions, + positionAt, + tokenOffsets, + traceStack, +} from '../src/source-maps' + +// TypeScript-only lines before the throw shift it: a map that skips the +// type erasure points at the wrong line. +const source = `interface Failure { + message: string +} +type Reason = Failure['message'] +export function fail(reason: Reason = 'MARK_FAIL'): string { + throw new Error(reason) +} +` +const throwLine = 6 + +describe('positions', () => { + test('positionAt and offsetAt count every JavaScript line terminator', () => { + const code = 'a\r\nb\rc\u2028d\u2029e\nf' + expect(positionAt(code, code.indexOf('e'))).toEqual({ line: 5, column: 0 }) + expect(positionAt(code, code.indexOf('f'))).toEqual({ line: 6, column: 0 }) + for (const char of 'abcdef') { + const offset = code.indexOf(char) + expect(offsetAt(code, positionAt(code, offset))).toBe(offset) + } + }) + + test('tokens are found by parsing, whatever the quotes or spacing', () => { + const code = `const a = "box"; const b = 'box'; const c = \`box\`; box(); throw a` + expect(tokenOffsets(code, { string: 'box' })).toHaveLength(3) + expect(tokenOffsets(code, { identifier: 'box' })).toEqual([ + code.indexOf('box()'), + ]) + expect(tokenOffsets(code, { throw: true })).toEqual([code.indexOf('throw')]) + expect( + findNodes(code, (node) => node.type === 'CallExpression'), + ).toHaveLength(1) + }) +}) + +describe.each([ + { + name: 'Oxc (Vite)', + transform: async (code: string, file: string) => { + const result = await transformWithOxc(code, file, { + lang: 'ts', + sourcemap: true, + }) + return { code: result.code, map: result.map! } + }, + }, + { + name: 'esbuild', + transform: async (code: string, file: string) => { + const result = await esbuild.transform(code, { + loader: 'ts', + format: 'cjs', + sourcefile: file, + sourcemap: 'external', + }) + return { code: result.code, map: JSON.parse(result.map) } + }, + }, +])('$name maps', ({ transform }) => { + test('a generated token resolves to the original line and column', async () => { + const { code, map } = await transform(source, '/app/src/fail.ts') + const generated = positionAt(code, tokenOffsets(code, { throw: true })[0]!) + const original = expectMapsTo(map, generated, { + file: 'src/fail.ts', + root: '/app', + line: throwLine, + column: positionAt(source, tokenOffsets(source, { throw: true })[0]!) + .column, + }) + expect(original.source).toContain('fail.ts') + expect( + originalPositions(map).some((position) => position.line === throwLine), + ).toBe(true) + }) + + test('an executed throw resolves through its stack', async () => { + const { code, map } = await transform( + `${source}fail()\n`, + '/app/src/fail.ts', + ) + const location = '/generated/fail.js' + let stack = '' + try { + vm.runInThisContext( + `(function (exports, module) {${code.replace(/^export /gm, '')}\n})({}, {})`, + { filename: location }, + ) + } catch (error) { + stack = (error as Error).stack! + } + expect(stack).toContain('MARK_FAIL') + const frames = traceStack(stack, (file) => + file === location ? map : undefined, + ) + expect(frames[0]).toMatchObject({ + location, + original: { line: throwLine }, + }) + expect(frames[0]!.original!.source).toContain('fail.ts') + }) + + test('a wrong line or file fails', async () => { + const { code, map } = await transform(source, '/app/src/fail.ts') + const generated = positionAt(code, tokenOffsets(code, { throw: true })[0]!) + expect(() => + expectMapsTo(map, generated, { file: 'src/fail.ts', line: 1 }), + ).toThrow() + expect(() => + expectMapsTo(map, generated, { file: 'src/other.ts', line: throwLine }), + ).toThrow() + }) +}) + +test('a CRLF source maps to the lines an editor shows', async () => { + const crlf = source.replaceAll('\n', '\r\n') + const { code, map } = await transformWithOxc(crlf, '/app/src/fail.ts', { + lang: 'ts', + sourcemap: true, + }) + const generated = positionAt(code, tokenOffsets(code, { throw: true })[0]!) + expectMapsTo(map!, generated, { file: 'src/fail.ts', line: throwLine }) +}) + +test('inlineSourceMap reads a data URL map, and nothing else', async () => { + const { code, map } = await transformWithOxc(source, '/app/src/fail.ts', { + lang: 'ts', + sourcemap: true, + }) + const inline = `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString('base64')}\n` + expect(inlineSourceMap(inline)).toEqual(JSON.parse(JSON.stringify(map))) + expect(inlineSourceMap(code)).toBeUndefined() + const generated = positionAt(code, tokenOffsets(code, { throw: true })[0]!) + expect(originalPositionOf(inlineSourceMap(inline)!, generated)?.line).toBe( + throwLine, + ) +}) From 13591a12df450b6ebbbc84d36de6b57864fb5755 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 20:59:45 +0200 Subject: [PATCH 0431/1425] test(start-plugin-core): environment-restriction throws through the shared oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serverOnlyError/clientOnlyError are now asymmetric matchers: the call threw (settle returns Thrown with the real error) a deliberate error naming the owning environment, whatever its prose (SPEC J4.10, §5.7). Consumers compare with toEqual instead of toBe; the nested-handler pin fixture and the client-only-imports build return the error's name and message instead of a "throws: " string. source-maps.ts reads maps through the shared trace-mapping consumer instead of a hand VLQ decoder (HYD-31). V4 green-sol/10 (reworded error): 48 failures -> 0. A message naming no environment still fails the same 48 tests. Co-Authored-By: Claude Opus 5.5 --- .../start-plugin-core/tests/compiler.test.ts | 14 ++--- .../start-plugin-core/tests/diagnostics.ts | 24 ++++++-- .../start-build-client-only-imports.test.ts | 5 +- .../tests/known-bugs-start-compiler.test.ts | 6 +- .../tests/regression-helpers.ts | 17 ++++-- .../start-plugin-core/tests/source-maps.ts | 60 +++++-------------- .../tests/start-compiler-factories.test.ts | 28 +++++---- .../tests/start-compiler-import-forms.test.ts | 8 +-- 8 files changed, 81 insertions(+), 81 deletions(-) diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts index 54961b4d1d0..503580747a7 100644 --- a/packages/start-plugin-core/tests/compiler.test.ts +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -69,7 +69,7 @@ export const fn = createIsomorphicFn() export const fn = createServerOnlyFn(() => 'server only value')`, '/test/src/file4.ts', ) - expect(settle(file4.fn)).toBe(serverOnlyError) + expect(settle(file4.fn)).toEqual(serverOnlyError) // File 5: Mix of ServerFn and IsomorphicFn const file5 = await evaluate( @@ -666,7 +666,7 @@ export const myFn = createServerOnlyFn(() => 'server-only-value')`) const { myFn } = await evaluateModule(result!, { '@tanstack/start-client-core': { createServerOnlyFn: uncompiled }, }) - expect(settle(myFn)).toBe(serverOnlyError) + expect(settle(myFn)).toEqual(serverOnlyError) }) test('aliased factory is still detected as a candidate', async () => { @@ -683,7 +683,7 @@ export const myFn = createSO(() => 'aliased-server-only')`, expect(result).not.toBeNull() expect(result).not.toContain('aliased-server-only') const { myFn } = await evaluateModule(result!) - expect(settle(myFn)).toBe(serverOnlyError) + expect(settle(myFn)).toEqual(serverOnlyError) }) }) @@ -780,7 +780,7 @@ describe('re-export chain resolution', () => { fn === 'createIsomorphicFn' ? module.getThemeData : module.initClientFeature - expect(settle(exported)).toBe(value) + expect(settle(exported)).toEqual(value) }, ) @@ -895,7 +895,7 @@ export const myFn = createServerOnlyFn(() => 'server-only-value')` `export { createClientOnlyFn as createServerOnlyFn } from '@tanstack/start-fn-stubs'` compiler.invalidateModule('/test/src/factory-inner.ts') - expect(await run()).toBe(clientOnlyError) + expect(await run()).toEqual(clientOnlyError) }) // The source module only mentions createServerFn: the factory module @@ -938,7 +938,7 @@ export const serverFn = createServerFn().handler(async () => { `export { createClientOnlyFn as createServerFnHmrFactory } from '@tanstack/start-fn-stubs'` compiler.invalidateModule('/test/src/factory.ts') - expect(await callServerFn()).toBe(clientOnlyError) + expect(await callServerFn()).toEqual(clientOnlyError) }) // The same with an external transform configured (e.g. the RSC CSS one). @@ -1105,7 +1105,7 @@ test.each([ expect(result).not.toBeNull() expect(result).not.toContain('private-server-body') const { fn } = await evaluateModule(result!) - expect(typeof fn === 'function' ? settle(fn) : undefined).toBe(value) + expect(typeof fn === 'function' ? settle(fn) : undefined).toEqual(value) }, ) diff --git a/packages/start-plugin-core/tests/diagnostics.ts b/packages/start-plugin-core/tests/diagnostics.ts index 149a3daa1a9..eae1ecc19ee 100644 --- a/packages/start-plugin-core/tests/diagnostics.ts +++ b/packages/start-plugin-core/tests/diagnostics.ts @@ -6,8 +6,11 @@ import { stripVTControlCharacters } from 'node:util' import { expect } from 'vitest' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' +import { identifiesEnvRestriction } from '@tanstack/compiler-test-utils/diagnostics' +import type { Environment } from '@tanstack/compiler-test-utils/diagnostics' import type { ServerFn } from '../src/start-compiler/types' import { + Thrown, callReportedServerFns, compileFor, outputs, @@ -125,7 +128,20 @@ export async function expectNoClientHandler(code: string) { await expectOutputsFreeOfHandler(code, results) } -export const serverOnlyError = - 'throws: createServerOnlyFn() functions can only be called on the server!' -export const clientOnlyError = - 'throws: createClientOnlyFn() functions can only be called on the client!' +/** + * What `settle` gives for a `createServerOnlyFn` called outside the server + * (J4.10): it threw an error restricting the call to the server, whatever + * its prose (`identifiesEnvRestriction`). An asymmetric matcher: compare + * with `toEqual`. + */ +export const serverOnlyError = thrownEnvRestriction('server') +/** `serverOnlyError` for a `createClientOnlyFn` called outside the client. */ +export const clientOnlyError = thrownEnvRestriction('client') + +function thrownEnvRestriction(env: Environment) { + return expect.toSatisfy( + (value: unknown) => + value instanceof Thrown && identifiesEnvRestriction(value.error, env), + `a call that throws an error restricting it to the ${env}`, + ) +} diff --git a/packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts b/packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts index c70bc53c8a4..ba5a21e74d1 100644 --- a/packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-client-only-imports.test.ts @@ -7,6 +7,7 @@ * Source: user ruling 2026-10-18 (E1 ASK-1). */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { envRestrictionError } from '@tanstack/compiler-test-utils/diagnostics' import { buildStart, startBundlers } from '../build-harness' import { reportedViolations } from '../importProtection/start-apps' import type { StartBuild } from '../build-harness' @@ -33,7 +34,7 @@ const run = `const settle = (fn) => { try { return fn() } catch (error) { - return 'throws: ' + error.message + return { thrown: { name: error.name, message: error.message } } } } return [settle(entry.render), settle(entry.repaint)]` @@ -78,7 +79,7 @@ describe.each(startBundlers)('%s build', (bundler) => { 'MARK_PAINT', ]) expect((await build.ssr.run(run)).result).toEqual([ - expect.stringMatching(/^throws: .*client/), + { thrown: envRestrictionError('client') }, 'server', ]) }) 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 08b4e0f3e24..4200ed91e45 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 @@ -87,7 +87,7 @@ async function expectServerOnlyRead(code: string) { const { read } = await evaluateModule(client, { [serverOnly]: { db: { secret: () => 'secret' } }, }) - expect(settle(() => read())).toBe(serverOnlyError) + expect(settle(() => read())).toEqual(serverOnlyError) } describe('harness controls', () => { @@ -446,7 +446,7 @@ import { db } from './db.server' export const read = serverOnly(() => db.x())`, // Uncompiled, the factory returns the implementation as is. stubs: { './env': { serverOnly: (impl: unknown) => impl } }, - check: (module) => expect(settle(module.read)).toBe(serverOnlyError), + check: (module) => expect(settle(module.read)).toEqual(serverOnlyError), }, ])( 'client builds compile $name finished or called in another module', @@ -1204,7 +1204,7 @@ function get(): string function get(id?: string) { return db.users(id) } export const read = createServerOnlyFn(() => get())`, check: (module: Record) => - expect(settle(() => module.read())).toBe(serverOnlyError), + expect(settle(() => module.read())).toEqual(serverOnlyError), }, ])( 'client: $name does not load its server-only import', diff --git a/packages/start-plugin-core/tests/regression-helpers.ts b/packages/start-plugin-core/tests/regression-helpers.ts index adbc969bc5b..eca8e7b7def 100644 --- a/packages/start-plugin-core/tests/regression-helpers.ts +++ b/packages/start-plugin-core/tests/regression-helpers.ts @@ -683,23 +683,32 @@ export function directivePrologue(code: string) { return directives } -/** The value of `run`, or the message of the error it throws. */ +/** + * What `settle` gives for a call that throws: the thrown value itself, so + * oracles (`serverOnlyError`, `clientOnlyError`) read the real error, and a + * value the call returns is never mistaken for a throw. + */ +export class Thrown { + constructor(readonly error: unknown) {} +} + +/** The value of `run`, or `Thrown` with the error it throws. */ export function settle(run: () => unknown) { try { return run() } catch (error) { - return `throws: ${(error as Error).message}` + return new Thrown(error) } } /** - * The value `run` resolves to, or the message of the error it throws or + * The value `run` resolves to, or `Thrown` with the error it throws or * rejects with. */ export async function settleAsync(run: () => unknown) { try { return await run() } catch (error) { - return `throws: ${(error as Error).message}` + return new Thrown(error) } } diff --git a/packages/start-plugin-core/tests/source-maps.ts b/packages/start-plugin-core/tests/source-maps.ts index 9a40c3feafc..45d39ec62d0 100644 --- a/packages/start-plugin-core/tests/source-maps.ts +++ b/packages/start-plugin-core/tests/source-maps.ts @@ -1,41 +1,15 @@ /** - * Reads the source map of a compiled module: the original position of a - * generated one. Re-exported by `regression-helpers.ts`. + * Reads the source map of a compiled module through the shared consumer + * (`@tanstack/compiler-test-utils/source-maps`, `@jridgewell/trace-mapping`). + * Re-exported by `regression-helpers.ts`. */ +import { + offsetAt, + originalPositions, +} from '@tanstack/compiler-test-utils/source-maps' +import type { SourceMapInput } from '@tanstack/compiler-test-utils/source-maps' import type { StartCompilerTransformResult } from '../src/types' -const base64 = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' - -/** The original (0-based) line and column of each source map segment. */ -function originalPositions(mappings: string) { - const positions: Array<[line: number, column: number]> = [] - let line = 0 - let column = 0 - for (const segment of mappings.split(/[;,]/)) { - const values: Array = [] - let value = 0 - let shift = 0 - for (const char of segment) { - const digit = base64.indexOf(char) - value += (digit & 31) << shift - if (digit & 32) { - shift += 5 - } else { - values.push(value & 1 ? -(value >>> 1) : value >>> 1) - value = 0 - shift = 0 - } - } - if (values.length >= 4) { - line += values[2]! - column += values[3]! - positions.push([line, column]) - } - } - return positions -} - /** * Where in `source` the children a chunk renders come from: the start of the * outermost `` element of `source` that contains the code the @@ -46,19 +20,17 @@ export function chunkBoundaryStart( source: string, boundaries: Array<{ start: number; end: number }>, ) { - const mappings = chunk.map?.mappings - if (typeof mappings !== 'string') { + if (typeof chunk.map?.mappings !== 'string') { return Infinity } - const lineStarts = [0] - for (let index = 0; index < source.length; index++) { - if (source[index] === '\n') { - lineStarts.push(index + 1) - } - } let start = Infinity - for (const [line, column] of originalPositions(mappings)) { - const offset = (lineStarts[line] ?? Infinity) + column + for (const position of originalPositions(chunk.map as SourceMapInput)) { + let offset: number + try { + offset = offsetAt(source, position) + } catch { + continue + } const boundary = boundaries.find( (range) => range.start <= offset && offset < range.end, ) 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 78c4d238e9c..17cecd9d51e 100644 --- a/packages/start-plugin-core/tests/start-compiler-factories.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-factories.test.ts @@ -18,6 +18,7 @@ import { startClientCore, } from './regression-helpers' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' +import { envRestrictionError } from '@tanstack/compiler-test-utils/diagnostics' import type { ModuleStub, Output } from './regression-helpers' // Each output keeps the implementation of its own environment (and only the @@ -103,10 +104,10 @@ export default createIsomorphicFn().server(() => db.x()).client(() => chart())`, } expect( settle((await evaluateModule(compiled.client, stubs)).default), - ).toBe(client) - expect(settle((await evaluateModule(compiled.ssr, stubs)).default)).toBe( - server, - ) + ).toEqual(client) + expect( + settle((await evaluateModule(compiled.ssr, stubs)).default), + ).toEqual(server) }, ) @@ -481,10 +482,10 @@ export const fn = ${chain}` expect(importSources(compiled.client)).not.toContain('./db.server') expect(compiled.client).not.toContain('SERVER_SECRET') expect(importSources(compiled.ssr)).not.toContain('./chart.client') - expect(settle((await evaluateModule(compiled.client, stubs)).fn)).toBe( + expect(settle((await evaluateModule(compiled.client, stubs)).fn)).toEqual( client, ) - expect(settle((await evaluateModule(compiled.ssr, stubs)).fn)).toBe( + expect(settle((await evaluateModule(compiled.ssr, stubs)).fn)).toEqual( server, ) }, @@ -507,7 +508,7 @@ export class Api extends Base { } }`) const client = await evaluateModule(compiled.client) - expect(settle(new client.Api().read(1))).toBe(serverOnlyError) + expect(settle(new client.Api().read(1))).toEqual(serverOnlyError) const ssr = await evaluateModule(compiled.ssr) expect(new ssr.Api().read(1)()).toEqual(['s', 'base', 1]) }) @@ -536,7 +537,7 @@ export class Api extends Base { ) expect(importSources(compiled.ssr)).not.toContain('./chart.client') const ssr = await evaluateModule(compiled.ssr, stubs) - expect(settle(() => ssr.makeDraw()())).toBe(clientOnlyError) + expect(settle(() => ssr.makeDraw()())).toEqual(clientOnlyError) }, ) @@ -546,7 +547,7 @@ 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( + expect(settle((await evaluateModule(compiled.client, stubs)).read)).toEqual( serverOnlyError, ) expect((await evaluateModule(compiled.ssr, stubs)).read()).toBe('secret') @@ -634,7 +635,8 @@ function settle(run: () => unknown) { try { return run() } catch (error) { - return (error as Error).message + const { name, message } = error as Error + return { thrown: { name, message } } } }` @@ -661,7 +663,7 @@ function settle(run: () => unknown) { './db.server': `export const db = { now: () => 'now', read: () => 'read' }`, './chart.client': `export const chart = () => 'chart'`, }), - ).toEqual([clientOnlyError.slice('throws: '.length), 'now', 'read']) + ).toEqual([{ thrown: envRestrictionError('client') }, 'now', 'read']) }, ) @@ -772,7 +774,7 @@ export const value = createServerOnlyFn(() => 'mine')`, } expect( settle((await evaluateModule(compiled ?? `${head}${code}`)).none), - ).toBe(elsewhere) + ).toEqual(elsewhere) }, ) }) @@ -1004,7 +1006,7 @@ export const serverOnly = createServerOnlyFn(() => 'server-only-marker')` // Where the project's own `createServerOnlyFn` wins, it is a plain // function. const client = await evaluateModule(compiled.client, linked, { files }) - expect(settle(client.serverOnly)).toBe( + expect(settle(client.serverOnly)).toEqual( resolvesServerOnlyFn ? serverOnlyError : 'server-only-marker', ) }, 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 30fec5a9337..00766b64741 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 @@ -145,7 +145,7 @@ const constructs: Array = [ expect(importSources(client)).not.toContain('./db.server') expect( settle((await evaluateModule(client, stubs, { framework })).fn), - ).toBe(serverOnlyError) + ).toEqual(serverOnlyError) expect((await evaluateModule(ssr, stubs, { framework })).fn()).toBe('x') }, }, @@ -158,9 +158,9 @@ const constructs: Array = [ expect((await evaluateModule(client, stubs, { framework })).fn()).toBe( 'chart', ) - expect(settle((await evaluateModule(ssr, stubs, { framework })).fn)).toBe( - clientOnlyError, - ) + expect( + settle((await evaluateModule(ssr, stubs, { framework })).fn), + ).toEqual(clientOnlyError) }, }, ] From c8b84f431a0cad9b33131a2ade83ac369c853c4d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:13:46 +0200 Subject: [PATCH 0432/1425] test(compiler-test-utils): client runs in Chromium, project JSX settings and real target globals in the validators A8 / GEN-36: client build output runs in a page of headless Chromium (browser.ts openPage) served over HTTP from the output directory, instead of jsdom globals copied onto Node. SSR output stays in a Node child process. The guarded setImmediate probe now returns 'none'. A8 / GEN-38: the dangling-name check reads the emitted chunks, never a separately minified copy. Unminified webpack/Rspack builds are not checked: their own output keeps dead code with dangling names for the minifier (Rspack's unminified build of @tanstack/react-router alone reads useRouter, jsx... without a binding). The control pairs the minified build (dead code gone, passes) with the unminified one (kept, unchecked). Removed control "a dangling name in code the minifier keeps fails" for unminified builds: covered by "a chunk that reads a dangling name fails the build description" (minified, every bundler). A8 / GEN-37: every real browser/Node global is allowed; a lost local is found from binding evidence (getModuleErrors `source` option), adopted by rp plugin-host expectValidModules and spc render.ts in place of the source-derived `globals`. A7 / GEN-27: getModuleErrors/freeIdentifiers take the project's JSX settings (`jsx`, default React automatic) and pass them to Oxc, which interprets the pragmas and their position itself; no comment scanning. Controls: the same non-leading pragma under a classic and an automatic project. Co-Authored-By: Claude Opus 5.5 --- .../compiler-test-utils/src/build-harness.ts | 307 +++++++++++------- .../compiler-test-utils/src/known-globals.ts | 140 ++------ .../src/validate-module.ts | 190 ++++++++--- .../tests/integration/build-harness.test.ts | 173 ++++++---- .../tests/validate-module.test.ts | 133 +++++++- packages/router-plugin/tests/plugin-host.ts | 15 +- .../tests/known-bugs-hydrate.test.ts | 5 +- packages/start-plugin-core/tests/render.ts | 15 +- 8 files changed, 599 insertions(+), 379 deletions(-) diff --git a/packages/compiler-test-utils/src/build-harness.ts b/packages/compiler-test-utils/src/build-harness.ts index e32d110a650..e4363dfc461 100644 --- a/packages/compiler-test-utils/src/build-harness.ts +++ b/packages/compiler-test-utils/src/build-harness.ts @@ -3,10 +3,11 @@ * chunk code ends up in, what tree-shaking drops, what side-effect hints * change. Writes a small app to disk, builds it with one of the bundlers the * router plugin supports, describes the emitted chunks (entry, eager, lazy) - * and runs the output in a separate Node process (with a jsdom document for - * client builds). Describing a production client build of Vite, Rspack or - * webpack rejects chunks that read a name that is neither declared, - * imported nor a global (`DanglingNameError`). + * and runs the output where it runs in an app: SSR output in a separate + * Node process, client output in a page of a real browser (headless + * Chromium). Describing a production client build of Vite, Rspack or + * webpack rejects emitted chunks that read a name that is neither declared, + * imported nor a browser global (`DanglingNameError`). * * Plugin-agnostic: the caller supplies the plugin of each build, and the * directory its apps live in (where their imports resolve). @@ -19,11 +20,14 @@ import { symlink, writeFile, } from 'node:fs/promises' -import { SourceMap as NodeSourceMap, createRequire } from 'node:module' +import { createServer } from 'node:http' +import { SourceMap as NodeSourceMap } from 'node:module' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { format } from 'node:util' import { runNodeProgram } from './run-node' +import type { AddressInfo } from 'node:net' +import type { BrowserPage } from './browser' export type Bundler = 'vite' | 'rspack' | 'webpack' | 'esbuild' @@ -124,9 +128,9 @@ export interface BundleTarget { /** * Defaults to `mode === 'production'`, like a real app build, except for * Vite SSR builds, which Vite does not minify by default. webpack and - * Rspack only drop unused code inside a kept module when minifying (the - * dangling-name check of an unminified production client build checks - * its chunks as the bundler's minifier emits them). + * Rspack only drop unused code inside a kept module when minifying, so + * the dangling-name check of their production client builds reads only + * minified chunks (see `checkDanglingNames`). */ minify?: boolean /** Output directory relative to the app root (default `dist`). */ @@ -201,10 +205,11 @@ export interface BuildResult { } where: (marker: string | RegExp) => Placement /** - * Imports the entry in a separate Node process and returns the JSON - * value `script` returns. `script` is an async function body with the - * entry's exports in scope as `entry` (and a jsdom `document`/`window` as - * globals for client builds). + * Loads the entry and returns the JSON value `script` returns. `script` is + * an async function body with the entry's exports in scope as `entry`. + * SSR builds run in a separate Node process; client builds in a page of + * headless Chromium that serves the output directory over HTTP (the page + * is `/`, with a `
    `). */ run: (script?: string) => Promise /** Removes the app directory. */ @@ -259,7 +264,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 defaultPackageJson = { name: 'app', private: true, type: 'module' } @@ -867,8 +871,11 @@ export interface DescribeBuildInput { /** Whether the build minified its output (default: `mode === 'production'`). */ minify?: boolean /** - * Whether the chunks are ES modules (the bundler's `output.module`), even - * a chunk that neither imports nor exports. Default: the chunks that do. + * Whether the chunks are ES modules (the bundler's `output.module`). + * Default `true`, as Vite and esbuild always emit and the harness + * configures webpack and Rspack. A client run loads an ES module entry + * with `import()`, and a classic script entry with the chunks of its group + * with ` - - -` - -interface Case { - name: string - files: Record - config?: Partial - /** Route id to its full path and its parent's id (null for the root). */ - expected: Record - /** URL to the id of the deepest route the router matches for it. */ - visits: Record - /** Vue only: URL to the texts its page renders, in order. */ - pages?: Record> -} +import { rootRoute } from './app' +import { checkApp, page, routes } from './real-router' +import type { Case } from './real-router' +// (the frameworks' pages: real-router-frameworks.test.ts) const cases: Array = [ { name: 'flat, directory, layout, pathless, group, lazy and param routes', @@ -177,130 +143,56 @@ const cases: Array = [ '/legacy': '/legacy/', }, }, - { - name: 'a Vue app with route files and component pieces', - config: { target: 'vue' }, +] + +describe('the real router on a generated route tree', () => { + test.each(cases)( + '$name', + (testCase) => checkApp(testCase, { types: testCase.types ?? true }), + 60_000, + ) + + // A layout route file in a route group directory: `(group)` folders are + // left out of the URL (file-naming-conventions.md:15) and a `route.tsx` + // file is the route file at its directory's path (:18), so + // `(marketing)/route.tsx` is a layout adding no path segment, like a + // pathless layout (maintained since #4629, fixture + // tests/generator/path-above-route-in-group). + const groupLayout: Case = { + name: 'a route group layout', files: { - 'src/routes/__root.ts': rootRoute('vue'), - 'src/routes/__root.component.vue': sfc('root'), - ...routes( - [ - 'index.ts', - 'posts.ts', - 'posts.$postId.ts', - '_auth.ts', - '_auth/login.ts', - ], - 'vue', + 'src/routes/__root.tsx': page('react', 'createRootRoute', 'root', true), + 'src/routes/(marketing)/route.tsx': page( + 'react', + 'createFileRoute', + 'marketing', + true, + ), + 'src/routes/(marketing)/pricing.tsx': page( + 'react', + 'createFileRoute', + 'pricing', ), - 'src/routes/index.component.vue': sfc('home'), - 'src/routes/posts.component.vue': sfc('posts'), - 'src/routes/posts.$postId.component.vue': sfc('post'), - 'src/routes/_auth.component.vue': sfc('auth'), - 'src/routes/_auth/login.component.vue': sfc('login'), - // A route with a component piece and no route file - 'src/routes/about.component.vue': sfc('about'), }, expected: { __root__: ['/', null], - '/': ['/', '__root__'], - '/about': ['/about', '__root__'], - '/posts': ['/posts', '__root__'], - '/posts/$postId': ['/posts/$postId', '/posts'], - '/_auth': ['/', '__root__'], - '/_auth/login': ['/login', '/_auth'], - }, - visits: { - '/': '/', - '/about': '/about', - '/posts': '/posts', - '/posts/1': '/posts/$postId', - '/login': '/_auth/login', - }, - pages: { - '/': ['root', 'home'], - '/about': ['root', 'about'], - '/posts/1': ['root', 'posts', 'post'], - '/login': ['root', 'auth', 'login'], + '/(marketing)': ['/', '__root__'], + '/(marketing)/pricing': ['/pricing', '/(marketing)'], }, - }, -] - -/** Types for Vue single-file component imports, as in a Vue app. */ -const vueShim = `declare module '*.vue' { - const component: unknown - export default component -} -` + visits: { '/pricing': '/(marketing)/pricing' }, + pages: { '/pricing': ['root', 'marketing', 'pricing'] }, + } -/** The texts of the `

    ` elements of server-rendered `html`, in order. */ -const paragraphs = (html: string) => - [...html.matchAll(/

    ([^<]*)<\/p>/g)].map((match) => match[1]) - -/** - * Generates the app of `testCase` and checks the router over its route tree - * and the tree's types against the expected routes. - */ -async function checkApp({ files, config, expected, visits, pages }: Case) { - const root = await createApp({ ...files, 'src/vue-shim.d.ts': vueShim }) - const { generatedRouteTree } = await generate(root, config) - - await withRouteTree(generatedRouteTree, async (tree) => { - const { router } = tree - expect( - Object.fromEntries( - Object.values(router.routesById).map((route) => [ - route.id, - [route.fullPath, route.parentRoute?.id ?? null], - ]), - ), - ).toEqual(expected) - expect( - Object.fromEntries( - Object.keys(visits).map((url) => [ - url, - router.matchRoutes(url).at(-1)?.routeId, - ]), - ), - ).toEqual(visits) - for (const [url, texts] of Object.entries(pages ?? {})) { - expect(paragraphs(await tree.renderAt(url))).toEqual(texts) - } - }) - - // The tree's types: the same route ids, and for each route the same full - // path and parent as the router's. - const ids = Object.keys(expected) - const checks = ids.map((id, i) => { - const [fullPath, parent] = expected[id]! - const route = `FileRoutesById[${JSON.stringify(id)}]` - const actual = `[${JSON.stringify(id)}, ${route}['fullPath'], ${parent === null ? 'null' : `${route}['parentRoute']['id']`}]` - const wanted = JSON.stringify([id, fullPath, parent]) - // (the assignment names the types of a route that differ) - return `export type Check${i} = Expect> -export const check${i}: ${wanted} = null as unknown as ${actual}` - }) - expect( - await typeErrorsOf( - root, - 'src/checks.ts', - `/// -import type { FileRoutesById } from './routeTree.gen' -${typeAssertions} -export type Ids = Expect JSON.stringify(id)).join(' | ')}>> -${checks.join('\n')} -`, - ), - ).toEqual([]) -} - -describe('the real router on a generated route tree', () => { - test.each(cases)('$name', checkApp, 60_000) + test( + 'the layout route of a route group directory adds no path segment and renders around its children', + () => checkApp(groupLayout, { types: false }), + 60_000, + ) // Bug: the route tree types the layout route of a route group directory // (`(marketing)/route.tsx`) with `fullPath: ''` in `FileRoutesByPath`, - // while the router gives it the full path `/` (a pathless layout, - // `_auth.tsx`, is typed `/`, and agrees). + // while the router gives it the full path `/` (the test above; a pathless + // layout, `_auth.tsx`, is typed `/`, and agrees). // Source: src/utils.ts:929-950 (`inferFullPath`: `/(marketing)` without // its group is `/`, which is not an index path, so its trailing slash is // dropped), printed at src/utils.ts:1260. @@ -308,19 +200,57 @@ describe('the real router on a generated route tree', () => { // links and hooks) say `''` in such a layout while the app sees `/`. test.fails( 'the layout route of a route group directory has the full path of the router in the types', + () => checkApp(groupLayout, { runtime: false }), + 60_000, + ) + + // Bug: a pathless layout with only a lazy route file + // (`settings/_layout.lazy.tsx`, no `settings/_layout.tsx`) gets no `path` + // in the route tree, so the router drops the `/settings` prefix its + // directory gives it: the child matches `/profile`, not + // `/settings/profile`. The tree's types say `/settings/profile`. Lazy-only + // route files are supported (guide/code-splitting.md:222-224: a virtual + // route anchors the code-split files; SPEC J1.8). Same bug as the G1 pin + // in route-paths.test.ts, checked here with matching, rendering and types. + // Source: src/generator.ts (the `path:` option of `createUpdateRoutes`: + // printed only for non-pathless nodes and nodes typed `pathless_layout`; + // a layout without a route file is typed `static`). + // Impact: the pages under such a layout are served at the wrong URL, and + // typed links to them lead nowhere. + test.fails( + 'a pathless layout with only a lazy route file keeps its directory path', () => checkApp({ - name: 'a route group layout', + name: 'a lazy-only pathless layout', files: { - 'src/routes/__root.tsx': rootRoute(), - ...routes(['(marketing)/route.tsx', '(marketing)/pricing.tsx']), + 'src/routes/__root.tsx': page( + 'react', + 'createRootRoute', + 'root', + true, + ), + 'src/routes/settings/_layout.lazy.tsx': page( + 'react', + 'createLazyFileRoute', + 'settings', + true, + ), + 'src/routes/settings/_layout/profile.tsx': page( + 'react', + 'createFileRoute', + 'profile', + ), }, expected: { __root__: ['/', null], - '/(marketing)': ['/', '__root__'], - '/(marketing)/pricing': ['/pricing', '/(marketing)'], + '/settings/_layout': ['/settings', '__root__'], + '/settings/_layout/profile': [ + '/settings/profile', + '/settings/_layout', + ], }, - visits: { '/pricing': '/(marketing)/pricing' }, + visits: { '/settings/profile': '/settings/_layout/profile' }, + pages: { '/settings/profile': ['root', 'settings', 'profile'] }, }), 60_000, ) diff --git a/packages/router-generator/tests/real-router.ts b/packages/router-generator/tests/real-router.ts new file mode 100644 index 00000000000..3b2a91c660b --- /dev/null +++ b/packages/router-generator/tests/real-router.ts @@ -0,0 +1,213 @@ +/** + * Shared helper of the real-router tests: generates an app, loads its route + * tree with the real router (route-tree.ts) and checks ids, full paths, + * parents, matching, rendered pages and the tree's types against literal + * expectations. + */ +import { expect } from 'vitest' +import { createApp, fileRoute, generate, lazyFileRoute } from './app' +import { withRouteTree } from './route-tree' +import { typeAssertions, typeErrorsOf } from './type-check' +import type { Config } from '../src' + +/** Route files whose ids the generator fixes. */ +export const routes = ( + files: Array, + target: Config['target'] = 'react', +) => + Object.fromEntries( + files.map((file) => [ + `src/routes/${file}`, + file.includes('.lazy.') + ? lazyFileRoute('/wrong', '{}', target) + : fileRoute('/wrong', '{}', target), + ]), + ) + +/** A Vue single-file component rendering `text`, and its child routes. */ +export const sfc = (text: string) => ` + + +` + +/** + * A route file for `target` (React or Solid) whose component renders + * `

    text

    ` and, with `outlet`, its child routes; `constructor` is + * `createRootRoute`, `createFileRoute` or `createLazyFileRoute` (whose id + * the generator fixes). + */ +export const page = ( + target: 'react' | 'solid', + constructor: string, + text: string, + outlet = false, +) => `${target === 'solid' ? '/** @jsxImportSource solid-js */\n' : ''}import { Outlet, ${constructor} } from '@tanstack/${target}-router' + +export const Route = ${constructor === 'createRootRoute' ? constructor : `${constructor}('/wrong')`}({ + component: () => ( + <> +

    ${text}

    + ${outlet ? '' : ''} + + ), +}) +` + +/** + * An app for `target` whose routes render pages: a root layout, an index, a + * route with a lazy component, a lazy-only route, a route with a child, and + * a pathless layout with a child. + */ +export const pagesApp = (target: 'react' | 'solid'): Case => ({ + name: `a ${target} app rendering its pages`, + config: { target }, + files: { + 'src/routes/__root.tsx': page(target, 'createRootRoute', 'root', true), + 'src/routes/index.tsx': page(target, 'createFileRoute', 'home'), + 'src/routes/about.tsx': fileRoute('/wrong', '{}', target), + 'src/routes/about.lazy.tsx': page(target, 'createLazyFileRoute', 'about'), + 'src/routes/contact.lazy.tsx': page( + target, + 'createLazyFileRoute', + 'contact', + ), + 'src/routes/posts.tsx': page(target, 'createFileRoute', 'posts', true), + 'src/routes/posts.$postId.tsx': page(target, 'createFileRoute', 'post'), + 'src/routes/_auth.tsx': page(target, 'createFileRoute', 'auth', true), + 'src/routes/_auth/login.tsx': page(target, 'createFileRoute', 'login'), + }, + expected: { + __root__: ['/', null], + '/': ['/', '__root__'], + '/about': ['/about', '__root__'], + '/contact': ['/contact', '__root__'], + '/posts': ['/posts', '__root__'], + '/posts/$postId': ['/posts/$postId', '/posts'], + '/_auth': ['/', '__root__'], + '/_auth/login': ['/login', '/_auth'], + }, + visits: { + '/': '/', + '/about': '/about', + '/contact': '/contact', + '/posts/1': '/posts/$postId', + '/login': '/_auth/login', + }, + pages: { + '/': ['root', 'home'], + '/about': ['root', 'about'], + '/contact': ['root', 'contact'], + '/posts/1': ['root', 'posts', 'post'], + '/login': ['root', 'auth', 'login'], + }, + // (React: the same routes' types are checked by the first case of + // real-router.test.ts) + types: target === 'solid', +}) + +export interface Case { + name: string + files: Record + config?: Partial + /** Route id to its full path and its parent's id (null for the root). */ + expected: Record + /** URL to the id of the deepest route the router matches for it. */ + visits: Record + /** URL to the texts of the paragraphs its page renders, in order. */ + pages?: Record> + /** False when other cases already check the types of the same routes. */ + types?: boolean +} + +/** Types for Vue single-file component imports, as in a Vue app. */ +const vueShim = `declare module '*.vue' { + const component: unknown + export default component +} +` + +/** The texts of the `

    ` elements of server-rendered `html`, in order. */ +const paragraphs = (html: string) => + [...html.matchAll(/]*>([^<]*)<\/p>/g)].map((match) => match[1]) + +/** + * Generates the app of a case and checks the router over its route tree + * (`runtime`) and the tree's types (`types`) against the expected routes. + */ +export async function checkApp( + { files, config, expected, visits, pages }: Case, + { runtime = true, types = true } = {}, +) { + const root = await createApp({ ...files, 'src/vue-shim.d.ts': vueShim }) + const { generatedRouteTree } = await generate(root, config) + + if (runtime) { + await checkRouter(generatedRouteTree, { expected, visits, pages }) + } + if (types) { + await checkTypes(root, expected) + } +} + +/** Checks the router over the route tree at `treeFile`. */ +async function checkRouter( + treeFile: string, + { expected, visits, pages }: Pick, +) { + await withRouteTree(treeFile, async (tree) => { + const { router } = tree + expect( + Object.fromEntries( + Object.values(router.routesById).map((route) => [ + route.id, + [route.fullPath, route.parentRoute?.id ?? null], + ]), + ), + ).toEqual(expected) + expect( + Object.fromEntries( + Object.keys(visits).map((url) => [ + url, + router.matchRoutes(url).at(-1)?.routeId, + ]), + ), + ).toEqual(visits) + for (const [url, texts] of Object.entries(pages ?? {})) { + expect(paragraphs(await tree.renderAt(url))).toEqual(texts) + } + }) +} + +/** + * Checks the types of the route tree of the app at `root`: the same route + * ids, and for each route the same full path and parent as `expected`. + */ +async function checkTypes(root: string, expected: Case['expected']) { + const ids = Object.keys(expected) + const checks = ids.map((id, i) => { + const [fullPath, parent] = expected[id]! + const route = `FileRoutesById[${JSON.stringify(id)}]` + const actual = `[${JSON.stringify(id)}, ${route}['fullPath'], ${parent === null ? 'null' : `${route}['parentRoute']['id']`}]` + const wanted = JSON.stringify([id, fullPath, parent]) + // (the assignment names the types of a route that differ) + return `export type Check${i} = Expect> +export const check${i}: ${wanted} = null as unknown as ${actual}` + }) + expect( + await typeErrorsOf( + root, + 'src/checks.ts', + `/// +import type { FileRoutesById } from './routeTree.gen' +${typeAssertions} +export type Ids = Expect JSON.stringify(id)).join(' | ')}>> +${checks.join('\n')} +`, + ), + ).toEqual([]) +} diff --git a/packages/router-generator/tests/route-tree.ts b/packages/router-generator/tests/route-tree.ts index c12101c47f7..51aed00a487 100644 --- a/packages/router-generator/tests/route-tree.ts +++ b/packages/router-generator/tests/route-tree.ts @@ -1,17 +1,17 @@ /** * Shared test helper: a generated route tree as the app gets it. The tree and - * the modules it imports are loaded by a real Vite server (the Vue plugin - * compiles single-file components), and the app's own router package - * (`@tanstack/react-router`, `@tanstack/vue-router`) builds a real router - * from it with `createRouter`. Route ids, full paths, parents, matching and - * lazily loaded options are the router's; nothing here derives them. - * - * This package has no Solid router, Solid compiler or React DOM renderer: - * Solid apps do not load here, and only Vue pages render. + * the modules it imports are loaded by a real Vite server with the app's + * framework plugin (`@vitejs/plugin-vue` for single-file components, + * `vite-plugin-solid` for Solid JSX), and the app's own router package + * (`@tanstack/react-router`, `@tanstack/solid-router`, `@tanstack/vue-router`) + * builds a real router from it with `createRouter`. Route ids, full paths, + * parents, matching and lazily loaded options are the router's, and pages + * render with the framework's own server renderer; nothing here derives them. */ import { existsSync } from 'node:fs' import { mkdtemp, + readFile, readdir, realpath, rm, @@ -21,13 +21,15 @@ import { import { tmpdir } from 'node:os' import path from 'node:path' import { createServer, createServerModuleRunner } from 'vite' +import type { PluginOption, ViteDevServer } from 'vite' import type { ModuleRunner } from 'vite/module-runner' /** This package's dependencies, for apps to import their packages from. */ const nodeModules = path.resolve(import.meta.dirname, '../node_modules') -/** The router packages an app may use, and the frameworks they render with. */ -const routerPackages = /^@tanstack\/(react|solid|vue)-router$/ +/** The entry file of a router package, and the framework it is for. */ +const routerPackageFile = + /[\\/](react|solid|vue)-router[\\/]dist[\\/][^\\/]+[\\/]index(?:\.dev)?\.[cm]?jsx?$/ /** * The root of the app `file` belongs to: the nearest directory above it with @@ -61,103 +63,174 @@ async function appRootOf(file: string) { } } +/** The frameworks of an app that need a Vite plugin. */ +interface Frameworks { + vue: boolean + solid: boolean +} + /** - * The Vite plugins of the app at `root`: the Vue plugin when the app has - * single-file components (loaded only then: it is large). + * The frameworks of the app whose sources are in `dir` (not in its + * `node_modules`): Vue when it has single-file components, Solid when its + * modules import Solid. */ -async function pluginsOf(root: string) { - const hasVueFiles = async (dir: string): Promise => { - for (const entry of await readdir(dir, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith('.vue')) { - return true - } - if ( - entry.isDirectory() && - entry.name !== 'node_modules' && - !entry.name.startsWith('.') && - (await hasVueFiles(path.join(dir, entry.name))) - ) { - return true +async function frameworksIn( + dir: string, + found: Frameworks = { vue: false, solid: false }, +) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) { + await frameworksIn(file, found) } + } else if (entry.name.endsWith('.vue')) { + found.vue = true + } else if ( + !found.solid && + /\.[cm]?[jt]sx?$/.test(entry.name) && + /from\s*['"](?:@tanstack\/solid-router|solid-js)['"/]/.test( + await readFile(file, 'utf8'), + ) + ) { + found.solid = true } - return false } - if (!(await hasVueFiles(root))) { - return [] + return found +} + +/** + * Creates a Vite dev server for apps with `frameworks`, configured like such + * an app would configure Vite: the Vue plugin for single-file components, + * the Solid plugin for Solid (with Solid's own choice of which packages to + * bundle for the server; otherwise Node loads the packages). The plugins are + * loaded only when needed: they are large. + */ +async function createAppServer(frameworks: Frameworks, root: string) { + const plugins: Array = [] + if (frameworks.vue) { + const { default: vue } = await import('@vitejs/plugin-vue') + plugins.push(vue()) } - const { default: vue } = await import('@vitejs/plugin-vue') - return [vue()] + if (frameworks.solid) { + const { default: solid } = await import('vite-plugin-solid') + plugins.push(solid({ ssr: true })) + } + return createServer({ + root, + configFile: false, + envDir: false, + logLevel: 'silent', + appType: 'custom', + clearScreen: false, + plugins, + server: { middlewareMode: true, hmr: false, watch: null, ws: false }, + optimizeDeps: { noDiscovery: true, include: [] }, + ssr: frameworks.solid ? {} : { external: true }, + }) } +/** + * The dev server Solid apps share in a worker: they bundle the Solid + * router's sources for the server, and compiling them again for every load + * would take most of a second. Nothing of an app is reused between loads + * (`withModule`). + */ +let solidServer: Promise | undefined + /** A module of the app, loaded by a Vite server of the app. */ interface LoadedModule { /** The module's exports. */ exports: Record /** The module runner that loaded it, with the modules it evaluated. */ runner: ModuleRunner - /** The router package the app's modules import, if any, and its name. */ - router?: { name: string; exports: Record } } /** * Loads `file` (absolute path) like its app's dev server does for server - * rendering: a fresh Vite server (no transform or module caches shared with - * other loads), with the Vue plugin for an app with single-file components; - * packages are loaded by Node. JSX in `.tsx`/`.jsx` files compiles with - * Vite's default (React) transform. Runs `use` with the module, then closes - * the server. + * rendering, with a Vite server configured for the app's frameworks (from + * the sources next to `file`) and a fresh module runner: a new server rooted + * at the app, or for a Solid app the shared Solid server, which first forgets + * every module of the app so that an edited or regenerated file is + * transformed again. JSX in the `.tsx`/`.jsx` files of a React or Vue app + * compiles with Vite's default (React) transform. Runs `use` with the module. */ async function withModule( file: string, use: (loaded: LoadedModule) => Promise | T, ): Promise { const root = await appRootOf(file) - const server = await createServer({ - root, - configFile: false, - envFile: false, - logLevel: 'silent', - appType: 'custom', - clearScreen: false, - plugins: await pluginsOf(root), - server: { middlewareMode: true, hmr: false, watch: null, ws: false }, - optimizeDeps: { noDiscovery: true, include: [] }, - ssr: { external: true }, + const realFile = await realpath(file) + const frameworks = await frameworksIn(path.dirname(realFile)) + let server: ViteDevServer + if (frameworks.solid && !frameworks.vue) { + solidServer ??= realpath(tmpdir()).then((dir) => + createAppServer(frameworks, dir), + ) + server = await solidServer + const { moduleGraph } = server.environments.ssr + for (const module of moduleGraph.idToModuleMap.values()) { + if ( + module.file?.startsWith(root + path.sep) && + !module.file + .slice(root.length) + .includes(`${path.sep}node_modules${path.sep}`) + ) { + moduleGraph.invalidateModule(module) + } + } + } else { + server = await createAppServer(frameworks, root) + } + const runner = createServerModuleRunner(server.environments.ssr, { + hmr: false, }) - let runner: ModuleRunner | undefined try { - runner = createServerModuleRunner(server.environments.ssr, { hmr: false }) - const exports = await runner.import(await realpath(file)) - // The router package the app's modules import: an external module, - // recorded under its name. - const routers = [...runner.evaluatedModules.idToModuleMap.values()] - .filter((node) => routerPackages.test(node.url)) - .map((node) => ({ name: node.url, exports: node.exports })) - if (routers.length > 1) { - throw new Error( - `The app imports ${routers.length} router packages: ${routers.map(({ name }) => name).join(', ')}`, - ) - } - return await use({ exports, runner, router: routers[0] }) + const exports = await runner.import(realFile) + return await use({ exports, runner }) } finally { - await runner?.close() - await server.close() + await runner.close() + if (server !== (await solidServer)) { + await server.close() + } } } +/** + * The router package the app's modules loaded (the instance they use), and + * its framework; undefined when they loaded none. + */ +function routerPackageOf(runner: ModuleRunner) { + const packages = new Map() + for (const node of runner.evaluatedModules.idToModuleMap.values()) { + const framework = routerPackageFile.exec(node.id)?.[1] + if (framework && typeof node.exports?.createRouter === 'function') { + // (a development entry re-exports the same bindings) + packages.set(node.exports.createRouter, { + framework, + exports: node.exports, + }) + } + } + if (packages.size > 1) { + throw new Error(`The app loaded ${packages.size} router packages`) + } + return [...packages.values()][0] +} + /** * Evaluates `code` as a module of an app (a file named `fileName` in a new - * temporary app) with the real router packages. Returns its exports and the - * router package it imports (its own exports, to compare bindings with). + * temporary app) with the real packages. Returns its exports and the router + * package it imports (its own exports, to compare bindings with). */ export async function evaluateModule(code: string, fileName = 'route.tsx') { const root = await mkdtemp(path.join(tmpdir(), 'tsr-module-')) try { const file = path.join(root, fileName) await writeFile(file, code) - return await withModule(file, ({ exports, router }) => ({ + return await withModule(file, ({ exports, runner }) => ({ exports, - router: router?.exports, + router: routerPackageOf(runner)?.exports, })) } finally { await rm(root, { recursive: true, force: true }) @@ -184,7 +257,7 @@ export interface LoadedRouteTree { } /** The app's router package, as the app's modules import it. */ routerPackage: Record - /** `react` or `vue`. */ + /** `react`, `solid` or `vue`. */ framework: string /** * The module file (relative to the tree, `/` separated) whose `Route` @@ -195,14 +268,13 @@ export interface LoadedRouteTree { /** Imports a module of the app (absolute path) like the tree's imports. */ import: (file: string) => Promise> /** - * Server-renders `component` with the app's framework (Vue only), after - * loading it if it is a lazily loaded route component (`preload`). + * Server-renders `component` with the app's framework, after loading it if + * it is a lazily loaded route component (`preload`). */ render: (component: unknown) => Promise /** - * Server-renders the app at `url` (Vue only): a new router over the tree - * with a memory history at `url` loads the location, and its - * `RouterProvider` renders. + * Server-renders the app at `url`: a new router over the tree with a memory + * history at `url` loads the location, and its `RouterProvider` renders. */ renderAt: (url: string) => Promise } @@ -216,12 +288,12 @@ export function withRouteTree( treeFile: string, use: (tree: LoadedRouteTree) => Promise | T, ): Promise { - return withModule(treeFile, async ({ exports, runner, router: imported }) => { - if (!imported) { - throw new Error('The app imports no router package') + return withModule(treeFile, async ({ exports, runner }) => { + const loaded = routerPackageOf(runner) + if (!loaded) { + throw new Error('The app loaded no router package') } - const routerPackage = imported.exports - const framework = routerPackages.exec(imported.name)![1]! + const { framework, exports: routerPackage } = loaded const { routeTree } = exports const router = routerPackage.createRouter({ routeTree, @@ -240,14 +312,34 @@ export function withRouteTree( } return null } - const renderVue = async (render: () => unknown) => { - if (framework !== 'vue') { - throw new Error(`No ${framework} server renderer in this package`) + /** Renders `Component` with `props` with the framework's renderer. */ + const renderElement = async (Component: any, props: object = {}) => { + if (framework === 'vue') { + // (the Vue the app's modules import: Node loads packages) + const { createSSRApp, h } = await import('vue') + const { renderToString } = await import('vue/server-renderer') + return renderToString( + createSSRApp({ render: () => h(Component, props) }), + ) + } + if (framework === 'react') { + // (the React the app's modules import: Node loads packages) + const { createElement } = await import('react') + const { renderToString } = await import('react-dom/server') + return renderToString(createElement(Component, props)) + } + // Solid's server renderer, as the router package loaded it + const web = [...runner.evaluatedModules.idToModuleMap.values()].find( + (node) => + /[\\/]solid-js[\\/]web[\\/]dist[\\/]/.test(node.id) && + typeof node.exports?.renderToStringAsync === 'function', + )?.exports + if (!web) { + throw new Error('The app loaded no solid-js/web') } - // (the Vue the app's modules import: Node loads packages) - const { createSSRApp } = await import('vue') - const { renderToString } = await import('vue/server-renderer') - return renderToString(createSSRApp({ render })) + return web.renderToStringAsync(() => + web.createComponent(Component, props), + ) } return use({ router, @@ -257,8 +349,7 @@ export function withRouteTree( import: (file) => runner.import(file), render: async (component: any) => { await component?.preload?.() - const { h } = await import('vue') - return renderVue(() => h(component)) + return renderElement(component) }, renderAt: async (url) => { const pageRouter = routerPackage.createRouter({ @@ -266,10 +357,9 @@ export function withRouteTree( history: routerPackage.createMemoryHistory({ initialEntries: [url] }), }) await pageRouter.load() - const { h } = await import('vue') - return renderVue(() => - h(routerPackage.RouterProvider, { router: pageRouter }), - ) + return renderElement(routerPackage.RouterProvider, { + router: pageRouter, + }) }, }) }) diff --git a/packages/router-generator/tests/template.test.ts b/packages/router-generator/tests/template.test.ts index 5a34c936f1b..7f2883dfd03 100644 --- a/packages/router-generator/tests/template.test.ts +++ b/packages/router-generator/tests/template.test.ts @@ -111,16 +111,13 @@ describe.each(['react', 'solid', 'vue'] as const)( expect([route.id, route.createdWith]).toEqual([id, createdWith]) expect(route.text.length + route.types.length).toBeGreaterThan(0) } - // (this package has no Solid router: Solid route trees do not load) - if (target !== 'solid') { - expect(await routesOf(generatedRouteTree)).toEqual({ - __root__: 'routes/__root.tsx', - '/posts': 'routes/posts.tsx', - '/about': null, - '/{-$locale}/contact': 'routes/{-$locale}/contact.tsx', - '/{-$locale}/help': null, - }) - } + expect(await routesOf(generatedRouteTree)).toEqual({ + __root__: 'routes/__root.tsx', + '/posts': 'routes/posts.tsx', + '/about': null, + '/{-$locale}/contact': 'routes/{-$locale}/contact.tsx', + '/{-$locale}/help': null, + }) }) // The docs (file-based-routing.md, `quoteStyle` and `semicolons`): "when From de3c4a135ae32b08f54ad3d808577236dec44abc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:17:22 +0200 Subject: [PATCH 0436/1425] test(router-generator): reuse the previous TypeScript program in type checks typeErrorsOf passes the previous program as `oldProgram` to ts.createProgram, so the compiler reuses what it can (the router packages' type definitions are the same files in every app). Same compiler options, same diagnostics; real-router.test.ts went from 4.8 s to 3.2 s in a back-to-back run on the same load. Co-Authored-By: Claude Opus 5.5 --- packages/router-generator/tests/type-check.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/router-generator/tests/type-check.ts b/packages/router-generator/tests/type-check.ts index b03e5f54ec6..a7907376037 100644 --- a/packages/router-generator/tests/type-check.ts +++ b/packages/router-generator/tests/type-check.ts @@ -9,6 +9,9 @@ import ts from 'typescript' /** The packages this package's tests may use (the router packages, React). */ const nodeModules = path.resolve(import.meta.dirname, '../node_modules') +/** The program of the last check. */ +let previousProgram: ts.Program | undefined + /** * Writes `code` to `file` (path relative to the app root) and type-checks it, * with the modules it imports (such as the route tree and the route files), @@ -24,17 +27,23 @@ export async function typeErrorsOf(root: string, file: string, code: string) { }, ) await writeFile(path.join(root, file), code) - const program = ts.createProgram([path.join(root, file)], { - strict: true, - noEmit: true, - skipLibCheck: true, - jsx: ts.JsxEmit.ReactJSX, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - target: ts.ScriptTarget.ES2022, - lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], - types: [], + const program = ts.createProgram({ + rootNames: [path.join(root, file)], + options: { + strict: true, + noEmit: true, + skipLibCheck: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2022, + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], + types: [], + }, + // (the package type definitions of the previous check are reused) + oldProgram: previousProgram, }) + previousProgram = program return ts.getPreEmitDiagnostics(program).map((diagnostic) => { const message = ts.flattenDiagnosticMessageText( diagnostic.messageText, From faf59c5b490f3668ad6725a94844da18b7104f94 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:19:41 +0200 Subject: [PATCH 0437/1425] docs(compiler-test-utils): list mismatches in the diagnostics API header Co-Authored-By: Claude Opus 5.5 --- packages/compiler-test-utils/src/diagnostics.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler-test-utils/src/diagnostics.ts b/packages/compiler-test-utils/src/diagnostics.ts index a559027c9cc..f2d33c19112 100644 --- a/packages/compiler-test-utils/src/diagnostics.ts +++ b/packages/compiler-test-utils/src/diagnostics.ts @@ -22,7 +22,9 @@ * - `findDiagnostics(diagnostics, expectation)` and * `expectDiagnostic(diagnostics, expectation)`: the diagnostics meeting a * `DiagnosticExpectation`; the latter fails, listing why each diagnostic - * does not match, unless one (or exactly `times`) does. + * does not match, unless one (or exactly `times`) does. Both take + * diagnostics or any value `diagnosticsOf` reads. `mismatches(diagnostic, + * expectation)` gives the reasons for one diagnostic. * - `mentionsFile(text, file, options?)`: whether a text names a file. * - `diagnosticPosition(source, needle)`: the 1-based line and column of * the first occurrence of `needle` in `source`. From 5aed121c413bdd443aa0608a571538cf182633ec Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:22:58 +0200 Subject: [PATCH 0438/1425] test(router-plugin): route HMR page waits on the real tools' signals; Rsbuild Solid and Vue `openRouteHmrPage` (tests/route-hmr-page.ts) now observes instead of sleeping: the dev server's own `hotUpdate` plugin hook (Vite) or the compile tracker (Rsbuild) tells when the server handled the written files; the HMR client's public API (`vite:afterUpdate`, the webpack-style `addStatusHandler`) counts the updates the page applied; document loads, HMR messages and the page's own text decide the outcome. An edit the client applied without showing fails after a short grace instead of a timeout (the pins of known-bugs-route-hmr no longer wait 3 s each), and a reload or update that arrives after the text showed is still attributed (HMR-34). `edit()` also returns the page's errors since the edit (HMR-14) and the page exposes `loads()`. Rsbuild apps now run Solid (`@rsbuild/plugin-solid` + `@rsbuild/plugin-babel`) and Vue (`@rsbuild/plugin-vue` + `@rsbuild/plugin-vue-jsx`) with their real HMR support (FIX-PLAN A1 harness work 1). New options: `pluginOptions: null` (the plugin reads tsr.config.json), `pluginOptionsAsFunction`, `readyTimeout`. Co-Authored-By: Claude Fable 5.1 --- packages/router-plugin/tests/hmr-model.ts | 299 ------------- .../router-plugin/tests/route-hmr-page.ts | 406 ++++++++++++++---- .../router-plugin/tests/vite-hot-update.ts | 85 ---- 3 files changed, 329 insertions(+), 461 deletions(-) delete mode 100644 packages/router-plugin/tests/hmr-model.ts delete mode 100644 packages/router-plugin/tests/vite-hot-update.ts diff --git a/packages/router-plugin/tests/hmr-model.ts b/packages/router-plugin/tests/hmr-model.ts deleted file mode 100644 index 6a84db509b3..00000000000 --- a/packages/router-plugin/tests/hmr-model.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * `hotUpdate`: runs the route HMR code of a compiled route file against a - * hot API and a live router, in process. Re-exported by - * `regression-helpers.ts`. - */ -import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' -import { - BaseRootRoute, - BaseRoute, - RouterCore, - createNonReactiveMutableStore, - createNonReactiveReadonlyStore, -} from '@tanstack/router-core' -import { vi } from 'vitest' -import { createMemoryHistory } from '../../history/src' -import type { Config } from '../src/core/config' -import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' -import { - compileRouteGraph, - routeFile, - transformWithRouteHmrPlugin, - webpackCompiler, -} from './plugin-host' -import type { RouteGraph, TargetFramework } from './plugin-host' -import { createRouterStub, stubExports } from './lazy-tracking' -import type { Stubs } from './lazy-tracking' - -const getStoreConfig: GetStoreConfig = () => ({ - createMutableStore: createNonReactiveMutableStore, - createReadonlyStore: createNonReactiveReadonlyStore, - batch: (fn) => fn(), -}) - -export type HotUpdateOptions = { - /** The bundler HMR API the route is compiled for and run with. Default `vite`. */ - style?: 'vite' | 'webpack' - /** - * Compile with automatic code splitting (the code splitter with HMR, its - * modules linked like `loadRoute`) instead of the route HMR plugin alone. - */ - codeSplitting?: boolean - /** Default `react`. */ - targetFramework?: TargetFramework - /** - * 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. */ - updated?: string - /** - * Route id the route file was registered under when the first version - * was compiled, when the route tree registers it under another one since - * (`routeId`), e.g. after a route configuration change. Default `routeId`. - */ - previousRouteId?: string - /** The route file's other imports, by specifier. */ - stubs?: Stubs -} - -/** A version of the route file as the bundler compiled it. */ -type CompiledRouteFile = Pick - -/** - * Runs the route HMR code the router plugin emits, on its own: compiles a - * route file for the dev server, loads the route module (one hot context per - * module, its `data` kept across the update), mounts its `Route` in a route - * tree as the generated route tree does and creates a router with it - * (`window.__TSR_ROUTER__`), then loads the modules of the edited file - * (`updated`) and gives each module that accepted its own updates its new - * version, per the bundler's hot API (Vite: its accept callbacks receive the - * new module; webpack: the module runs again with the data its dispose - * handlers wrote). - * - * Which modules a bundler updates, and what the page shows after an update, - * are not modelled here: tests of those run a real dev server and a real - * page (`route-hmr-page.ts`). - * - * Returns the options of the live route before (`previous`) and after - * (`live`) the update, a copy of the options each module load created a - * route with (`created`), whether a module asked the bundler for a full - * reload (`invalidated`), the `window` the modules ran with, and - * `load(option)`, which resolves a lazy option of the live route to what its - * module provides. - */ -export async function hotUpdate(code: string, options: HotUpdateOptions = {}) { - const { - style = 'vite', - codeSplitting = false, - targetFramework = 'react', - routeId = '/', - } = options - const pluginOptions: Partial = { - target: targetFramework, - plugin: { hmr: { style } }, - } - const compiler = - style === 'webpack' - ? webpackCompiler(targetFramework === 'react') - : undefined - const compile = ( - source: string, - compiledRouteId = routeId, - ): CompiledRouteFile => { - if (codeSplitting) { - return compileRouteGraph(source, { - targetFramework, - hmr: true, - routeId: compiledRouteId, - pluginOptions, - webpackCompiler: compiler, - }) - } - const file = routeFile('index') - const compiled = transformWithRouteHmrPlugin( - source, - pluginOptions, - { file, routeId: compiledRouteId }, - compiler, - ) - return { - id: file, - modules: new Map([[file, compiled]]), - resolve: () => undefined, - } - } - const first = compile(code, options.previousRouteId) - const second = - options.updated === undefined && options.previousRouteId === undefined - ? first - : compile(options.updated ?? code) - - const created: Array> = [] - const lazy = new Map Promise>() - let invalidated = false - type HotState = { - data: Record - accepted: Array<(module: unknown) => void> - disposers: Array<(data: Record) => void> - } - const hotStates = new Map() - const hotState = (id: string) => { - let state = hotStates.get(id) - if (!state) { - state = { data: {}, accepted: [], disposers: [] } - hotStates.set(id, state) - } - return state - } - const hotContext = (id: string) => { - const state = hotState(id) - return { - data: state.data, - accept: (callback?: (module: unknown) => void) => { - if (callback) { - state.accepted.push(callback) - } - }, - dispose: (callback: (data: Record) => void) => { - state.disposers.push(callback) - }, - invalidate: () => { - invalidated = true - }, - } - } - - const recording = - (create: (options: Record) => unknown) => - (routeOptions: Record = {}) => { - // A copy: the HMR code writes kept options into the new ones. - created.push({ ...routeOptions }) - return create(routeOptions) - } - const createRoute = (routeOptions: Record) => { - const route = new BaseRoute(routeOptions as never) as unknown as { - isRoot: boolean - } - route.isRoot = false - return route - } - // 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 routerPackage = `@tanstack/${targetFramework}-router` - // The route classes of the framework packages add hooks to the router-core - // ones; route HMR only uses what router-core provides. - const routerStub: Record = { - ...createRouterStub(lazy), - createFileRoute: () => recording(createRoute), - createLazyFileRoute: () => recording(createRoute), - createRootRoute: recording(createRootRoute), - createRootRouteWithContext: () => recording(createRootRoute), - ...stubExports(options.stubs?.[routerPackage]), - } - const load = (compiled: CompiledRouteFile) => - createModuleGraph({ - modules: Object.fromEntries(compiled.modules), - resolve: compiled.resolve, - stubs: { ...options.stubs, [routerPackage]: routerStub }, - importMeta: (id) => ({ - [style === 'webpack' ? 'webpackHot' : 'hot']: hotContext(id), - }), - }) - - const window: { __TSR_ROUTER__?: RouterCore } = {} - vi.stubGlobal('window', window) - try { - const firstGraph = load(first) - const firstModule = await firstGraph.import(first.id) - const isRoot = rootRoutes.has(firstModule.Route as object) - let routeTree: AnyRoute - if (isRoot) { - routeTree = firstModule.Route as AnyRoute - } else { - const rootRoute = new BaseRootRoute({}) - const route = (firstModule.Route as AnyRoute).update({ - id: routeId, - path: routeId, - getParentRoute: () => rootRoute, - } as never) - routeTree = rootRoute.addChildren([route]) as unknown as AnyRoute - } - const router = new RouterCore( - { - routeTree, - history: createMemoryHistory({ initialEntries: ['/'] }), - isServer: false, - origin: 'http://localhost', - }, - 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 { - options: Record - } - ).options - const previous = { ...liveOptions() } - - if (style === 'webpack') { - for (const state of hotStates.values()) { - const next: Record = {} - state.disposers.forEach((dispose) => dispose(next)) - state.data = next - state.disposers = [] - } - } - const accepted = new Map( - [...hotStates].map(([id, state]) => [id, state.accepted]), - ) - for (const state of hotStates.values()) { - state.accepted = [] - } - const secondGraph = load(second) - await secondGraph.import(second.id) - // The route module's update first, then the other modules that accept - // their own updates, each with its new module. - const updates = [...accepted].sort( - ([a], [b]) => Number(b === first.id) - Number(a === first.id), - ) - for (const [id, callbacks] of updates) { - if (callbacks.length === 0 || !second.modules.has(id)) { - continue - } - const updatedModule = await secondGraph.import(id) - callbacks.forEach((accept) => accept(updatedModule)) - } - - const live = liveOptions() - return { - /** Id of the route file, as the plugin saw it. */ - file: first.id, - previous, - live, - created, - invalidated, - /** The `window` the modules ran with (the router is `__TSR_ROUTER__`). */ - window, - /** Whether an option of the live route loads lazily from another module. */ - isLazy: (option: string) => lazy.has(live[option]), - /** The value of an option of the live route, once its lazy module loaded. */ - load: (option: string): Promise => { - const loader = lazy.get(live[option]) - return loader ? loader() : Promise.resolve(live[option]) - }, - } - } finally { - vi.unstubAllGlobals() - } -} diff --git a/packages/router-plugin/tests/route-hmr-page.ts b/packages/router-plugin/tests/route-hmr-page.ts index 2d0b982a9b1..3c76b085f6f 100644 --- a/packages/router-plugin/tests/route-hmr-page.ts +++ b/packages/router-plugin/tests/route-hmr-page.ts @@ -1,20 +1,28 @@ /** * Route HMR as an app sees it: a real dev server (Vite with the router * plugin and the framework's own Vite plugin, or Rsbuild with the router - * plugin's Rspack entry and `@rsbuild/plugin-react`) serves a small + * plugin's Rspack entry and the framework's Rsbuild plugin) serves a small * file-based app with the real router package, a real browser page * (headless Chromium) runs it with the dev server's own HMR client, and the * test edits route files on disk, which the dev server's own file watcher - * reports. The test reads the page: what it shows, and whether it reloaded. + * reports. The test reads the page: what it shows, whether it reloaded, and + * what it logged. * * Nothing of the HMR path is modelled: the bundler decides which modules * accept the update, the framework's refresh runtime (React Refresh, Solid * Refresh, Vue's HMR runtime) patches components, and the route HMR code - * the router plugin emits updates the live router. + * the router plugin emits updates the live router. The harness only waits on + * what the real tools report: the dev server's plugin hooks and HMR + * messages, the HMR client's public update events, the page's document + * loads, and the page's text. */ import path from 'node:path' import { fileURLToPath } from 'node:url' +import { pluginBabel } from '@rsbuild/plugin-babel' import { pluginReact } from '@rsbuild/plugin-react' +import { pluginSolid } from '@rsbuild/plugin-solid' +import { pluginVue } from '@rsbuild/plugin-vue' +import { pluginVueJsx } from '@rsbuild/plugin-vue-jsx' import react from '@vitejs/plugin-react' import vueJsx from '@vitejs/plugin-vue-jsx' import solid from 'vite-plugin-solid' @@ -22,16 +30,42 @@ import { onTestFinished } from 'vitest' import { openPage } from '@tanstack/compiler-test-utils/browser' import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' import { createRsbuildDevServer } from '@tanstack/compiler-test-utils/rsbuild-dev-server' +import { getConfig } from '../src/core/config' import { tanstackRouter as tanstackRouterRspack } from '../src/rspack' import { tanstackRouter } from '../src/vite' import type { BrowserPage } from '@tanstack/compiler-test-utils/browser' import type { RsbuildPlugin } from '@rsbuild/core' -import type { PluginOption } from 'vite' +import type { Plugin, PluginOption } from 'vite' import type { Config } from '../src/core/config' import type { TargetFramework } from './regression-helpers' const harnessDir = path.dirname(fileURLToPath(import.meta.url)) +/** + * How the app counts the updates its HMR client applied, through the + * client's public API: Vite's `vite:afterUpdate` event, and the + * webpack-style hot API's status handler (Rspack), which reports `idle` + * again once an update is applied. The count is read by `edit()`. + */ +const hmrProbes = { + vite: `if (import.meta.hot) { + import.meta.hot.on('vite:afterUpdate', () => { + window.__routeHmrApplied = (window.__routeHmrApplied ?? 0) + 1 + }) +} +`, + rsbuild: `if (import.meta.webpackHot) { + let status = import.meta.webpackHot.status() + import.meta.webpackHot.addStatusHandler((next) => { + if (next === 'idle' && status !== 'idle') { + window.__routeHmrApplied = (window.__routeHmrApplied ?? 0) + 1 + } + status = next + }) +} +`, +} + /** How the app mounts the router into `#app`, per framework. */ const mains: Record = { react: `import { createRoot } from 'react-dom/client' @@ -55,12 +89,31 @@ createApp({ setup: () => () => }).mount('#app } /** The framework's own Vite plugin, as an app adds it. */ -const frameworkPlugins: Record PluginOption> = { +const frameworkVitePlugins: Record PluginOption> = { react: () => react(), solid: () => solid(), vue: () => vueJsx(), } +/** + * The framework's own Rsbuild plugins, as an app adds them (the Solid and + * Vue JSX plugins compile JSX with Babel: `@rsbuild/plugin-babel`). + */ +const frameworkRsbuildPlugins: Record< + TargetFramework, + (options: RouteHmrPageOptions) => Array +> = { + react: (options) => [ + pluginReact({ fastRefresh: options.reactRefresh ?? true }), + ], + solid: () => [pluginBabel({ include: /\.(?:jsx|tsx)$/ }), pluginSolid()], + vue: () => [ + pluginBabel({ include: /\.(?:jsx|tsx)$/ }), + pluginVue(), + pluginVueJsx(), + ], +} + /** * Dependencies the app imports, pre-bundled up front as Vite does for an app * that installed them (the router package is a linked workspace package @@ -80,17 +133,28 @@ const prebundled: Record> = { } export interface RouteHmrPageOptions { - /** Default `vite`. Rsbuild runs React apps only. */ + /** Default `vite`. */ bundler?: 'vite' | 'rsbuild' /** Default `react`. */ target?: TargetFramework - /** Router plugin options over the defaults (`autoCodeSplitting: true`). */ - pluginOptions?: Partial + /** + * Router plugin options over the defaults (`autoCodeSplitting: true`, the + * app's `routes/` directory and route tree); `null` gives the plugin no + * options (Vite only: it reads the app's `tsr.config.json`). + */ + pluginOptions?: Partial | null + /** + * Gives the router plugin its options as a function returning them, as + * `tanstackRouter(() => config)` allows (Vite only). Default: an object. + */ + pluginOptionsAsFunction?: boolean /** * Whether the app adds its framework's plugin (default `true`): * `@vitejs/plugin-react`, `vite-plugin-solid` or `@vitejs/plugin-vue-jsx` - * with Vite, `@rsbuild/plugin-react` with Rsbuild. Without it, a React app - * on Rsbuild runs without React Refresh. + * with Vite, `@rsbuild/plugin-react`, `@rsbuild/plugin-solid` or + * `@rsbuild/plugin-vue` + `@rsbuild/plugin-vue-jsx` with Rsbuild. Without + * it, a React app runs without React Refresh (Vite compiles its JSX + * itself; Rsbuild's SWC does too). */ frameworkPlugin?: boolean /** @@ -101,11 +165,14 @@ export interface RouteHmrPageOptions { /** * App files relative to its root: route files in `routes/` * (`routes/__root.tsx` defaults to an empty root route, which renders its - * child routes) and any other module they import. + * child routes) and any other module they import. `main.tsx` defaults to + * an entry mounting the router into `#app`. */ files: Record /** Text the app shows once it rendered. */ ready: string | RegExp + /** How long the app may take to show `ready` (default 15 s). */ + readyTimeout?: number /** * Registers the cleanup (closing the page and the dev server) to run when * the test ends. Default: vitest's `onTestFinished`; a concurrent test @@ -114,22 +181,42 @@ export interface RouteHmrPageOptions { onTestFinished?: (cleanup: () => Promise) => void } +export interface EditResult { + /** The page did a full reload instead of a hot update. */ + reloaded: boolean + /** + * Uncaught errors of the page and what it logged with `console.error` + * since the edit began: the HMR client's and the framework's reports of a + * failed update, and whatever the app logs itself (a test with a route + * that deliberately throws leaves those out). + */ + errors: Array +} + export interface RouteHmrPage { /** The browser page. */ page: BrowserPage + /** How many times the page loaded (its first load included). */ + loads: () => number /** Clicks the first element matching `selector`. */ click: (selector: string) => Promise /** * Writes app files (`null` deletes one) for the dev server's watcher to - * pick up, waits until the app shows `expected`, then until the dev server - * is quiet, and returns whether the page reloaded meanwhile (a full reload - * instead of a hot update). + * pick up, waits until the dev server handled them and the app shows + * `expected`, then until the HMR client applied every update the server + * sent and the server is quiet, and returns whether the page reloaded + * meanwhile and what it logged. + * + * Fails as soon as the HMR client reports the update applied and the app + * still does not show `expected` shortly after, or after `timeout` + * (default 15 s) otherwise (a full reload shows `expected` once the page + * loaded again). */ edit: ( files: Record, expected: string | RegExp, options?: { timeout?: number }, - ) => Promise<{ reloaded: boolean }> + ) => Promise /** * Runs `script` in the page (e.g. reads the live router, * `window.__TSR_ROUTER__`) and returns its JSON value. @@ -140,16 +227,40 @@ export interface RouteHmrPage { /** The part of a dev server the page needs. */ interface AppServer { url: string - /** Writes files for the server's watcher. */ - write: (files: Record) => Promise - /** Waits until the server is quiet. */ - quiet: () => Promise + /** + * Writes files for the server's watcher and waits until the server + * handled them (Vite: ran the HMR hooks for each file; Rsbuild: finished + * the compile that includes them). + */ + write: ( + files: Record, + timeout: number, + ) => Promise + /** Waits until the server has no work in flight. */ + idle: () => Promise + /** Hot updates the server sent the page so far. */ + updates: () => number /** Full reloads the server sent the page so far. */ fullReloads: () => number } -/** How long the dev server must stay silent after an edit showed. */ -const quietTime = 300 +/** + * How long the page may take to show an edit once its HMR client reported + * the update applied: the framework's refresh runtime patches components a + * few milliseconds later (React Refresh debounces by 16–30 ms), then the + * router re-runs loaders and renders. + */ +const appliedGrace = 500 + +/** + * How long the server and the page must stay quiet (no new HMR message, no + * new document load) after an edit showed, before `edit()` returns: a + * reload the dev server decides after the update (e.g. for a file a plugin + * wrote while handling the edit) arrives within this time. + */ +const quietTime = 100 + +const defaultEditTimeout = 15_000 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -162,51 +273,181 @@ export async function openRouteHmrPage( options: RouteHmrPageOptions, ): Promise { const target = options.target ?? 'react' + const bundler = options.bundler ?? 'vite' const files = { - 'main.tsx': mains[target], + 'main.tsx': `${mains[target]}${hmrProbes[bundler]}`, 'routes/__root.tsx': `import { createRootRoute } from '@tanstack/${target}-router' export const Route = createRootRoute({}) `, ...options.files, } const server = - (options.bundler ?? 'vite') === 'vite' + bundler === 'vite' ? await viteServer(target, files, options) : await rsbuildServer(target, files, options) const page = await openPage(server.url, { registerCleanup: options.onTestFinished ?? onTestFinished, }) - await page.waitForText(options.ready, { selector: '#app' }) - await server.quiet() + await page.waitForText(options.ready, { + selector: '#app', + timeout: options.readyTimeout, + }) + await server.idle() + + const text = async () => { + try { + return await page.text('#app') + } catch { + // The page is reloading + return '' + } + } + const matches = (expected: string | RegExp, current: string) => + typeof expected === 'string' + ? current.includes(expected) + : expected.test(current) + /** Updates the page's HMR client applied so far (see `hmrProbes`). */ + const applied = async () => { + try { + return await page.page.evaluate('window.__routeHmrApplied ?? 0') + } catch { + // The page is reloading + return undefined + } + } + const describe = (expected: string | RegExp, current: string) => + [ + `the page shows: ${JSON.stringify(current)}`, + `expected: ${String(expected)}`, + `page loads: ${page.loads()}`, + `errors:\n${page.errors.join('\n') || '(none)'}`, + `console:\n${page.console.join('\n') || '(none)'}`, + ].join('\n') return { page, + loads: () => page.loads(), click: async (selector) => { await page.page.locator(selector).first().click() }, edit: async (edited, expected, editOptions = {}) => { - const loads = page.loads() - const fullReloads = server.fullReloads() - await server.write(edited) - await page.waitForText(expected, { - selector: '#app', - timeout: editOptions.timeout, - }) - await server.quiet() + const timeout = editOptions.timeout ?? defaultEditTimeout + const start = { + loads: page.loads(), + errors: page.errors.length, + applied: (await applied()) ?? 0, + updates: server.updates(), + fullReloads: server.fullReloads(), + } + const reloaded = () => + page.loads() > start.loads || server.fullReloads() > start.fullReloads + const deadline = Date.now() + timeout + await server.write(edited, timeout) + + // Until the page shows the edit + let appliedAt: number | undefined + for (;;) { + const current = await text() + if (matches(expected, current)) { + break + } + if (!reloaded()) { + const count = await applied() + if (count !== undefined && count > start.applied) { + appliedAt ??= Date.now() + if (Date.now() - appliedAt > appliedGrace) { + throw new Error( + `the page's HMR client applied the update of ${Object.keys(edited).join(', ')} but the page does not show the edit\n${describe(expected, current)}`, + ) + } + } + } + if (Date.now() > deadline) { + throw new Error( + `the page did not show the edit of ${Object.keys(edited).join(', ')} within ${timeout} ms\n${describe(expected, current)}`, + ) + } + await sleep(25) + } + + // Until the client applied every update the server sent, and nothing + // else comes + for (;;) { + await server.idle() + const quietUntil = Date.now() + quietTime + const seen = { + loads: page.loads(), + updates: server.updates(), + fullReloads: server.fullReloads(), + } + let moved = false + while (Date.now() < quietUntil) { + await sleep(25) + const count = await applied() + const pending = + !reloaded() && + count !== undefined && + count - start.applied < seen.updates - start.updates + if ( + pending || + page.loads() !== seen.loads || + server.updates() !== seen.updates || + server.fullReloads() !== seen.fullReloads + ) { + moved = true + break + } + } + if (!moved) { + break + } + if (Date.now() > deadline) { + throw new Error( + `the dev server and the page did not settle after the edit of ${Object.keys(edited).join(', ')} within ${timeout} ms\n${describe(expected, await text())}`, + ) + } + } return { - reloaded: - server.fullReloads() !== fullReloads || page.loads() !== loads, + reloaded: reloaded(), + errors: page.errors.slice(start.errors), } }, evaluate: (script) => page.page.evaluate(script), } } +/** The router plugin's options for an app at `root`. */ +function routerPluginOptions( + target: TargetFramework, + root: string, + options: RouteHmrPageOptions, +): Partial { + return { + target, + routesDirectory: path.join(root, 'routes'), + generatedRouteTree: path.join(root, 'routeTree.gen.ts'), + autoCodeSplitting: true, + ...options.pluginOptions, + } +} + async function viteServer( target: TargetFramework, files: Record, options: RouteHmrPageOptions, ): Promise { + /** How many times the server ran the client HMR hooks for each file. */ + const handled = new Map() + const handledTimes = (file: string) => handled.get(file) ?? 0 + /** Records the files the server handled hot updates for (a plugin hook). */ + const recordingPlugin: Plugin = { + name: 'route-hmr-page', + hotUpdate({ file }) { + if (this.environment.name === 'client') { + handled.set(file, handledTimes(file) + 1) + } + }, + } const server = await createDevServer({ dir: harnessDir, prefix: '.route-hmr-page-', @@ -224,42 +465,50 @@ async function viteServer( ...files, }, config: { optimizeDeps: { include: prebundled[target] } }, - plugins: [ - tanstackRouter({ - target, - routesDirectory: './routes', - generatedRouteTree: './routeTree.gen.ts', - autoCodeSplitting: true, - ...options.pluginOptions, - }), + plugins: ({ root }) => [ + options.pluginOptions === null + ? tanstackRouter() + : options.pluginOptionsAsFunction + ? tanstackRouter(() => + getConfig(routerPluginOptions(target, root, options), root), + ) + : tanstackRouter(routerPluginOptions(target, root, options)), ...(options.frameworkPlugin === false ? [] - : [frameworkPlugins[target]()]), + : [frameworkVitePlugins[target]()]), + recordingPlugin, ], }) + const clientMessages = (type: 'update' | 'full-reload') => () => + server.hmr.filter( + (message) => message.environment === 'client' && message.type === type, + ).length return { url: server.url!, - write: server.write, - // Until the server sent no HMR message for a while - quiet: async () => { - let count = server.hmr.length - let since = Date.now() - for (;;) { - await sleep(25) - if (server.hmr.length !== count) { - count = server.hmr.length - since = Date.now() - } else if (Date.now() - since >= quietTime) { - await server.idle() - return + write: async (edited, timeout) => { + const before = Object.keys(edited).map((relative) => { + const file = server.file(relative) + return { file, count: handledTimes(file) } + }) + await server.write(edited) + // Until the server's watcher reported each file and the server ran + // the HMR hooks for it + const deadline = Date.now() + timeout + while (before.some(({ file, count }) => handledTimes(file) === count)) { + if (Date.now() > deadline) { + throw new Error( + `the dev server did not handle the edit of ${before + .filter(({ file, count }) => handledTimes(file) === count) + .map(({ file }) => path.relative(server.root, file)) + .join(', ')} within ${timeout} ms`, + ) } + await sleep(10) } }, - fullReloads: () => - server.hmr.filter( - (message) => - message.environment === 'client' && message.type === 'full-reload', - ).length, + idle: () => server.idle(), + updates: clientMessages('update'), + fullReloads: clientMessages('full-reload'), } } @@ -268,8 +517,10 @@ async function rsbuildServer( files: Record, options: RouteHmrPageOptions, ): Promise { - if (target !== 'react') { - throw new Error('the Rsbuild page runs React apps only') + if (options.pluginOptions === null || options.pluginOptionsAsFunction) { + throw new Error( + 'the Rsbuild page gives the router plugin its options as an object', + ) } const server = await createRsbuildDevServer({ dir: harnessDir, @@ -282,32 +533,33 @@ async function rsbuildServer( plugins: ({ root }) => [ ...(options.frameworkPlugin === false ? [] - : [pluginReact({ fastRefresh: options.reactRefresh ?? true })]), + : frameworkRsbuildPlugins[target](options)), { name: 'route-hmr-page', setup(api) { api.modifyRspackConfig((config) => { config.plugins.push( - tanstackRouterRspack({ - target, - // The Rspack plugin resolves relative paths from the working - // directory - routesDirectory: path.join(root, 'routes'), - generatedRouteTree: path.join(root, 'routeTree.gen.ts'), - autoCodeSplitting: true, - ...options.pluginOptions, - }), + tanstackRouterRspack(routerPluginOptions(target, root, options)), ) }) }, } satisfies RsbuildPlugin, ], }) + let updates = 0 return { url: server.url!, - write: server.edit, - quiet: () => sleep(quietTime), - // Rsbuild's client reloads the page itself + write: async (edited) => { + // Until the compile that includes the edit ended; its hot-update + // chunks are what the page applies + await server.edit(edited) + if (Object.keys(await server.hotUpdates()).length > 0) { + updates++ + } + }, + idle: () => Promise.resolve(), + updates: () => updates, + // Rsbuild's client reloads the page itself: the page's loads tell fullReloads: () => 0, } } diff --git a/packages/router-plugin/tests/vite-hot-update.ts b/packages/router-plugin/tests/vite-hot-update.ts deleted file mode 100644 index 7f5776044f0..00000000000 --- a/packages/router-plugin/tests/vite-hot-update.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Route file edits in a real Vite dev server with the router plugin and the - * app's framework plugin (`@vitejs/plugin-react`, `vite-plugin-solid`, - * `@vitejs/plugin-vue-jsx`): which HMR messages the page receives. Vite - * decides from the served code which modules accept their own updates (HMR - * boundaries); an update no boundary accepts reloads the page. - */ -import { describeHmr } from '@tanstack/compiler-test-utils/dev-server' -import { routerDevServer, routerPackageStub } from './dev-server' -import type { HmrMessage } from '@tanstack/compiler-test-utils/dev-server' -import type { PluginOption, Update } from 'vite' -import type { TargetFramework } from './regression-helpers' - -export type RouteEdit = { - target: TargetFramework - autoCodeSplitting: boolean - /** The framework's Vite plugins, after the router plugin. */ - plugins?: Array - /** The route file, relative to the app root. Default `routes/index.tsx`. */ - file?: string - /** The route file of each version (`'page'` first, then `'edited'`). */ - route: (version: string) => string - /** - * Modules the page loaded besides the client entry and what it imports - * statically, e.g. a split component (`?tsr-split=component`, relative to - * the route file's URL), as the page rendering the route did. - */ - loaded?: Array - /** Other files of the app, relative to its root. */ - files?: Record -} - -/** Dependencies, Vite's client and the router package stub (`/@id/...`). */ -const notLoaded = /^\/@id\/|\/node_modules\/|^\/@(?:vite|react-refresh)\b/ - -/** - * Starts a dev server (router package stubbed) on an app whose client entry - * imports the route tree, loads the entry, what it imports and `loaded` in - * the client environment, as a page does, edits the route file and returns - * what the client received: the HMR messages (`describeHmr` lines), the - * updates (each boundary `path` and the module it imports again, - * `acceptedPath`), and whether the edit is a hot update without a reload. - */ -export async function editRouteFile(edit: RouteEdit) { - const file = edit.file ?? 'routes/index.tsx' - const server = await routerDevServer({ - pluginOptions: { - target: edit.target, - autoCodeSplitting: edit.autoCodeSplitting, - }, - plugins: [routerPackageStub(edit.target), ...(edit.plugins ?? [])], - files: { - ...edit.files, - [file]: edit.route('page'), - 'entry.ts': `export { routeTree } from './routeTree.gen'`, - }, - }) - await server.load('/entry.ts', { skip: notLoaded }) - for (const url of edit.loaded ?? []) { - await server.load(`/${file}${url}`, { skip: notLoaded }) - } - const messages = (await server.edit({ [file]: edit.route('edited') })).filter( - (message) => message.environment === 'client', - ) - const updates = messages.flatMap((message: HmrMessage) => - message.payload.type === 'update' - ? message.payload.updates.map(({ path, acceptedPath }: Update) => ({ - path, - acceptedPath, - })) - : [], - ) - const described = describeHmr(messages) - return { - messages: described, - updates, - /** The page applies the edit in place: an update and no full reload. */ - hotUpdate: - updates.length > 0 && - !described.some((message) => message.startsWith('client: full-reload')), - /** Whether the client imports this module (URL) again. */ - reimports: (url: string) => - updates.some((update) => update.acceptedPath === url), - } -} From d64142520b3596c6271201c8683372d67b2407e9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:23:13 +0200 Subject: [PATCH 0439/1425] test(router-plugin): delete the in-process HMR model; its consumers move to the real page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hotUpdate` (tests/hmr-model.ts) created hot contexts and hot data, ran disposers, re-executed modules and ordered accept callbacks itself; `editRouteFile` (tests/vite-hot-update.ts) used Vite's boundary analysis with a fake router and no browser client. Both copied bundler/HMR dispatch (ruling 2026-10-18; FIX-PLAN A1). Every consumer is ported to `openRouteHmrPage` or deleted: - route-hmr.test.ts: the Solid/Vue, factory and stale-id model tests go (HMR-01/02/04/05; the page suites cover Solid/Vue on Vite and Rsbuild, factory options in route-hmr-browser.test.ts; stale-id reachability is evidence work, SPEC §9). The React-import exclusion stays as a compile check (HMR-03: no window-key assertion). The no-HMR scan includes `module.hot` (HMR-08). The dev-server tests that looked for the hot API in the served code (.ts, environmentName, hmr.style alone, tsr.config.json target, function options: HMR-10/11) are page rows in route-hmr-browser-plugin.test.ts; the excluded environment is checked on its whole served graph. - known-bugs-route-hmr.test.ts (fast tier): the `hotUpdate` alias pin and the `editRouteFile` boundary tests (HMR-22/23) are page pins; the fake `ErrorBoundary` control/pin (HMR-19) is a page control/pin with the real `react-error-boundary`; the staticData identity control stays. - regression-helpers.test.ts: the four `hotUpdate` controls go with the helper. - react-refresh-route-components.test.ts: the root module creating another route is unsupported (one route per file, HMR-12): deleted; root edits are covered by route-hmr-browser.test.ts. - code-splitter-plugin-pipeline.test.ts: the webpack-style Solid update and the factory-route HMR matrix (F07/F09/F33) are page rows; the accept count goes (the route evaluates without a hot API). - code-splitter-route-options.test.ts: root route call shapes are page rows. - router-plugin-context.test.ts: the internal-plugin HMR test (F08) goes; every page test's first edit goes through the public plugin setup. - integration/plugin-options.test.ts: the hot-API scan of the rspack/webpack entries (F08) becomes a page row on Rsbuild; the `addHmr: false` control stays. - handle-route-update.test.ts calls the emitted `handleRouteUpdate` with explicit inputs and a real router, no hot context (AGENTS.md sentence, P3); the absent-route test navigates the live tree (HMR-26). AGENTS.md carries the approved sentence (DECISIONS.md, P3). Co-Authored-By: Claude Fable 5.1 --- packages/router-plugin/AGENTS.md | 4 +- .../code-splitter-plugin-pipeline.test.ts | 93 +----- .../tests/code-splitter-route-options.test.ts | 31 +- .../tests/handle-route-update.test.ts | 236 ++++++---------- .../tests/integration/plugin-options.test.ts | 195 ++----------- .../tests/known-bugs-route-hmr.test.ts | 247 +--------------- .../react-refresh-route-components.test.ts | 32 +-- .../tests/regression-helpers.test.ts | 69 +---- .../router-plugin/tests/regression-helpers.ts | 14 +- .../router-plugin/tests/route-hmr.test.ts | 267 ++++-------------- .../tests/router-plugin-context.test.ts | 83 +----- 11 files changed, 204 insertions(+), 1067 deletions(-) diff --git a/packages/router-plugin/AGENTS.md b/packages/router-plugin/AGENTS.md index 0d6f25296b9..97532925cd1 100644 --- a/packages/router-plugin/AGENTS.md +++ b/packages/router-plugin/AGENTS.md @@ -2,9 +2,9 @@ - Code splitting must not change what a route does. The route module and its split chunks share one instance of every module-level value: `Route` and any state created at module load exist once, and every chunk sees the same object. Which bindings the splitter shares, copies or leaves in the entry is an implementation choice; tests assert values, identity and init counts, never placement. - Every option configured for splitting must end up split. When a pattern the user can change prevents it, the option keeps working in the entry and the build warns once, naming the file and the option. When the limitation is ours, it is a bug to fix, not a warning. -- `handleRouteUpdate` is stringified and injected into app modules. Keep runtime dependencies inside the function or explicitly emit them; extracting a helper to plugin module scope breaks the generated function. `tests/handle-route-update.test.ts` executes that emitted code: it compiles edited route files with the route HMR plugin and evaluates them next to a live router. +- `handleRouteUpdate` is stringified and injected into app modules. Keep runtime dependencies inside the function or explicitly emit them; extracting a helper to plugin module scope breaks the generated function. `tests/handle-route-update.test.ts` executes that emitted code: it calls the emitted function with the route of an edited file and a live router. - Route HMR rebuilds indexes, clears `resolvePathCache`, replaces lazy-chunk ownership, and refreshes active routes while preserving component identities (React, Solid and Vue). Keep these steps coordinated when adding derived state. - Tests run in two tiers. `test:unit` (fast tier, the inner loop) runs `tests/**` in process; a Vite dev server in middleware mode is allowed. `test:integration` (slow tier) runs `tests/integration/**`: every test that starts a real build (`build`, `buildAndRun`), an Rsbuild dev server or watch compiler, a child Node process or a browser page (`openRouteHmrPage`). Put such tests under `tests/integration/`; a file with tests of both kinds is split by test into files of the same name in both directories. Run both tiers before handing back. - Test code-splitting changes by behaviour: compile the route and load the emitted modules with `loadRoute` from `tests/regression-helpers.ts` (built on the module linker from `@tanstack/compiler-test-utils`), and assert what users see (rendered output, loader results, init counts). Outcomes the bundler decides (dead code, chunk contents, `sideEffects` / `@__PURE__` hints) are asserted in a real build with `build` from `tests/build-harness.ts`, per bundler. Do not add output snapshots. - Run behavioural tests for React, Solid and Vue through the `targetFramework` option of the test helpers. -- Test what the page shows after a route file edit with `openRouteHmrPage` (`tests/route-hmr-page.ts`): a real dev server with the framework's plugin, a real browser page and the real HMR client. Never copy bundler, HMR-client or React Refresh logic into tests; `hotUpdate` only runs our route HMR code against the bundler's hot API. +- Test what the page shows after a route file edit with `openRouteHmrPage` (`tests/route-hmr-page.ts`): a real dev server with the framework's plugin, a real browser page and the real HMR client. Never copy bundler, HMR-client or React Refresh logic into tests. There is no in-process HMR model: test route HMR with `openRouteHmrPage`. A unit test of `handleRouteUpdate` may call it with explicit inputs and a real router, but must not recreate hot contexts, disposal, module selection or framework refresh. 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 84d649aea82..77773a65281 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import { compileRouteModules, createCodeSplitterTransforms, @@ -7,7 +8,6 @@ import { evaluateModule, expectValidModules, head, - hotUpdate, importRequests, importSources, loadRoute, @@ -15,7 +15,6 @@ import { routeFile, transformWithRouteHmrPlugin, } from './regression-helpers' -import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' import type { Config } from '../src/core/config' const routeSource = (name: string) => ` @@ -504,23 +503,13 @@ export const Route = createFileRoute('/hmr')({ }) it.each(['solid', 'vue'] as const)( - 'appends Vite HMR handling to %s routes without changing them', + 'keeps a %s route valid and rendering when compiled with HMR', async (target) => { const output = transformWith({ target }) expect(await getModuleErrors(output)).toEqual([]) - let accepted = 0 const { Route } = await evaluateModule(output, routerStubs(target), { - importMeta: { - hot: { - data: {}, - accept: () => { - accepted++ - }, - dispose: () => {}, - }, - }, + importMeta: { hot: undefined }, }) - expect(accepted).toBe(1) expect((Route as any).options.component()).toBe('

    hmr

    ') }, ) @@ -548,24 +537,8 @@ export const Route = createFileRoute('/hmr')({ expect((Route as any).options.component()).toBe('

    hmr

    ') }) - it('hot-updates a non-React route with webpack HMR', async () => { - const route = (data: string) => ` -import { createFileRoute } from '@tanstack/solid-router' -export const Route = createFileRoute('/hmr')({ - loader: () => '${data}', - component: () =>

    hmr

    , -}) -` - const update = await hotUpdate(route('old'), { - style: 'webpack', - targetFramework: 'solid', - routeId: '/hmr', - updated: route('new'), - }) - expect(update.invalidated).toBe(false) - expect((update.live.loader as () => string)()).toBe('new') - expect((update.live.component as () => string)()).toBe('

    hmr

    ') - }) + // A Solid or Vue route hot-updated through the bundler's hot API: + // integration/route-hmr-browser-frameworks.test.ts (a real page). }) // A route the route file creates inside a function of the same file (a @@ -665,60 +638,8 @@ export const Route = (() => }, ) - // With automatic code splitting, and with the route HMR plugin alone. - describe.each([ - { style: 'vite', codeSplitting: true }, - { style: 'webpack', codeSplitting: true }, - { style: 'vite', codeSplitting: false }, - { style: 'webpack', codeSplitting: false }, - ] as const)( - 'with $style HMR (codeSplitting: $codeSplitting)', - ({ style, codeSplitting }) => { - it.each(Object.entries(factoryRoutes))( - 'a route built in %s is hot-updated in place keeping its component, or reported', - async (_, route) => { - const { result: update, reported } = await reporting( - () => - hotUpdate(route('old'), { - style, - codeSplitting, - routeId: '/made', - updated: route('new'), - }), - (update) => update.file, - ) - const updated = { - loader: await (await update.load('loader'))(), - component: (await update.load('component'))(), - } - const updatedInPlace = - !update.invalidated && - updated.loader === 'new' && - updated.component === '

    made

    ' - // (a) supported: split (with code splitting on) and hot-updated - // in place, keeping the component's identity. - const supported = - updatedInPlace && - update.live.component === update.previous.component && - (!codeSplitting || update.isLazy('component')) - // (b) reported, and still correct: updated in place, or the - // update invalidated (the page reloads with the new code). - const unsupportedAndReported = - reported && (update.invalidated || updatedInPlace) - expect( - supported || unsupportedAndReported, - JSON.stringify({ - invalidated: update.invalidated, - split: update.isLazy('component'), - identityKept: update.live.component === update.previous.component, - reported, - updated, - }), - ).toBe(true) - }, - ) - }, - ) + // An edit of such a route in development shows its new code: + // integration/route-hmr-browser-plugin.test.ts (a real page). // Bug: route options that only exist inside the factory (its parameters, // its locals) are split out of it as if they were module-level code: a 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 14fbe3aec4d..d2aea50bdeb 100644 --- a/packages/router-plugin/tests/code-splitter-route-options.test.ts +++ b/packages/router-plugin/tests/code-splitter-route-options.test.ts @@ -3,9 +3,7 @@ import { compileRouteModules, declaredNames, expectValidModules, - generatedRouteId, head, - hotUpdate, importSources, importedNames, loadRoute, @@ -534,32 +532,9 @@ describe('route factories', () => { expect((await route.load('component'))()).toBe('

    spaced

    ') }) - // Source: utils.test.ts routeFactoryCallCodeFilter (type arguments and - // whitespace before the call). - it.each([ - 'createRootRoute({', - 'createRootRoute ({', - 'createRootRouteWithContext()({', - 'createRootRouteWithContext<{ user: string }>()({', - 'createRootRouteWithContext <{ user: string }>()({', - ])('hot-update a root route created with `%s`', async (call) => { - const source = (version: number) => - `import { createRootRoute, createRootRouteWithContext } from '@tanstack/react-router' -export const Route = ${call} - staticData: { version: ${version} }, - component: () =>

    root

    , -}) -` - const update = await hotUpdate(source(1), { - codeSplitting: true, - routeId: await generatedRouteId('__root.tsx'), - updated: source(2), - }) - expect([update.previous.staticData, update.live.staticData]).toEqual([ - { version: 1 }, - { version: 2 }, - ]) - }) + // A root route created with `createRootRoute ({`, `createRootRouteWithContext + // <...>()({` and the like is hot-updated on a real page: + // integration/route-hmr-browser-plugin.test.ts. it('keep a root route created with a context type valid when compiled for HMR', async () => { const route = await loadRoute( diff --git a/packages/router-plugin/tests/handle-route-update.test.ts b/packages/router-plugin/tests/handle-route-update.test.ts index 042f8c6a0a8..56c485bc291 100644 --- a/packages/router-plugin/tests/handle-route-update.test.ts +++ b/packages/router-plugin/tests/handle-route-update.test.ts @@ -1,10 +1,13 @@ // @vitest-environment jsdom /** - * Route HMR applied to a live router: each test edits a route file, compiles - * it with the route HMR plugin and evaluates the output next to the router, - * so the HMR code the plugin emits (with the stringified - * `handleRouteUpdate`) updates the live route. + * Route HMR applied to a live router: each test calls the route HMR code + * the plugin emits (the stringified `handleRouteUpdate`, as the route + * module's HMR code calls it once the bundler gave it the edited module) + * with the route of an edited route file and a live router + * (`window.__TSR_ROUTER__`). No hot context, disposal or module selection + * is recreated here: what the bundler and the framework do with an edit is + * tested on a real page (integration/route-hmr-browser*.test.ts). */ import { @@ -24,12 +27,8 @@ import { HeadContent } from '../../react-router/src/HeadContent' import { RouterContextProvider } from '../../react-router/src/RouterProvider' import { Scripts } from '../../react-router/src/Scripts' import { Router as ReactRouter } from '../../react-router/src/router' -import { - evaluateModule, - generatedRouteId, - routeFile, - transformWithRouteHmrPlugin, -} from './regression-helpers' +import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' +import { getHandleRouteUpdateCode } from '../src/core/hmr' import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' vi.mock('@tanstack/router-core/isServer', async (importOriginal) => ({ @@ -132,53 +131,52 @@ 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` (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. + * The route option keys the React framework plugins keep stable across hot + * updates (the component options), as the compiler gives them to the + * emitted `handleRouteUpdate`. */ -async function hotUpdateRoute( +const stableRouteOptionKeys = [ + ...new Set( + ( + getFrameworkHmrCompilerPlugins({ targetFramework: 'react' }) ?? [] + ).flatMap((plugin) => plugin.getStableRouteOptionKeys?.() ?? []), + ), +] + +/** The emitted `handleRouteUpdate`, as the route HMR code calls it. */ +const handleRouteUpdate = new Function( + `return (${getHandleRouteUpdateCode(stableRouteOptionKeys)})`, +)() as (routeId: string, newRoute: AnyRoute) => void + +/** + * Applies a hot update of the route `routeId` to the live `router`: calls + * the emitted `handleRouteUpdate` with the route the edited route file + * creates (`createFileRoute(routeId)(options)`, or `createRootRoute(options)` + * for the root), as the route module's HMR code does once the bundler gave + * it the edited module. Returns that route, which holds the hot module's + * `Route` export. + */ +function hotUpdateRoute( router: RouterCore, routeId: string, - source: string, - stubs: Record> = {}, + options: Record = {}, ) { - const code = transformWithRouteHmrPlugin( - source, - { target: 'react' }, - { file: routeFile('hot'), routeId }, + const Route = asAnyRoute( + routeId === rootRouteId + ? new BaseRootRoute(options as never) + : new BaseRoute(options as never), ) const restoreWindow = withWindowRouter(router) try { - return await evaluateModule( - code, - { - '@tanstack/react-router': { - createFileRoute: () => (options: any) => new BaseRoute(options), - createRootRoute: (options: any) => new BaseRootRoute(options), - }, - ...stubs, - }, - { - importMeta: { hot: { data: {}, accept() {}, dispose() {} } }, - }, - ) + handleRouteUpdate(routeId, Route) } finally { restoreWindow() } + return { Route } } -/** - * An edited route file creating the route `routeId` with `options` (source - * text). - */ -const routeSource = (routeId: string, options = '{}', imports = '') => - `import { createFileRoute } from '@tanstack/react-router' -${imports} -export const Route = createFileRoute(${JSON.stringify(routeId)})(${options}) -` +/** The id the route tree gives the root route. */ +const rootRouteId = '__root__' function getProcessedTreeRouteForPath( router: RouterCore, @@ -188,7 +186,7 @@ function getProcessedTreeRouteForPath( } describe('handleRouteUpdate', () => { - it('keeps routesByPath pointed at an index route when a pathless layout updates', async () => { + it('keeps routesByPath pointed at an index route when a pathless layout updates', () => { const rootRoute = new BaseRootRoute({}) const parentRoute = new BaseRoute({ getParentRoute: () => asAnyRoute(rootRoute), @@ -217,11 +215,7 @@ describe('handleRouteUpdate', () => { expect(router.routesByPath[key]?.id).toBe(indexRoute.id) - await hotUpdateRoute( - router, - pathlessRoute.id, - routeSource(pathlessRoute.id, '{ staticData: { hot: true } }'), - ) + hotUpdateRoute(router, pathlessRoute.id, { staticData: { hot: true } }) expect((router.routesById as any)[pathlessRoute.id].options).toMatchObject({ staticData: { hot: true }, }) @@ -232,7 +226,7 @@ describe('handleRouteUpdate', () => { ) }) - it('keeps an index route winning the trimmed path key when its parent updates', async () => { + it('keeps an index route winning the trimmed path key when its parent updates', () => { const rootRoute = new BaseRootRoute({}) const parentRoute = new BaseRoute({ getParentRoute: () => asAnyRoute(rootRoute), @@ -250,11 +244,7 @@ describe('handleRouteUpdate', () => { expect(router.routesByPath[key]?.id).toBe(indexRoute.id) - await hotUpdateRoute( - router, - parentRoute.id, - routeSource(parentRoute.id, '{ staticData: { hot: true } }'), - ) + hotUpdateRoute(router, parentRoute.id, { staticData: { hot: true } }) expect((router.routesById as any)[parentRoute.id].options).toMatchObject({ staticData: { hot: true }, }) @@ -265,7 +255,7 @@ describe('handleRouteUpdate', () => { ) }) - it('refreshes matching data when params.parse changes', async () => { + it('refreshes matching data when params.parse changes', () => { const rootRoute = new BaseRootRoute({}) const itemRoute = new BaseRoute({ getParentRoute: () => asAnyRoute(rootRoute), @@ -281,56 +271,42 @@ describe('handleRouteUpdate', () => { expect(router.getMatchedRoutes('/items/abc')[2]?.id).toBeUndefined() - await hotUpdateRoute( - router, - itemRoute.id, - routeSource( - itemRoute.id, - `{ - params: { parse: ({ itemId }) => ({ itemId }) }, -}`, - ), - ) + hotUpdateRoute(router, itemRoute.id, { + params: { parse: ({ itemId }: { itemId: string }) => ({ itemId }) }, + }) 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. + // not include: the update leaves the router as it is, and the live tree + // keeps navigating. 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('/hot', `{ loader: () => 'x' }`), - { target: 'react' }, - { file: routeFile('hot'), routeId: '/hot' }, + const homeRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(rootRoute), + path: '/home', + loader: () => 'home', + }) + const router = createClientTestRouter( + rootRoute.addChildren([asAnyRoute(homeRoute)]), + '/', ) - 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() - } + await router.load() + expect(() => + hotUpdateRoute(router, '/hot', { loader: () => 'x' }), + ).not.toThrow() + expect(Object.keys(router.routesById).sort()).toEqual( + [rootRoute.id, homeRoute.id].sort(), + ) + await router.navigate({ to: '/home' }) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: homeRoute.id, + loaderData: 'home', + }) }) - it('hydrates the hot module route export with generated route tree state', async () => { + it('hydrates the hot module route export with generated route tree state', () => { const rootRoute = new BaseRootRoute({}) const itemRoute = new BaseRoute({ getParentRoute: () => asAnyRoute(rootRoute), @@ -339,11 +315,7 @@ describe('handleRouteUpdate', () => { const routeTree = rootRoute.addChildren([asAnyRoute(itemRoute)]) const router = createTestRouter(routeTree) - const { Route } = await hotUpdateRoute( - router, - itemRoute.id, - routeSource(itemRoute.id), - ) + const { Route } = hotUpdateRoute(router, itemRoute.id) const newRoute = Route as unknown as AnyRoute expect(newRoute.id).toBe(itemRoute.id) @@ -377,11 +349,7 @@ describe('handleRouteUpdate', () => { await router.loadRouteChunk(itemRoute) expect(liveOptions().component).toBe(LazyComponent) - await hotUpdateRoute( - router, - itemRoute.id, - routeSource(itemRoute.id, `{ loader: () => 'edited' }`), - ) + hotUpdateRoute(router, itemRoute.id, { loader: () => 'edited' }) await router.loadRouteChunk(itemRoute) expect(liveOptions().loader()).toBe('edited') expect(liveOptions().component).toBe(LazyComponent) @@ -406,7 +374,7 @@ describe('handleRouteUpdate', () => { ]) expect(router.state.matches[1]!.loaderData).toBe('stale loader data') - await hotUpdateRoute(router, hotRoute.id, routeSource(hotRoute.id)) + hotUpdateRoute(router, hotRoute.id) await vi.waitFor(() => { expect(router.state.matches.map((match) => match.routeId)).toEqual([ rootRoute.id, @@ -435,15 +403,7 @@ describe('handleRouteUpdate', () => { const oldRefresh = router.invalidate() await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledTimes(2)) - await hotUpdateRoute( - router, - await generatedRouteId('__root.tsx'), - `import { createRootRoute } from '@tanstack/react-router' -import { newLoader } from './loader' -export const Route = createRootRoute({ loader: newLoader }) -`, - { './loader': { newLoader } }, - ) + hotUpdateRoute(router, rootRouteId, { loader: newLoader }) await vi.waitFor(() => { expect(router.state.matches[0]?.loaderData).toBe('hot') @@ -480,16 +440,7 @@ export const Route = createRootRoute({ loader: newLoader }) const preload = router.preloadRoute({ to: '/hot' }) await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledOnce()) - await hotUpdateRoute( - router, - hotRoute.id, - routeSource( - hotRoute.id, - '{ loader: newLoader }', - `import { newLoader } from './loader'`, - ), - { './loader': { newLoader } }, - ) + hotUpdateRoute(router, hotRoute.id, { loader: newLoader }) resolveOldLoader('obsolete') await preload @@ -525,17 +476,12 @@ export const Route = createRootRoute({ loader: newLoader }) }) expect(router.state.matches[1]!.context).toMatchObject({ source: 'old' }) - await hotUpdateRoute( - router, - itemRoute.id, - routeSource( - itemRoute.id, - `{ - params: { parse: ({ itemId }) => ({ itemId, parser: 'new' }) }, - context: () => ({ source: 'new' }), -}`, - ), - ) + hotUpdateRoute(router, itemRoute.id, { + params: { + parse: ({ itemId }: { itemId: string }) => ({ itemId, parser: 'new' }), + }, + context: () => ({ source: 'new' }), + }) await vi.waitFor(() => { expect(router.state.matches[1]!.routeId).toBe(itemRoute.id) @@ -571,11 +517,9 @@ export const Route = createRootRoute({ loader: newLoader }) inheritedSource: 'old', }) - await hotUpdateRoute( - router, - parentRoute.id, - routeSource(parentRoute.id, `{ context: () => ({ source: 'new' }) }`), - ) + hotUpdateRoute(router, parentRoute.id, { + context: () => ({ source: 'new' }), + }) await vi.waitFor(() => { expect(router.state.matches[2]?.context).toMatchObject({ @@ -610,7 +554,7 @@ export const Route = createRootRoute({ loader: newLoader }) expect(beforeLoad).toHaveBeenCalledTimes(1) expect(loader).toHaveBeenCalledTimes(1) - await hotUpdateRoute(router, hotRoute.id, routeSource(hotRoute.id)) + hotUpdateRoute(router, hotRoute.id) await router.navigate({ to: '/hot' }) const match = router.state.matches.find( @@ -691,7 +635,7 @@ export const Route = createRootRoute({ loader: newLoader }) ).not.toBeNull() await act(async () => { - await hotUpdateRoute(router, hotRoute.id, routeSource(hotRoute.id)) + hotUpdateRoute(router, hotRoute.id) await vi.waitFor(() => { expect(router.state.matches[1]!.routeId).toBe(hotRoute.id) expect(router.state.matches[1]!.meta).toBeUndefined() diff --git a/packages/router-plugin/tests/integration/plugin-options.test.ts b/packages/router-plugin/tests/integration/plugin-options.test.ts index 04cf2d269ba..cbd050ea4dd 100644 --- a/packages/router-plugin/tests/integration/plugin-options.test.ts +++ b/packages/router-plugin/tests/integration/plugin-options.test.ts @@ -91,179 +91,38 @@ describe('plugin.vite.environmentName', () => { }) // The webpack and Rspack entries use their bundler's HMR API whatever -// `plugin.hmr.style` says (src/rspack.ts, src/webpack.ts). +// `plugin.hmr.style` says (src/rspack.ts, src/webpack.ts): an edit with +// `plugin.hmr.style: 'vite'` on Rsbuild hot-updates the page +// (integration/route-hmr-browser-plugin.test.ts). With `addHmr: false`, a +// development build has no route HMR code at all. describe.each(['rspack', 'webpack'] as const)('%s entry', (bundler) => { - /** - * Development client build with the bundler's HMR plugin, `addHmr`, and - * `plugin.hmr.style: 'vite'`. - */ - async function hotDevelopmentBuild( - addHmr: boolean | undefined, - routes?: (config: RouterPluginOptions) => Array, - ) { - return build({ - bundler, - env: 'client', - mode: 'development', - pluginOptions: { - plugin: { hmr: { style: 'vite' } }, - codeSplittingOptions: { addHmr }, - }, - routerPlugins: routes, - extraPlugins: () => [ - { - apply(compiler: any) { - new compiler.webpack.HotModuleReplacementPlugin().apply(compiler) - }, - }, - ], - files: routeApp(route(), { env: 'client' }), - }) - } - - /** - * The plugins the entry is made of, for this bundler, without the entry's - * own handling of the options. - */ - async function unforcedPlugins() { - const { createRspackPlugin, createWebpackPlugin } = await import('unplugin') - const { createRouterGeneratorPlugin } = - await import('../../src/core/router-generator-plugin') - const { createRouterCodeSplitterPlugin } = - await import('../../src/core/router-code-splitter-plugin') - const { createRouterPluginContext } = - await import('../../src/core/router-plugin-context') - const create: ( - factory: (options: any) => any, - ) => (options: any) => unknown = - bundler === 'rspack' ? createRspackPlugin : createWebpackPlugin - return (config: RouterPluginOptions) => { - const context = createRouterPluginContext() - return [ - create((options) => createRouterGeneratorPlugin(options, context))( - config, - ), - create((options) => createRouterCodeSplitterPlugin(options, context))( - config, - ), - ] - } - } - - /** - * Whether the route modules accept hot updates through the hot module API - * of webpack and Rspack (`import.meta.webpackHot` / `module.hot`), and - * whether they use Vite's (`import.meta.hot`, undefined in these bundles). - */ - const hmrApi = (app: BuildResult) => ({ - accepts: app.where(/\bhot\.accept\(/) !== 'nowhere', - vite: app.where(/\bimport\.meta\.hot\b/) !== 'nowhere', - }) - test( - 'makes routes accept hot updates through the bundler even when plugin.hmr.style is vite', + 'with addHmr: false, a development build with the HMR plugin has no route HMR code', async () => { - const [entry, withoutHmr, unforced] = await Promise.all([ - hotDevelopmentBuild(undefined), - hotDevelopmentBuild(false), - hotDevelopmentBuild(undefined, await unforcedPlugins()), - ]) - // Controls: no route HMR code; Vite's HMR API when the style is not - // forced - expect(hmrApi(withoutHmr)).toEqual({ accepts: false, vite: false }) - expect(hmrApi(unforced).vite).toBe(true) - expect(hmrApi(entry)).toEqual({ accepts: true, vite: false }) - }, - timeout, - ) -}) - -describe('separate generator and code splitter entries', () => { - const entries: Record< - (typeof bundlers)[number], - () => Promise<(config: RouterPluginOptions) => Array> - > = { - vite: async () => { - const vite = await import('../../src/vite') - return (config) => [ - vite.tanstackRouterGenerator(config), - vite.tanStackRouterCodeSplitter(config), - ] - }, - rspack: async () => { - const rspack = await import('../../src/rspack') - return (config) => [ - rspack.TanStackRouterGeneratorRspack(config), - rspack.TanStackRouterCodeSplitterRspack(config), - ] - }, - webpack: async () => { - const webpack = await import('../../src/webpack') - return (config) => [ - webpack.TanStackRouterGeneratorWebpack(config), - webpack.TanStackRouterCodeSplitterWebpack(config), - ] - }, - } - - test.each(bundlers)( - 'generate the route tree and split like the combined plugin (%s)', - async (bundler) => { - const { app, html } = await buildRoute( + const app = await build({ bundler, - {}, - { routes: await entries[bundler]() }, - ) - expect(app.where('MARK_COMPONENT')).toBe('lazy') - expect(app.where('MARK_ROUTE')).toBe('eager') - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) + env: 'client', + mode: 'development', + pluginOptions: { + plugin: { hmr: { style: 'vite' } }, + codeSplittingOptions: { addHmr: false }, + }, + extraPlugins: () => [ + { + apply(compiler: any) { + new compiler.webpack.HotModuleReplacementPlugin().apply(compiler) + }, + }, + ], + files: routeApp(route(), { env: 'client' }), + }) + // The bundler's own HMR runtime stays; no module accepts hot updates + // of its own, and nothing uses Vite's hot API. + expect({ + accepts: app.where(/\bhot\.accept\(/), + vite: app.where(/\bimport\.meta\.hot\b/), + }).toEqual({ accepts: 'nowhere', vite: 'nowhere' }) }, timeout, ) }) - -// The package root exports the generator's unplugin factory, for bundler -// adapters of its own. -// Source: r5-gaps N12 (rp:1935: the factory returned no plugin). -test( - 'the unplugin factory of the generator from the package root generates the route tree (vite)', - async () => { - const { createUnplugin } = await import('unplugin') - const { unpluginRouterGeneratorFactory } = await import('../../src') - const { app, html } = await buildRoute( - 'vite', - {}, - { - routes: (config) => [ - createUnplugin(unpluginRouterGeneratorFactory).vite(config), - ], - }, - ) - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) - }, - timeout, -) - -test( - 'the deprecated TanStackRouterVite generates the route tree and splits like tanstackRouter', - async () => { - const { TanStackRouterVite } = await import('../../src/vite') - const { app, html } = await buildRoute( - 'vite', - {}, - { routes: (config) => [TanStackRouterVite(config)] }, - ) - expect(app.where('MARK_COMPONENT')).toBe('lazy') - expect(html).toContain('MARK_COMPONENT') - expect(await read(app, 'routeTree.gen.ts')).toContain( - "from './routes/index'", - ) - }, - timeout, -) 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 7aa1f79fb3b..d939cd3166a 100644 --- a/packages/router-plugin/tests/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/known-bugs-route-hmr.test.ts @@ -6,23 +6,17 @@ * so that React Refresh can patch it in place; a component React Refresh does * not register keeps rendering the old code until a full reload. Pins of what * the page shows run a real dev server and a real browser page - * (`route-hmr-page.ts`). + * (`route-hmr-page.ts`): integration/known-bugs-route-hmr.test.ts. */ -import react from '@vitejs/plugin-react' -import vueJsx from '@vitejs/plugin-vue-jsx' -import solid from 'vite-plugin-solid' import { describe, expect, test } from 'vitest' import { evaluateModule, expectValidModules, head, - hotUpdate, loadRoute, routeSpecifier, transformWithRouteHmrPlugin, } from './regression-helpers' -import { editRouteFile } from './vite-hot-update' -import type { PluginOption } from 'vite' describe('React Refresh registration', () => { // Inline components that are not plain functions (`memo(...)`, ...): @@ -350,22 +344,13 @@ export const Route = createFileRoute('/')({ component: page })`, /** * Compiles a route with the route HMR plugin (automatic code splitting - * off), evaluates it and returns its route. `react-error-boundary`'s - * `ErrorBoundary` renders its `FallbackComponent`, as when its children - * throw. + * off), evaluates it and returns its route. */ async function evaluateUnsplitRoute(code: string) { const { Route } = await evaluateModule(transformWithRouteHmrPlugin(code), { '@tanstack/react-router': { createFileRoute: () => (options: unknown) => ({ options }), }, - 'react-error-boundary': { - ErrorBoundary: ({ - FallbackComponent, - }: { - FallbackComponent: (props: object) => unknown - }) => FallbackComponent({}), - }, }) return Route as unknown as { options: Record } } @@ -390,15 +375,14 @@ export const Route = createFileRoute('/')({ expect((await route.load('component'))()).toBe('

    data

    ') }) - /** A lowercase error fallback, and an import of `ErrorBoundary`. */ - const errorFallback = `${head}import { ErrorBoundary } from 'react-error-boundary' -const errorFallback = () =>

    error

    ` - - // Control for the hoisted inline component pin below: an option that is - // not an inline component, reading a lowercase component a later option - // passes. + // Control for the hoisted inline component pin (a real page: + // integration/known-bugs-route-hmr.test.ts "an inline pending component + // hoisted before a renamed lowercase component"): an option that is not + // an inline component, reading a lowercase component a later option + // passes, gets that component. test('an option reading a lowercase component a later option passes gets that component', async () => { - const Route = await evaluateUnsplitRoute(`${errorFallback} + const Route = + await evaluateUnsplitRoute(`${head}const errorFallback = () =>

    error

    export const Route = createFileRoute('/')({ staticData: { errorFallback }, errorComponent: errorFallback, @@ -408,217 +392,4 @@ export const Route = createFileRoute('/')({ ) expect(Route.options.errorComponent()).toBe('

    error

    ') }) - - // Control for the hoisted inline component pin below (same harness). - test('an inline component reading a lowercase component renders it', async () => { - const Route = await evaluateUnsplitRoute(`${errorFallback} -export const Route = createFileRoute('/')({ - pendingComponent: () => ( - -

    loading

    -
    - ), -})`) - expect(Route.options.pendingComponent()).toBe('

    error

    ') - }) - - // Bug: React HMR hoists inline component options into declarations it - // inserts after visiting every option, and renames a lowercase component - // passed to a later option. The rename only reaches nodes attached to the - // program, so a hoisted component still reads the old name. - // Impact: in development, the hoisted component throws when it renders - // (ReferenceError: errorFallback is not defined). - // Fix: rename before hoisting (or insert hoisted declarations immediately), - // or rename through the analyzer's binding references. - test.fails( - 'an inline component hoisted before a renamed lowercase component renders it', - async () => { - const Route = await evaluateUnsplitRoute(`${errorFallback} -export const Route = createFileRoute('/')({ - pendingComponent: () => ( - -

    loading

    -
    - ), - errorComponent: errorFallback, -})`) - expect(Route.options.pendingComponent()).toBe('

    error

    ') - expect(Route.options.errorComponent()).toBe('

    error

    ') - }, - ) -}) - -describe('route updates', () => { - /** - * Compiles a route file at `/` with the route HMR plugin for `style` and - * runs a hot update (`hotUpdate`). Returns whether the update reaches the - * page: the live route now has the new module's `option`, or the module - * asked the bundler for a full reload (`hot.invalidate()`). - */ - async function updatesOption( - code: string, - style: 'vite' | 'webpack', - option = 'loader', - ) { - const update = await hotUpdate(code, { style }) - return ( - update.invalidated || update.live[option] === update.created[1]![option] - ) - } - - // Control for the route update pins below (same harness). - test.each(['vite', 'webpack'] as const)( - '%s: a hot update gives the live route the new options', - async (style) => { - expect( - await updatesOption( - `${head}export const Route = createFileRoute('/')({ - loader: () => 'data', - component: () =>

    page

    , -})`, - style, - ), - ).toBe(true) - }, - ) - - // Bug: the injected HMR code reads the module's route through the - // identifier `Route`, but a route may be declared under another name and - // exported as `Route` (`export { MyRoute as Route }`). - // Impact: webpack and Rspack apps throw "Route is not defined" when the - // route module loads in development; Vite apps throw on the first hot - // update of the route. - test.fails.each(['vite', 'webpack'] as const)( - '%s: a route exported under another local name is hot-updated', - async (style) => { - expect( - await updatesOption( - `${head}const MyRoute = createFileRoute('/')({ - loader: () => 'data', - component: () =>

    page

    , -}) -export { MyRoute as Route }`, - style, - ), - ).toBe(true) - }, - ) -}) - -describe('Vite HMR boundaries', () => { - /** A route file of `target` whose component renders `text`. */ - const route = - (target: 'react' | 'solid' | 'vue') => - ( - text: string, - ) => `import { createFileRoute } from '@tanstack/${target}-router' -export const Route = createFileRoute('/')({ - loader: () => '${text}', - component: Page, -}) -function Page() { - return
    ${text}
    -}` - - /** - * Edits the route file (`route` of `target`) in a dev server with the - * framework's Vite plugins, after the page loaded it and, when split, its - * component (`editRouteFile`). - */ - const edit = ( - target: 'react' | 'solid' | 'vue', - autoCodeSplitting: boolean, - plugins: Array, - options: { file?: string; route?: (text: string) => string } = {}, - ) => - editRouteFile({ - target, - autoCodeSplitting, - plugins, - file: options.file, - route: options.route ?? route(target), - loaded: autoCodeSplitting ? ['?tsr-split=component'] : [], - }) - - // Control for the pins below (same harness): with `@vitejs/plugin-react`, - // React Refresh makes a React route module an HMR boundary. - // Source: gap H6a. - test.each([true, false])( - 'an edit of a React route module is a hot update with @vitejs/plugin-react (autoCodeSplitting: %s)', - async (autoCodeSplitting) => { - const served = await edit('react', autoCodeSplitting, [react()]) - expect(served.messages).toEqual([ - expect.stringMatching(/^client: update /), - ]) - expect(served.hotUpdate).toBe(true) - }, - 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 served = await edit('react', false, [react()], { - route: (text) => `import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/')({ - loader: () => '${text}', - component: () =>

    ${text}

    , -})`, - }) - expect(served.hotUpdate).toBe(true) - }, 30_000) - - // Control (same harness): with `vite-plugin-solid`, Solid Refresh makes a - // route module that declares its component an HMR boundary. - test('an edit of a Solid route module that declares its component is a hot update with vite-plugin-solid (autoCodeSplitting: false)', async () => { - const served = await edit('solid', false, [solid()]) - expect(served.hotUpdate).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 served = await edit('react', false, [react()], { - file: 'routes/index.ts', - route: ( - text, - ) => `import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/')({ loader: () => '${text}' })`, - }) - expect(served.hotUpdate).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(`. - // A route module is then never an HMR boundary of its own: the update - // propagates to the route tree and the app entry, and the page reloads. - // React apps are not affected because React Refresh makes route modules - // boundaries (control above); Solid Refresh only does for modules that - // declare components (control above: not a split route module), and Vue - // JSX only for `defineComponent` components. - // Impact: every edit of a Solid route file with automatic code splitting, - // and of any Vue route file, reloads the page in Vite dev. - // Any boundary (the route module or an importer that accepts it) passes. - test.fails.each([ - { target: 'solid', autoCodeSplitting: true, plugin: () => solid() }, - { target: 'vue', autoCodeSplitting: true, plugin: () => vueJsx() }, - { target: 'vue', autoCodeSplitting: false, plugin: () => vueJsx() }, - ] as const)( - 'an edit of a $target route module is a hot update with its framework plugin (autoCodeSplitting: $autoCodeSplitting)', - async ({ target, autoCodeSplitting, plugin }) => { - const served = await edit(target, autoCodeSplitting, [plugin()]) - expect(served.hotUpdate).toBe(true) - }, - 30_000, - ) }) 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 17d31d10c64..1909e304ce3 100644 --- a/packages/router-plugin/tests/react-refresh-route-components.test.ts +++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts @@ -258,34 +258,10 @@ export const Route = createFileRoute('/')({ }) }) -// Split route components written inline in a memo / forwardRef call: -// route-hmr-browser.test.ts (a real page). - -describe('React Refresh hoisting of inline route components', () => { - it('hoists the root route component but not the components of routes a function creates', async () => { - const { reference } = compileWithCodeSplitting(` -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`: its routes' components must - // stay in the function to read it. - const { exports } = await evaluateRouteModule(reference!) - const child = (exports.makeChild as (label: string) => any)('x') - expect(child.options.component()).toBe('
    x
    ') - // React Refresh can only register components bound to top-level names. - await expectRegisteredRouteOption(reference!, 'component') - }) - - // The components of the module's Route when another route is created - // first: route-hmr-browser.test.ts (a real page). -}) +// Split route components written inline in a memo / forwardRef call, and +// inline root route components: route-hmr-browser.test.ts (a real page). A +// route file creates one route (ruling 2026-10-18): no test of components +// of other routes a function of the file creates. // A route module stays a React Refresh boundary in Vite: // route-hmr-browser.test.ts (a real page). diff --git a/packages/router-plugin/tests/regression-helpers.test.ts b/packages/router-plugin/tests/regression-helpers.test.ts index a31e70d9673..c97681f4bf5 100644 --- a/packages/router-plugin/tests/regression-helpers.test.ts +++ b/packages/router-plugin/tests/regression-helpers.test.ts @@ -1,8 +1,8 @@ /** * Controls for the regression harness itself: module discovery, lazy * evaluation and output validation (module linking and module validation: - * `@tanstack/compiler-test-utils`), and the route HMR page - * (`route-hmr-page.ts`). + * `@tanstack/compiler-test-utils`). The route HMR page (`route-hmr-page.ts`): + * integration/regression-helpers.test.ts. */ import { describe, expect, it } from 'vitest' import { routerDevServer } from './dev-server' @@ -11,9 +11,7 @@ import { componentChunk, expectRegisteredRouteOption, expectValidModules, - generatedRouteId, head, - hotUpdate, importRequests, importSources, loadRoute, @@ -273,69 +271,6 @@ export const Route = createFileRoute('/')({ }) }) -describe('hotUpdate', () => { - const route = (loader: string) => `${head}function Page() { - return

    page

    -} -export const Route = createFileRoute('/')({ - loader: () => '${loader}', - component: Page, -})` - - it.each(['vite', 'webpack'] as const)( - '%s: the live route gets the options of the updated module', - async (style) => { - const update = await hotUpdate(route('old'), { - style, - updated: route('new'), - }) - expect((update.previous.loader as () => string)()).toBe('old') - expect((update.live.loader as () => string)()).toBe('new') - expect(update.created).toHaveLength(2) - expect(update.invalidated).toBe(false) - }, - ) - - it.each(['vite', 'webpack'] as const)( - '%s with code splitting: the split component stays lazy and the new loader applies', - async (style) => { - const update = await hotUpdate(route('old'), { - style, - codeSplitting: true, - updated: route('new'), - }) - expect((update.live.loader as () => string)()).toBe('new') - expect(update.isLazy('component')).toBe(true) - expect((await update.load('component'))()).toBe('

    page

    ') - }, - ) - - it('a root route is updated as the root of the route tree', async () => { - const root = ( - loader: string, - ) => `import { createRootRoute } from '@tanstack/react-router' -export const Route = createRootRoute({ loader: () => '${loader}' })` - const update = await hotUpdate(root('old'), { - routeId: await generatedRouteId('__root.tsx'), - updated: root('new'), - }) - expect((update.live.loader as () => string)()).toBe('new') - }) - - it('a Solid route is updated with the Solid router package', async () => { - const solid = ( - loader: string, - ) => `import { createFileRoute } from '@tanstack/solid-router' -export const Route = createFileRoute('/')({ loader: () => '${loader}' })` - const update = await hotUpdate(solid('old'), { - style: 'webpack', - targetFramework: 'solid', - updated: solid('new'), - }) - expect((update.live.loader as () => string)()).toBe('new') - }) -}) - describe('routerDevServer', () => { it('serves a route of another target with the extra plugins after the router plugin', async () => { const stubId = '\0solid-router-stub' diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index f0ee95235c5..63a9e9e91c6 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -3,8 +3,10 @@ * a route file through the code-splitter plugin the way a bundler does, * follow the imports of its output to every module it emits, evaluate those * modules like a runtime, and inspect an emitted module. The plugin host - * (`plugin-host.ts`), `loadRoute` (`lazy-tracking.ts`) and `hotUpdate` - * (`hmr-model.ts`) live in their own modules and are re-exported here. + * (`plugin-host.ts`) and `loadRoute` (`lazy-tracking.ts`) live in their own + * modules and are re-exported here. Route HMR has no in-process model: what + * the page shows after an edit is tested with `openRouteHmrPage` + * (`route-hmr-page.ts`). */ import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' @@ -12,15 +14,15 @@ import * as React from 'react' import { parseSync, transformWithOxc } from 'vite' import { expect } from 'vitest' import { build, routeApp } from './build-harness' -import type { CodeSplitGroupings } from '../src/core/constants' -import type { ESTree } from 'vite' import { refreshScope, transformWithReactRefresh, withRouterStubs, } from './lazy-tracking' -import type { Stubs } from './lazy-tracking' import { routeModuleId } from './plugin-host' +import type { CodeSplitGroupings } from '../src/core/constants' +import type { ESTree } from 'vite' +import type { Stubs } from './lazy-tracking' export { webpackCompiler, @@ -48,8 +50,6 @@ export { loadRoute, createRouteLoader, } from './lazy-tracking' -export { hotUpdate } from './hmr-model' -export type { HotUpdateOptions } from './hmr-model' export { declaredNames } from '@tanstack/compiler-test-utils/validate-module' diff --git a/packages/router-plugin/tests/route-hmr.test.ts b/packages/router-plugin/tests/route-hmr.test.ts index c54d13027f3..3229b47fade 100644 --- a/packages/router-plugin/tests/route-hmr.test.ts +++ b/packages/router-plugin/tests/route-hmr.test.ts @@ -1,37 +1,25 @@ /** - * Route HMR: editing a route file updates the live route in place, keeping - * the identity of its components so that the framework's refresh patches - * them without a remount, and nothing of it ships when HMR is off. - * - * What the page shows after an edit is tested in a real dev server and a - * real browser page (`route-hmr-browser.test.ts`). Here: the route HMR code - * the router plugin emits, run on its own with the bundler's hot API - * (`hotUpdate`), compiled modules evaluated with `loadRoute` / - * `evaluateModule`, and what the route HMR plugin serves in a real Vite dev - * server. + * Route HMR: the modules the compiler emits for development evaluate and + * render with or without a hot API, keep no React code for Solid and Vue, + * and carry no HMR code when HMR is off. What the page shows after an edit + * is tested in a real dev server and a real browser page + * (integration/route-hmr-browser*.test.ts); here: compiled modules + * evaluated with `loadRoute` / `evaluateModule`, and what the route HMR + * plugin serves to each Vite environment. */ import { SourceMap } from 'node:module' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it, onTestFinished, test, vi } from 'vitest' -import { - createDevServer, - evaluateServed, -} from '@tanstack/compiler-test-utils/dev-server' -import { rootRouteOf, routerDevServer, routerPackageStub } from './dev-server' +import { describe, expect, it, test, vi } from 'vitest' +import { routerDevServer, routerPackageStub } from './dev-server' import { compileRouteModules, evaluateModule, expectValidModules, head, - hotUpdate, importSources, loadRoute, } 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, @@ -53,101 +41,45 @@ function PostsErrorComponent() { return
    Error loading posts ${version}
    }` -/** The value a loader of the live route returns, split or not. */ -async function loaderData(update: Awaited>) { - return (await update.load('loader')).call(undefined) -} - -describe('the route HMR code of a Solid or Vue route', () => { - // Source: add-hmr snapshot fixture solid/arrow-function. Solid and Vue - // have no React Refresh to patch a kept component in place: the live route - // gets the new component (webpack / Rspack style, the route module runs - // again). What the page shows after an edit: route-hmr-browser.test.ts. +describe('the modules of a Solid or Vue route compiled with HMR', () => { + // What the page shows after an edit, with the framework's own refresh: + // integration/route-hmr-browser-frameworks.test.ts. Here: the emitted + // modules import nothing of React (source: add-hmr snapshot fixture + // solid/arrow-function). test.each([ - { framework: 'solid', codeSplitting: true }, - { framework: 'vue', codeSplitting: true }, - { framework: 'solid', codeSplitting: false }, - { framework: 'vue', codeSplitting: false }, + { framework: 'solid', style: 'vite' }, + { framework: 'solid', style: 'webpack' }, + { framework: 'vue', style: 'vite' }, + { framework: 'vue', style: 'webpack' }, ] as const)( - '$framework (codeSplitting: $codeSplitting): applies the new options and component with the router package of the framework and no React code', - async ({ framework, codeSplitting }) => { - const route = (version: string) => + '$framework ($style style): import the router package of the framework and no React code', + ({ framework, style }) => { + const { modules } = compileRouteModules( fileRoute( - version, + 'data', 'component: PostsComponent,', `function PostsComponent() { - return
    posts ${version}
    + return
    posts
    }`, framework, - ) - const update = await hotUpdate(route('old'), { - style: 'webpack', - targetFramework: framework, - codeSplitting, - updated: route('new'), - }) - expect(update.invalidated).toBe(false) - expect(await loaderData(update)).toBe('new') - expect((await update.load('component'))()).toBe('
    posts new
    ') - // No React code. - expect(Object.keys(update.window)).toEqual(['__TSR_ROUTER__']) - for (const style of ['vite', 'webpack'] as const) { - const { modules } = compileRouteModules(route('old'), { + ), + { targetFramework: framework, hmr: true, pluginOptions: { plugin: { hmr: { style } } }, - }) - for (const code of Object.values(modules)) { - expect(importSources(code)).not.toContain('react') - expect(importSources(code)).not.toContain('@tanstack/react-router') - } + }, + ) + expect(importSources(modules.reference!)).toContain( + `@tanstack/${framework}-router`, + ) + for (const code of Object.values(modules)) { + expect(importSources(code)).not.toContain('react') + expect(importSources(code)).not.toContain('@tanstack/react-router') } }, ) }) -describe('the route HMR code of a route whose options a factory call builds', () => { - // webpack and Rspack run the route module again: its HMR code applies the - // options the new factory call built. (In Vite, the edit may reload the - // page instead; either way the edit shows: route-hmr-browser.test.ts.) - it('webpack: applies the new options', async () => { - const factoryRoute = ( - data: string, - ) => `import { createFileRoute } from '@tanstack/react-router' -import { makeOptions } from './options' -export const Route = createFileRoute('/')(makeOptions('${data}'))` - const update = await hotUpdate(factoryRoute('old'), { - style: 'webpack', - updated: factoryRoute('new'), - stubs: { - './options': { - makeOptions: (data: string) => ({ loader: () => data }), - }, - }, - }) - expect(update.invalidated).toBe(false) - expect(await loaderData(update)).toBe('new') - }) -}) - -describe('the route a hot update applies to', () => { - // Source: add-hmr "prefers the current generated route id over stale Vite - // hot data". The route file was registered under another route id when - // its previous version loaded (e.g. before a route configuration change); - // the hot data its HMR code wrote still names that route. - // Vite only: the webpack HMR code deliberately reads the previous route id - // from hot data (webpack-adapter.ts). - it('vite: is the route the file is registered under now, not the one it was registered under before', async () => { - const route = (data: string) => fileRoute(data, '') - const update = await hotUpdate(route('old'), { - codeSplitting: true, - previousRouteId: '/stale', - updated: route('new'), - }) - expect(await loaderData(update)).toBe('new') - }) -}) - describe('route modules compiled for the dev server', () => { const router = { createFileRoute: () => (options: unknown) => ({ options }), @@ -319,7 +251,9 @@ function RootComponent() { async ({ route, targetFramework }) => { const loaded = await loadRoute(route, { targetFramework, ...options }) for (const [name, code] of Object.entries(loaded.modules)) { - expect(code, name).not.toMatch(/import\.meta\.(?:webpackHot|hot)\b/) + expect(code, name).not.toMatch( + /import\.meta\.(?:webpackHot|hot)\b|\bmodule\.hot\b/, + ) } }, ) @@ -369,23 +303,14 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => timeout, ) + // An edit of a `.ts` route file, the environment name, `plugin.hmr.style` + // alone, plugin options given as a function and a `tsr.config.json` + // target, on a real page: integration/route-hmr-browser-plugin.test.ts + // and known-bugs-route-hmr.test.ts. Here: the environment + // `plugin.vite.environmentName` excludes gets no route HMR code in any + // module of the served route. 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', + 'serves no route HMR code to an environment plugin.vite.environmentName does not name', async () => { const server = await routerDevServer({ pluginOptions: { @@ -398,113 +323,17 @@ export const Route = createFileRoute('/')({ loader: () => 'x', component: () => 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 also - // get React Refresh's, which installs its hooks on `window` when the - // module runs in the page. - test.each([ - { target: 'solid', reactRefreshHooks: false }, - { target: 'react', reactRefreshHooks: true }, - ] as const)( - 'compiles for the target of a tsr.config.json in the Vite root ($target)', - async ({ target, reactRefreshHooks }) => { - const server = await routerDevServer({ - pluginOptions: null, - plugins: [routerPackageStub(target)], - files: { - 'tsr.config.json': JSON.stringify({ - target, - routesDirectory: './routes', - generatedRouteTree: './routeTree.gen.ts', - }), - 'routes/index.tsx': `import { createFileRoute } from '@tanstack/${target}-router' -export const Route = createFileRoute('/')({ loader: () => 'x', component: () =>

    x

    })`, - }, + const served = await server.load('/routes/index.tsx', { + environment: 'ssr', + skip: /^\/@id\/|\/node_modules\/|^\/@vite\b/, }) - const window = {} - vi.stubGlobal('window', window) - try { - // As the page loads it (router package and JSX runtime stubbed) - const { Route } = await evaluateServed(server, '/routes/index.tsx', { - skip: /^\/@id\/|\/node_modules\/|^\/@vite\b/, - stubs: { - [`/@id/__x00__@tanstack/${target}-router-stub`]: { - createFileRoute: () => (options: unknown) => ({ options }), - }, - }, - fallbackStub: (_, names) => - Object.fromEntries([...names].map((name) => [name, () => null])), - }) - expect(await Route.options.loader()).toBe('x') - expect(Object.keys(window).length > 0).toBe(reactRefreshHooks) - } finally { - vi.unstubAllGlobals() + for (const [url, code] of Object.entries(served)) { + expect(hasRouteHmr(code), url).toBe(false) } }, 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', diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index e34d6de3789..1572c6ab79e 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -1,9 +1,5 @@ -import path from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { getModuleErrors } from '@tanstack/compiler-test-utils/validate-module' -import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin' -import { createRouterPluginContext } from '../src/core/router-plugin-context' -import { normalizePath } from '../src/core/utils' import { tanstackRouter } from '../src/vite' import { loadRouteOption, @@ -11,35 +7,8 @@ import { routerDevServer, routerPackageStub, } from './dev-server' -import { - createRouteLoader, - evaluateModule, - expectRegisteredRouteOption, -} from './regression-helpers' +import { createRouteLoader } from './regression-helpers' import type { CodeSplitGroupings } from '../src/core/constants' -import type { TransformResult, UnpluginOptions } from 'unplugin' - -async function transformReferenceRoute( - plugin: UnpluginOptions, - code: string, - id: string, -): Promise { - const transform = plugin.transform - if (!transform || typeof transform === 'function') { - throw new Error('Expected object transform') - } - return transform.handler.call({} as never, code, id) -} - -function getCode(result: TransformResult | null | undefined) { - if (!result) { - return null - } - if (typeof result === 'string') { - return result - } - return result.code -} describe('router plugin context', () => { it('refreshes chunk ownership after source changes and isolates split settings', async () => { @@ -131,49 +100,7 @@ export const Route = createFileRoute('${routePath}')({ component: Page }) } }, 30_000) - it('normalizes HMR options before the first transform', async () => { - const routeFile = normalizePath( - path.join(process.cwd(), 'src/routes/owned.tsx'), - ) - - const routeCode = ` -import { createFileRoute } from '@tanstack/react-router' - -function Component() { - return
    Hello
    -} - -export const Route = createFileRoute('/owned')({ - component: Component, -}) -` - - const context = createRouterPluginContext() - context.routesByFile.set(routeFile, { routeId: '/owned' }) - - const hmrPlugin = createRouterHmrPlugin({}, context) - const hmrResult = await transformReferenceRoute( - Array.isArray(hmrPlugin) ? hmrPlugin[0]! : hmrPlugin, - routeCode, - routeFile, - ) - - const hmrCode = getCode(hmrResult) - - expect(hmrCode).not.toBeNull() - // The route module accepts its hot updates, and React Refresh registers - // its component. - const accept = vi.fn() - await evaluateModule( - hmrCode!, - { - '@tanstack/react-router': { - createFileRoute: () => (options: unknown) => ({ options }), - }, - }, - { importMeta: { hot: { data: {}, accept, dispose() {} } } }, - ) - expect(accept).toHaveBeenCalled() - await expectRegisteredRouteOption(hmrCode!, 'component') - }) + // The first edit of a route file through the public plugin setup + // (`tanstackRouter({...})`) hot-updates the page: + // integration/route-hmr-browser.test.ts (every test's first edit). }) From f95f2ad9ce52e25069b33c0297d38afadcb4c73a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:23:32 +0200 Subject: [PATCH 0440/1425] test(router-plugin): route HMR page suites: Solid/Vue on Rsbuild, plugin configurations, sharper pins route-hmr-browser.test.ts: every edit also requires no unexpected page error (HMR-14; the deliberate loader error is filtered); the memo/forwardRef split rows and an inline-arrow row count mounts through a state initializer (HMR-13); the options the edits add are cumulative, and the removal of a loaded option (the pinned bug) is the last, loose step; the rows creating a second route in a route module are gone (one route per file, HMR-12). route-hmr-browser-frameworks.test.ts (new): Solid and Vue on Vite and Rsbuild; `{ reloaded: false }` where it holds today, the rest pinned. route-hmr-browser-plugin.test.ts (new): plugin configurations (hmr.style, environmentName, function options, tsr.config.json, Rsbuild with hmr.style vite), root route call shapes, routes created inside a function, `.ts` routes and an app without @vitejs/plugin-react (loose controls). known-bugs-route-hmr.test.ts: pins wait on the client's applied signal, not 3 s; new output and `{ reloaded: false, errors: [] }` everywhere (HMR-21); the memo(Page) control edits the body and keeps state, next to a forwardRef(Page) control (HMR-20, G.3); per-variant controls of the removal pin (HMR-15); new pins: alias export (Vite, Rsbuild), unsplit Vue, `.ts` route, Vite without plugin-react, the hoisted inline component with the real react-error-boundary (HMR-19), an unsplit Solid route on Rsbuild and a helper-created route without code splitting (both NEW, FOLLOWUPS). Every pin verified red for its stated reason in a run without `.fails`. The production-build pins move to known-bugs-route-hmr-production.test.ts (per-file budget). regression-helpers.test.ts: the reload detector control uses the browser's own reload (HMR-30); a control that an applied-but-not-shown edit fails without its timeout on both bundlers. Co-Authored-By: Claude Fable 5.1 --- .../known-bugs-route-hmr-production.test.ts | 83 +++ .../integration/known-bugs-route-hmr.test.ts | 651 +++++++++++++----- .../integration/regression-helpers.test.ts | 69 +- .../route-hmr-browser-frameworks.test.ts | 109 +++ .../route-hmr-browser-plugin.test.ts | 312 +++++++++ .../integration/route-hmr-browser.test.ts | 267 ++++--- 6 files changed, 1138 insertions(+), 353 deletions(-) create mode 100644 packages/router-plugin/tests/integration/known-bugs-route-hmr-production.test.ts create mode 100644 packages/router-plugin/tests/integration/route-hmr-browser-frameworks.test.ts create mode 100644 packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts diff --git a/packages/router-plugin/tests/integration/known-bugs-route-hmr-production.test.ts b/packages/router-plugin/tests/integration/known-bugs-route-hmr-production.test.ts new file mode 100644 index 00000000000..ca2b4ac9c1e --- /dev/null +++ b/packages/router-plugin/tests/integration/known-bugs-route-hmr-production.test.ts @@ -0,0 +1,83 @@ +// Production-build half of the known route HMR bugs: the page pins are in +// known-bugs-route-hmr.test.ts, the in-process pins in +// ../known-bugs-route-hmr.test.ts (fast tier). +/** + * Known route HMR bugs in production builds. Each test asserts correct + * behaviour for a bug on main and is marked .fails; remove .fails when the + * bug is fixed. + */ +import { describe, expect, test } from 'vitest' +import { build, routeApp } from '../build-harness' +import { head } from '../regression-helpers' +import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' + +describe('production builds', () => { + /** + * Builds a production client bundle with automatic code splitting on or + * off (off: the route HMR plugin's case), the plugin's own `addHmr` + * default and `NODE_ENV` as given (`null`: unset), and returns where a + * bundler HMR API is used. + */ + async function hmrPlacement( + bundler: Bundler, + autoCodeSplitting: boolean, + nodeEnv?: null, + ) { + const app = await build({ + bundler, + env: 'client', + nodeEnv, + pluginOptions: { + autoCodeSplitting, + codeSplittingOptions: { addHmr: undefined }, + }, + files: routeApp( + `${head}function Page() { + return

    page

    +} +export const Route = createFileRoute('/')({ component: Page }) +`, + { env: 'client' }, + ), + }) + return app.where(/import\.meta\.(?:webpackHot|hot)\b|\bmodule\.hot\b/) + } + + // No Vite rows: Vite replaces `import.meta.hot` with `undefined` in a + // build and drops the code it guards, so they could not fail. + + // Control for the pin below: with `NODE_ENV=production` exported. + test.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no route HMR code (%s)', + async (bundler) => { + expect(await hmrPlacement(bundler, false)).toBe('nowhere') + }, + 60_000, + ) + + // Source: gap P1 / DV51. (No esbuild row: the esbuild plugin neither + // generates nor splits routes, pinned in build-harness.test.ts, so it + // could not fail.) + test.each(['rspack', 'webpack'] as const)( + 'a production client bundle with automatic code splitting has no route HMR code (%s)', + async (bundler) => { + expect(await hmrPlacement(bundler, true)).toBe('nowhere') + }, + 60_000, + ) + + // Bug: with automatic code splitting off, the composed router plugin adds + // the route HMR plugin unless `NODE_ENV` is `production` when the plugin is + // created (`router-composed-plugin.ts`), instead of following the + // bundler's mode. webpack and Rspack set `NODE_ENV` only inside the bundle. + // Source: gap CC-P1 / routes.md R7. + // Impact: webpack/Rspack production bundles built from a shell without + // `NODE_ENV` ship the route HMR code of every route. + test.fails.each(['rspack', 'webpack'] as const)( + 'a production client bundle has no route HMR code when NODE_ENV is unset (%s)', + async (bundler) => { + expect(await hmrPlacement(bundler, false, null)).toBe('nowhere') + }, + 60_000, + ) +}) diff --git a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts index 82c93f00279..7bfa14a6c32 100644 --- a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts @@ -1,5 +1,6 @@ // Slow-tier half of this suite: the tests that need a real tool. Its -// in-process tests are in ../known-bugs-route-hmr.test.ts (fast tier). +// in-process tests are in ../known-bugs-route-hmr.test.ts (fast tier); the +// production-build pins are in known-bugs-route-hmr-production.test.ts. /** * Known route HMR and React Refresh bugs. Each test asserts correct behaviour * for a bug on main and is marked .fails; remove .fails when the bug is fixed. @@ -8,24 +9,20 @@ * so that React Refresh can patch it in place; a component React Refresh does * not register keeps rendering the old code until a full reload. Pins of what * the page shows run a real dev server and a real browser page - * (`route-hmr-page.ts`). + * (`route-hmr-page.ts`): an edit must show its new code without a full + * reload and without errors in the page. A pin fails as soon as the page's + * HMR client reports the update applied without the new code showing. + * + * Concurrent: each pin has its own dev server and page, and mostly waits. */ - -import { describe, expect, test } from 'vitest' -import type { TestContext } from 'vitest' -import { build, routeApp } from '../build-harness' -import { head } from '../regression-helpers' +import { describe, test } from 'vitest' import { openRouteHmrPage } from '../route-hmr-page' -import type { Bundler } from '@tanstack/compiler-test-utils/build-harness' +import type { TestContext } from 'vitest' +import type { EditResult, RouteHmrPageOptions } from '../route-hmr-page' -// Route HMR in a real dev server and a real browser page -// (`route-hmr-page.ts`, as route-hmr-browser.test.ts). A pin waits for the -// new code at most `pinTimeout`, well under the test timeout. -// Concurrent: each pin mostly waits (`pinTimeout`) for an edit that never -// shows. -describe.concurrent('route HMR in a real page', { timeout: 60_000 }, () => { - const pinTimeout = 3_000 +const inPlace = { reloaded: false, errors: [] } +describe.concurrent('route HMR in a real page', { timeout: 60_000 }, () => { /** A React route at `/` whose component, `name`, keeps a click count. */ const counterRoute = ( version: string, @@ -47,33 +44,30 @@ export const Route = createFileRoute('/')({ ` /** - * Opens a React app in Vite whose route file `file` is `route('v1')`, - * waits until it shows `shows('v1')`, edits it to `route('v2')` and - * returns whether the page reloaded once it shows `shows('v2')` (waiting - * at most `timeout`). + * Opens an app whose route file `file` is `route('v1')`, waits until it + * shows `shows('v1')`, edits it to `route('v2')` and returns the result of + * the edit once the page shows `shows('v2')`. */ - async function editComponent( + async function editRoute( { file, - autoCodeSplitting, route, shows, - }: { + ...options + }: Partial & { file: string - autoCodeSplitting: boolean route: (version: string) => string shows: (version: string) => string }, onTestFinished: TestContext['onTestFinished'], - timeout?: number, - ) { + ): Promise { const app = await openRouteHmrPage({ + ...options, onTestFinished, - pluginOptions: { autoCodeSplitting }, - files: { [file]: route('v1') }, + files: { ...options.files, [file]: route('v1') }, ready: shows('v1'), }) - return app.edit({ [file]: route('v2') }, shows('v2'), { timeout }) + return app.edit({ [file]: route('v2') }, shows('v2')) } /** A route at `/` with the `component` option `option`, after `imports`. */ @@ -87,29 +81,45 @@ export const Route = createFileRoute('/')({ }) ` - // Control for the inline component pins below (same harness). - test('vite: an edit of a component passed as memo(Page) shows in place (autoCodeSplitting: false)', async ({ - expect, - onTestFinished, - }) => { - expect( - await editComponent( - { - file: 'routes/index.tsx', - autoCodeSplitting: false, - route: inlineRoute( - `import { memo } from 'react' + // Controls for the inline component pins below (same harness): a + // component wrapped in `memo` or `forwardRef` at the option, declared + // under a name React Refresh registers, is patched in place with its + // state. + test.for(['memo', 'forwardRef'])( + 'vite: an edit of a component passed as %s(Page) shows in place with its state (autoCodeSplitting: false)', + async (wrapper: string, { expect, onTestFinished }: TestContext) => { + const route = inlineRoute('', () => `component: ${wrapper}(Page)`) + const file = 'routes/index.tsx' + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting: false }, + files: { + [file]: `import { ${wrapper}, useState } from 'react' function Page() { - return

    page v1

    -}`, - () => 'component: memo(Page)', - ), - shows: () => 'page v1', + const [count, setCount] = useState(0) + return +} +${route('v1')}`, }, - onTestFinished, - ), - ).toEqual({ reloaded: false }) - }) + ready: 'page v1 count 0', + }) + await app.click('button') + await app.page.waitForText('page v1 count 1', { selector: '#app' }) + expect( + await app.edit( + { + [file]: `import { ${wrapper}, useState } from 'react' +function Page() { + const [count, setCount] = useState(0) + return +} +${route('v2')}`, + }, + 'page v2 count 1', + ), + ).toEqual(inPlace) + }, + ) // Bug: inline component options that are not plain functions (`memo(...)`, // `forwardRef(...)`, method shorthand, a function wrapped in a TypeScript @@ -149,7 +159,7 @@ function Page() { ].map(([name, imports, component]) => ({ name: name!, file: 'routes/index.tsx', - autoCodeSplitting: false, + pluginOptions: { autoCodeSplitting: false }, route: inlineRoute( imports!, (version) => `component: ${component!.replace('VERSION', version)}`, @@ -159,7 +169,7 @@ function Page() { ...[false, true].map((autoCodeSplitting) => ({ name: 'method shorthand', file: 'routes/index.tsx', - autoCodeSplitting, + pluginOptions: { autoCodeSplitting }, route: inlineRoute( '', (version) => `component() { @@ -171,7 +181,7 @@ function Page() { { name: 'React.memo(forwardRef(...)) on a root route', file: 'routes/__root.tsx', - autoCodeSplitting: true, + pluginOptions: { autoCodeSplitting: true }, route: (version: string) => `import React, { forwardRef } from 'react' import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute({ @@ -183,14 +193,28 @@ export const Route = createRootRoute({ shows: (version: string) => `root ${version}`, }, ])( - 'vite: an edit of an inline component written as $name shows in place (autoCodeSplitting: $autoCodeSplitting)', + 'vite: an edit of an inline component written as $name shows in place (autoCodeSplitting: $pluginOptions.autoCodeSplitting)', async (row, { expect, onTestFinished }: TestContext) => { - expect(await editComponent(row, onTestFinished, pinTimeout)).toEqual({ - reloaded: false, - }) + expect(await editRoute(row, onTestFinished)).toEqual(inPlace) }, ) + /** + * A route file of `target` at `/` whose loader and component name the + * version of the file; the component declares its own `Page`. + */ + const frameworkRoute = (target: 'solid' | 'vue') => (version: string) => + `import { createFileRoute } from '@tanstack/${target}-router' +export const Route = createFileRoute('/')({ + loader: () => 'data ${version}', + component: Page, +}) +function Page() { + const data = Route.useLoaderData() + return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    +} +` + // Bug: route HMR keeps the previous split components only for React: for // Solid and Vue, the route module creates a new lazy component on every // update and `handleRouteUpdate` keeps no component option @@ -212,17 +236,7 @@ export const Route = createRootRoute({ target: 'solid' | 'vue', { expect, onTestFinished }: TestContext, ) => { - const route = (version: string) => - `import { createFileRoute } from '@tanstack/${target}-router' -export const Route = createFileRoute('/')({ - loader: () => 'data ${version}', - component: Page, -}) -function Page() { - const data = Route.useLoaderData() - return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    -} -` + const route = frameworkRoute(target) const app = await openRouteHmrPage({ target, onTestFinished, @@ -232,16 +246,186 @@ function Page() { const liveComponent = `window.__TSR_ROUTER__.routesById['/'].options.component` await app.evaluate(`window.shownComponent = ${liveComponent}`) expect( - await app.edit({ 'routes/index.tsx': route('v2') }, 'page v2 data v2', { - timeout: pinTimeout, - }), - ).toEqual({ reloaded: false }) + await app.edit({ 'routes/index.tsx': route('v2') }, 'page v2 data v2'), + ).toEqual(inPlace) expect( await app.evaluate(`window.shownComponent === ${liveComponent}`), ).toBe(true) }, ) + // 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(`. + // A route module is then never an HMR boundary of its own: the update + // propagates to the route tree and the app entry, and the page reloads. + // React apps are not affected because React Refresh makes route modules + // boundaries (route-hmr-browser.test.ts); Solid Refresh only does for + // modules that declare components (route-hmr-browser-frameworks.test.ts: + // an unsplit Solid route applies in place), and Vue JSX only for + // `defineComponent` components. + // Impact: every edit of a Vue route file reloads the page in Vite dev (and + // of a split Solid route: the pin above). + // Any boundary (the route module or an importer that accepts it) passes. + test.fails( + 'vite: an edit of an unsplit Vue route shows in place', + async ({ expect, onTestFinished }: TestContext) => { + expect( + await editRoute( + { + target: 'vue', + pluginOptions: { autoCodeSplitting: false }, + file: 'routes/index.tsx', + route: frameworkRoute('vue'), + shows: (version) => `page ${version} data ${version}`, + }, + onTestFinished, + ), + ).toEqual(inPlace) + }, + ) + + // Bug: on Rsbuild, an edit of an unsplit Solid route file (the route HMR + // plugin alone) shows its new loader data but keeps rendering the old + // component: the route module runs again with Solid Refresh, the route + // HMR code gives the live route the new options, but the rendered + // component is not replaced nor refreshed. Split Solid routes on Rsbuild, + // and unsplit Solid routes in Vite, apply in place + // (route-hmr-browser-frameworks.test.ts). + // Impact: edits of a Solid route's component do not show until a reload + // in an Rsbuild app without automatic code splitting. + // Source: NEW (W2-g, found by the Rsbuild Solid page). + test.fails( + 'rsbuild: an edit of an unsplit Solid route shows its new component in place', + async ({ expect, onTestFinished }: TestContext) => { + expect( + await editRoute( + { + bundler: 'rsbuild', + target: 'solid', + pluginOptions: { autoCodeSplitting: false }, + file: 'routes/index.tsx', + route: frameworkRoute('solid'), + shows: (version) => `page ${version} data ${version}`, + }, + onTestFinished, + ), + ).toEqual(inPlace) + }, + ) + + // Bug: a route a helper function of the route file creates + // (`export const Route = makeRoute()`, decision D11) edited without + // automatic code splitting (the route HMR plugin alone) shows its new + // loader data but keeps rendering the old inline component: the route HMR + // code keeps the previous component for React Refresh to patch, but the + // component inside the helper is neither hoisted nor registered. With + // automatic code splitting, and for an IIFE, the edit shows + // (route-hmr-browser-plugin.test.ts). + // Impact: edits of such a route's component do not show until a reload. + // Source: NEW (W2-g, found by the real page; the former in-process model + // compared a component whose text did not change between versions). + test.fails.for(['vite', 'rsbuild'] as const)( + '%s: an edit of a route a helper function creates shows its new component in place (autoCodeSplitting: false)', + async ( + bundler: 'vite' | 'rsbuild', + { expect, onTestFinished }: TestContext, + ) => { + expect( + await editRoute( + { + bundler, + pluginOptions: { autoCodeSplitting: false }, + file: 'routes/index.tsx', + route: (version) => + `import { createFileRoute } from '@tanstack/react-router' +function makeRoute() { + return createFileRoute('/')({ + loader: () => 'data ${version}', + component: () =>

    helper ${version} {Route.useLoaderData()}

    , + }) +} +export const Route = makeRoute() +`, + shows: (version) => `helper ${version} data ${version}`, + }, + onTestFinished, + ), + ).toEqual(inPlace) + }, + ) + + /** A root route rendering the loader data of the active matches. */ + const rootShowingLoaderData = `import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router' +export const Route = createRootRoute({ + component: () => { + const data = useRouterState({ + select: (state) => state.matches.map((match) => match.loaderData ?? '').join(''), + }) + return

    root {data}

    + }, +}) +` + + // Bug: same root cause as the pin above, 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. + test.fails.for([true, false])( + 'vite: an edit of a React .ts route module shows its new loader data in place (autoCodeSplitting: %s)', + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + expect( + await editRoute( + { + pluginOptions: { autoCodeSplitting }, + files: { 'routes/__root.tsx': rootShowingLoaderData }, + file: 'routes/index.ts', + route: (version) => + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ loader: () => 'data ${version}' }) +`, + shows: (version) => `root data ${version}`, + }, + onTestFinished, + ), + ).toEqual(inPlace) + }, + ) + + // Bug: same root cause, for a React app without `@vitejs/plugin-react` + // (Vite compiles the JSX itself; nothing registers React Refresh + // boundaries): the route module's own accept call is on an alias, so the + // page reloads on every edit. (Without a refresh runtime, the new + // component may be mounted anew: a subtree replacement, not a document + // reload.) + // Impact: every edit of a route file reloads the page in a Vite React app + // without the React plugin. + test.fails.for([true, false])( + 'vite: an edit of a route component in an app without @vitejs/plugin-react shows in place (autoCodeSplitting: %s)', + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + expect( + await editRoute( + { + frameworkPlugin: false, + pluginOptions: { autoCodeSplitting }, + file: 'routes/index.tsx', + route: (version) => counterRoute(version), + shows: (version) => `Page ${version} count`, + }, + onTestFinished, + ), + ).toEqual(inPlace) + }, + ) + // Bug: `handleRouteUpdate` keeps the previous component options so that // React Refresh patches them in place, also when the edit replaced the // component with another one (renamed, so another component family for @@ -254,12 +438,15 @@ function Page() { // Source: router-plugin src/core/hmr/handle-route-update.ts (component // keys kept whenever both versions have them); NEW (hmr-real, found by the // real-page harness; the former model treated it as expected). - // Any fix passes: a hot update rendering the new component (React mounts - // it anew), or a reload. + // Goal: a hot update rendering the new component (React mounts it anew), + // without a full reload or errors. describe.each(['vite', 'rsbuild'] as const)('%s', (bundler) => { test.fails.for([true, false])( - 'renaming the route component shows the renamed component (autoCodeSplitting: %s)', - async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { + 'renaming the route component shows the renamed component in place (autoCodeSplitting: %s)', + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { const app = await openRouteHmrPage({ bundler, onTestFinished, @@ -267,11 +454,12 @@ function Page() { files: { 'routes/index.tsx': counterRoute('v1') }, ready: 'Page v1 count 0', }) - await app.edit( - { 'routes/index.tsx': counterRoute('v2', 'Renamed') }, - 'Renamed v2 count', - { timeout: pinTimeout }, - ) + expect( + await app.edit( + { 'routes/index.tsx': counterRoute('v2', 'Renamed') }, + 'Renamed v2 count', + ), + ).toEqual(inPlace) }, ) }) @@ -281,18 +469,17 @@ function Page() { // reaches the page: the route module's code is unchanged, so Rspack does // not run it again and the route HMR code (`webpack-adapter.ts`, in the // route module only) never refreshes the route; the split loader module - // accepts no update of its own. With Rsbuild's default lazy compilation - // the loaded chunk is not even updated; without it (`dev.lazyCompilation: - // false`), the next load gets the new loader, but nothing reloads the - // route. Vite updates the route in place (route-hmr-browser.test.ts). + // accepts no update of its own. The next load gets the new loader, but + // nothing reloads the route. Vite updates the route in place + // (route-hmr-browser.test.ts). // Impact: the page keeps the old loader's data until a reload or a // navigation, with no sign that the edit was not applied. // Source: router-plugin src/core/hmr/webpack-adapter.ts (HMR code only in // the route module); NEW (hmr-real). - // Any fix passes: a hot update showing the new loader's data, or a reload. + // Goal: a hot update showing the new loader's data, without a full reload. test.fails.for([`[['loader'], ['component']]`, `[['component', 'loader']]`])( - 'rsbuild: an edit of a split loader (codeSplitGroupings: %s) shows its data', - async (groupings: string, { onTestFinished }: TestContext) => { + 'rsbuild: an edit of a split loader (codeSplitGroupings: %s) shows its data in place', + async (groupings: string, { expect, onTestFinished }: TestContext) => { const route = (version: string) => `import { createFileRoute } from '@tanstack/react-router' function Page() { @@ -310,12 +497,52 @@ export const Route = createFileRoute('/')({ files: { 'routes/index.tsx': route('v1') }, ready: 'loader v1', }) - await app.edit({ 'routes/index.tsx': route('v2') }, 'loader v2', { - timeout: pinTimeout, - }) + expect( + await app.edit({ 'routes/index.tsx': route('v2') }, 'loader v2'), + ).toEqual(inPlace) }, ) + /** + * The index route whose loader throws, with (`errorComponent: Failed`) or + * without its error component: without it, the router's default error + * component shows the error. + */ + const failingRoute = (version: string, errorComponent: boolean) => + counterRoute( + version, + 'Page', + `${errorComponent ? 'errorComponent: Failed,\n ' : ''}loader: () => { + throw new Error('loader failed') + },`, + ) + + /** + * Opens an app whose index route shows its error component, edits the + * route so that it has no error component anymore, and returns the result + * of the edit (without the deliberate loader error the app reports) once + * the page shows the router's default error component. + */ + async function removeErrorComponent( + options: Pick, + onTestFinished: TestContext['onTestFinished'], + ) { + const app = await openRouteHmrPage({ + ...options, + onTestFinished, + files: { 'routes/index.tsx': failingRoute('v1', true) }, + ready: 'failed v1', + }) + const result = await app.edit( + { 'routes/index.tsx': failingRoute('v2', false) }, + /^(?!.*failed v).*loader failed/, + ) + return { + reloaded: result.reloaded, + errors: result.errors.filter((error) => !error.includes('loader failed')), + } + } + // Bug: an edit that removes a split component option (here // `errorComponent`, which the page had loaded) leaves its split module in // Vite's module graph; Vite updates it with the rest of the route file, the @@ -329,29 +556,36 @@ export const Route = createFileRoute('/')({ // of an option the route file no longer has); NEW (hmr-real). // Goal (any fix): the edit applies in place, and the page logs no error. test.fails( - 'vite: an edit that removes a split option the page loaded applies without errors', + 'vite: an edit that removes a split option the page loaded applies in place without errors', async ({ expect, onTestFinished }: TestContext) => { - const app = await openRouteHmrPage({ - onTestFinished, - files: { - 'routes/index.tsx': counterRoute( - 'v1', - 'Page', - `errorComponent: Failed, - loader: () => { - throw new Error('loader failed') - },`, - ), - }, - ready: 'failed v1', - }) - const errors = app.page.errors.length expect( - await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { - timeout: pinTimeout, - }), - ).toEqual({ reloaded: false }) - expect(app.page.errors.slice(errors)).toEqual([]) + await removeErrorComponent( + { pluginOptions: { autoCodeSplitting: true } }, + onTestFinished, + ), + ).toEqual(inPlace) + }, + ) + + // Controls for the pin above: the same removal applies in place where the + // option was not split (Vite), and on Rsbuild (its hot update replaces the + // route module and its chunks together). + test.for([ + { bundler: 'vite', autoCodeSplitting: false }, + { bundler: 'rsbuild', autoCodeSplitting: true }, + { bundler: 'rsbuild', autoCodeSplitting: false }, + ] as const)( + '$bundler: an edit that removes a component option the page loaded applies in place without errors (autoCodeSplitting: $autoCodeSplitting)', + async ( + { bundler, autoCodeSplitting }, + { expect, onTestFinished }: TestContext, + ) => { + expect( + await removeErrorComponent( + { bundler, pluginOptions: { autoCodeSplitting } }, + onTestFinished, + ), + ).toEqual(inPlace) }, ) @@ -365,92 +599,135 @@ export const Route = createFileRoute('/')({ // Impact: the bundler reports the route as hot-updated, but the page shows // the old component until a reload; without the route HMR code the same // edit reloads the page. - // Any fix passes: a hot update rendering the new component, or a reload. + // Goal: a hot update rendering the new component (mounted anew, without a + // refresh runtime), without a full reload. test.fails.for([true, false])( - 'rsbuild without React Refresh: an edit of the route component shows (autoCodeSplitting: %s)', - async (autoCodeSplitting: boolean, { onTestFinished }: TestContext) => { - const app = await openRouteHmrPage({ - bundler: 'rsbuild', - onTestFinished, - reactRefresh: false, - pluginOptions: { autoCodeSplitting }, - files: { 'routes/index.tsx': counterRoute('v1') }, - ready: 'Page v1 count 0', - }) - await app.edit({ 'routes/index.tsx': counterRoute('v2') }, 'Page v2', { - timeout: pinTimeout, - }) + 'rsbuild without React Refresh: an edit of the route component shows in place (autoCodeSplitting: %s)', + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + expect( + await editRoute( + { + bundler: 'rsbuild', + reactRefresh: false, + pluginOptions: { autoCodeSplitting }, + file: 'routes/index.tsx', + route: (version) => counterRoute(version), + shows: (version) => `Page ${version} count`, + }, + onTestFinished, + ), + ).toEqual(inPlace) }, ) + + /** A route at `/` declared under another name and exported as `Route`. */ + const aliasedRoute = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +const MyRoute = createFileRoute('/')({ + loader: () => 'data ${version}', + component: () =>

    page {MyRoute.useLoaderData()}

    , }) +export { MyRoute as Route } +` + + // Bug: the injected HMR code reads the module's route through the + // identifier `Route`, but a route may be declared under another name and + // exported as `Route` (`export { MyRoute as Route }`). + // Impact: Rsbuild (webpack-style HMR) apps throw "Route is not defined" + // when the route module loads in development, so the page never renders; + // Vite apps throw on the first hot update of the route, which never shows. + // Control: the ordinary `export const Route` of every other test here. + describe.each(['vite', 'rsbuild'] as const)('%s', (bundler) => { + test.fails( + 'a route exported under another local name is hot-updated in place', + async ({ expect, onTestFinished }: TestContext) => { + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + files: { 'routes/index.tsx': aliasedRoute('v1') }, + ready: 'page data v1', + // Rsbuild: the page never renders + readyTimeout: 4_000, + }) + expect( + await app.edit( + { 'routes/index.tsx': aliasedRoute('v2') }, + 'page data v2', + // Vite: the update's callback throws, so the client never + // reports it applied + { timeout: 4_000 }, + ), + ).toEqual(inPlace) + }, + ) + }) -describe('production builds', () => { /** - * Builds a production client bundle with automatic code splitting on or - * off (off: the route HMR plugin's case), the plugin's own `addHmr` - * default and `NODE_ENV` as given (`null`: unset), and returns where a - * bundler HMR API is used. + * The index route of an app without automatic code splitting (the route + * HMR plugin alone) whose `pendingComponent` renders an `ErrorBoundary` + * (`react-error-boundary`) around a child that throws, with the lowercase + * `errorFallback` component as its fallback; `errorComponent` reads the + * same component when `alsoErrorComponent`. The loader never settles, so + * the pending component shows. */ - async function hmrPlacement( - bundler: Bundler, - autoCodeSplitting: boolean, - nodeEnv?: null, - ) { - const app = await build({ - bundler, - env: 'client', - nodeEnv, - pluginOptions: { - autoCodeSplitting, - codeSplittingOptions: { addHmr: undefined }, - }, - files: routeApp( - `${head}function Page() { - return

    page

    + const errorBoundaryRoute = (alsoErrorComponent: boolean) => + `import { createFileRoute } from '@tanstack/react-router' +import { ErrorBoundary } from 'react-error-boundary' +const errorFallback = () =>

    fallback shown

    +function Thrower() { + throw new Error('child failed') } -export const Route = createFileRoute('/')({ component: Page }) -`, - { env: 'client' }, - ), - }) - return app.where(/import\.meta\.(?:webpackHot|hot)\b|\bmodule\.hot\b/) - } - - // No Vite rows: Vite replaces `import.meta.hot` with `undefined` in a - // build and drops the code it guards, so they could not fail. - - // Control for the pin below: with `NODE_ENV=production` exported. - test.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no route HMR code (%s)', - async (bundler) => { - expect(await hmrPlacement(bundler, false)).toBe('nowhere') - }, - 60_000, - ) +export const Route = createFileRoute('/')({ + loader: () => new Promise(() => {}), + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () => ( + + + + ),${alsoErrorComponent ? '\n errorComponent: errorFallback,' : ''} +}) +` - // Source: gap P1 / DV51. (No esbuild row: the esbuild plugin neither - // generates nor splits routes, pinned in build-harness.test.ts, so it - // could not fail.) - test.each(['rspack', 'webpack'] as const)( - 'a production client bundle with automatic code splitting has no route HMR code (%s)', - async (bundler) => { - expect(await hmrPlacement(bundler, true)).toBe('nowhere') - }, - 60_000, - ) + // Control for the hoisted inline component pin below (same harness): an + // inline component reading a lowercase component renders it (the real + // `react-error-boundary` shows the fallback once its child throws). + test('vite: an inline pending component rendering an error boundary with a lowercase fallback shows the fallback', async ({ + expect, + onTestFinished, + }: TestContext) => { + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting: false }, + files: { 'routes/index.tsx': errorBoundaryRoute(false) }, + ready: 'fallback shown', + }) + expect( + app.page.errors.some((error) => error.includes('child failed')), + ).toBe(true) + }) - // Bug: with automatic code splitting off, the composed router plugin adds - // the route HMR plugin unless `NODE_ENV` is `production` when the plugin is - // created (`router-composed-plugin.ts`), instead of following the - // bundler's mode. webpack and Rspack set `NODE_ENV` only inside the bundle. - // Source: gap CC-P1 / routes.md R7. - // Impact: webpack/Rspack production bundles built from a shell without - // `NODE_ENV` ship the route HMR code of every route. - test.fails.each(['rspack', 'webpack'] as const)( - 'a production client bundle has no route HMR code when NODE_ENV is unset (%s)', - async (bundler) => { - expect(await hmrPlacement(bundler, false, null)).toBe('nowhere') + // Bug: React HMR hoists inline component options into declarations it + // inserts after visiting every option, and renames a lowercase component + // passed to a later option. The rename only reaches nodes attached to the + // program, so a hoisted component still reads the old name. + // Impact: in development, the hoisted component throws when it renders + // (ReferenceError: errorFallback is not defined). + // Fix: rename before hoisting (or insert hoisted declarations immediately), + // or rename through the analyzer's binding references. + test.fails( + 'vite: an inline pending component hoisted before a renamed lowercase component renders it', + async ({ onTestFinished }: TestContext) => { + await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting: false }, + files: { 'routes/index.tsx': errorBoundaryRoute(true) }, + ready: 'fallback shown', + readyTimeout: 4_000, + }) }, - 60_000, ) }) diff --git a/packages/router-plugin/tests/integration/regression-helpers.test.ts b/packages/router-plugin/tests/integration/regression-helpers.test.ts index c37c20f7583..441f20297f7 100644 --- a/packages/router-plugin/tests/integration/regression-helpers.test.ts +++ b/packages/router-plugin/tests/integration/regression-helpers.test.ts @@ -36,15 +36,18 @@ export const Route = createFileRoute('/')({ component: () =>

    page

    })`, }) // Controls of the route HMR page (`openRouteHmrPage`): it tells kept state -// from lost state, and a hot update from a full reload. A React app whose -// route component renders a counter, and whose entry renders a header -// imported from a module that no HMR boundary accepts. +// from lost state, a hot update from a full reload (the browser's own +// reload API, as a user pressing reload), an update the page applied +// without showing the edit (it fails without waiting for its timeout), and +// what the page logged. A React app whose route component renders a +// counter. describe.each(['vite', 'rsbuild'] as const)( 'openRouteHmrPage (%s)', (bundler) => { const route = ( version: string, child = 'Counter', + effect = '', ) => `import { createFileRoute } from '@tanstack/react-router' import { useState } from 'react' function ${child}() { @@ -52,31 +55,16 @@ function ${child}() { return } function Page() { + ${effect} return
    <${child} />
    } export const Route = createFileRoute('/')({ component: Page }) -` - const main = `import { createRoot } from 'react-dom/client' -import { RouterProvider, createRouter } from '@tanstack/react-router' -import { routeTree } from './routeTree.gen' -import { header } from './header' -const router = createRouter({ routeTree }) -createRoot(document.getElementById('app')!).render( - <> -

    {header}

    - - , -) ` - it('sees kept state, lost state and full reloads', async () => { + it('sees kept state, lost state, full reloads and what the page logs', async () => { const app = await openRouteHmrPage({ bundler, - files: { - 'main.tsx': main, - 'header.ts': `export const header = 'header v1'`, - 'routes/index.tsx': route('v1'), - }, + files: { 'routes/index.tsx': route('v1') }, ready: 'Counter v1 count 0', }) await app.click('button') @@ -87,7 +75,7 @@ createRoot(document.getElementById('app')!).render( { 'routes/index.tsx': route('v2') }, 'Counter v2 count 1', ), - ).toEqual({ reloaded: false }) + ).toEqual({ reloaded: false, errors: [] }) // A component the edit renames is another component for React Refresh: // React mounts it anew. expect( @@ -95,14 +83,41 @@ createRoot(document.getElementById('app')!).render( { 'routes/index.tsx': route('v3', 'Renamed') }, 'Renamed v3 count 0', ), - ).toEqual({ reloaded: false }) - // An edit of a module that no HMR boundary accepts reloads the page. + ).toEqual({ reloaded: false, errors: [] }) + // What the app logs with `console.error` during an edit is reported. expect( await app.edit( - { 'header.ts': `export const header = 'header v2'` }, - 'header v2', + { + 'routes/index.tsx': route( + 'v4', + 'Renamed', + `console.error('MARK_LOGGED')`, + ), + }, + 'Renamed v4 count 0', ), - ).toEqual({ reloaded: true }) + ).toEqual({ reloaded: false, errors: ['MARK_LOGGED'] }) + // A full reload of the page (here: the browser's own reload) counts + // as a load; the app starts anew. + const loads = app.loads() + await app.page.page.reload() + await app.page.waitForText('Renamed v4 count 0') + expect(app.loads()).toBe(loads + 1) + }, 60_000) + + it('fails an edit the page applied without showing it, without waiting for its timeout', async () => { + const app = await openRouteHmrPage({ + bundler, + files: { 'routes/index.tsx': route('v1') }, + ready: 'Counter v1 count 0', + }) + const started = Date.now() + await expect( + app.edit({ 'routes/index.tsx': route('v2') }, 'Counter v3', { + timeout: 20_000, + }), + ).rejects.toThrow(/applied the update .* but the page does not show/s) + expect(Date.now() - started).toBeLessThan(10_000) }, 60_000) }, ) diff --git a/packages/router-plugin/tests/integration/route-hmr-browser-frameworks.test.ts b/packages/router-plugin/tests/integration/route-hmr-browser-frameworks.test.ts new file mode 100644 index 00000000000..01423da7dc3 --- /dev/null +++ b/packages/router-plugin/tests/integration/route-hmr-browser-frameworks.test.ts @@ -0,0 +1,109 @@ +/** + * Route HMR in Solid and Vue apps, as the developer sees it: a real dev + * server (Vite with `vite-plugin-solid` / `@vitejs/plugin-vue-jsx`, or + * Rsbuild with `@rsbuild/plugin-solid` / `@rsbuild/plugin-vue` + + * `@rsbuild/plugin-vue-jsx`) serves a file-based app with the framework's + * router package to a real browser page, and the test edits its route files + * on disk (`route-hmr-page.ts`). An edit of a route file shows its new + * code: the new loader's data and the new component body. Whether without a + * full reload, keeping the split component (decision D10): where it does, + * required here; where it does not, pinned in known-bugs-route-hmr.test.ts. + * + * React apps: route-hmr-browser.test.ts. + */ +import { describe, it } from 'vitest' +import { openRouteHmrPage } from '../route-hmr-page' +import type { TestContext } from 'vitest' + +const timeout = 90_000 + +/** + * A route file of `target` at `/` whose loader and component name the + * version of the file; the component declares its own `Page`, which Solid + * Refresh and Vue's HMR runtime can patch. + */ +const route = (target: 'solid' | 'vue', version: string) => + `import { createFileRoute } from '@tanstack/${target}-router' +export const Route = createFileRoute('/')({ + loader: () => 'data ${version}', + component: Page, +}) +function Page() { + const data = Route.useLoaderData() + return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    +} +` + +/** A cell of the bundler × framework × code-splitting matrix. */ +interface Cell { + bundler: 'vite' | 'rsbuild' + target: 'solid' | 'vue' + autoCodeSplitting: boolean +} + +const same = (a: Cell, b: Cell) => + a.bundler === b.bundler && + a.target === b.target && + a.autoCodeSplitting === b.autoCodeSplitting + +/** + * Where an edit of a route file applies in place today: Solid in Vite + * without automatic code splitting (Solid Refresh makes a module that + * declares its component a boundary), split Solid and Vue on Rsbuild (the + * route module runs again). The other cells reload the page (Vite: the + * route module's accept call is on an alias) and are pinned in + * known-bugs-route-hmr.test.ts ("an edit of a split Solid/Vue route", "an + * edit of an unsplit Vue route"). + */ +const hotToday = (cell: Cell) => + [ + { bundler: 'vite', target: 'solid', autoCodeSplitting: false }, + { bundler: 'rsbuild', target: 'solid', autoCodeSplitting: true }, + { bundler: 'rsbuild', target: 'vue', autoCodeSplitting: true }, + { bundler: 'rsbuild', target: 'vue', autoCodeSplitting: false }, + ].some((hot) => same(hot as Cell, cell)) + +/** + * Where the edit does not show at all today: an unsplit Solid route on + * Rsbuild keeps its old component (pinned in known-bugs-route-hmr.test.ts + * "rsbuild: an edit of an unsplit Solid route"). + */ +const brokenToday = (cell: Cell) => + same(cell, { bundler: 'rsbuild', target: 'solid', autoCodeSplitting: false }) + +describe.concurrent.each([ + { bundler: 'vite', target: 'solid' }, + { bundler: 'vite', target: 'vue' }, + { bundler: 'rsbuild', target: 'solid' }, + { bundler: 'rsbuild', target: 'vue' }, +] as const)('$bundler: a $target app', ({ bundler, target }) => { + it.for( + [true, false].filter( + (autoCodeSplitting) => + !brokenToday({ bundler, target, autoCodeSplitting }), + ), + )( + 'shows the new code of an edited route file (autoCodeSplitting: %s)', + { timeout }, + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + const app = await openRouteHmrPage({ + bundler, + target, + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': route(target, 'v1') }, + ready: 'page v1 data v1', + }) + const result = await app.edit( + { 'routes/index.tsx': route(target, 'v2') }, + 'page v2 data v2', + ) + if (hotToday({ bundler, target, autoCodeSplitting })) { + expect(result).toEqual({ reloaded: false, errors: [] }) + } + }, + ) +}) diff --git a/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts b/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts new file mode 100644 index 00000000000..b351fcda853 --- /dev/null +++ b/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts @@ -0,0 +1,312 @@ +/** + * Route HMR with the router plugin configured in its supported ways, and + * for the route files it recognises, as the developer sees it: a real dev + * server serves a file-based app to a real browser page, and the test edits + * its route files on disk (`route-hmr-page.ts`). What matters is what the + * page shows after the edit, not which code the plugin serves. + * + * Sources: the former route-hmr.test.ts "route HMR in a Vite dev server + * without automatic code splitting" (which looked for the hot API in the + * served code), code-splitter-route-options.test.ts "hot-update a root + * route created with ..." and code-splitter-plugin-pipeline.test.ts "a route + * created inside a function ... is hot-updated" (which ran the route HMR + * code against a modelled hot API), plugin-options.test.ts "makes routes + * accept hot updates through the bundler even when plugin.hmr.style is vite" + * (which scanned a build for the hot API). + */ +import { describe, it } from 'vitest' +import { openRouteHmrPage } from '../route-hmr-page' +import type { TestContext } from 'vitest' +import type { RouteHmrPageOptions } from '../route-hmr-page' + +const timeout = 90_000 +const inPlace = { reloaded: false, errors: [] } + +/** + * A route file of `target` at `/` whose loader and component name the + * version of the file. + */ +const indexRoute = (target: 'react' | 'solid', version: string) => + `import { createFileRoute } from '@tanstack/${target}-router' +export const Route = createFileRoute('/')({ + loader: () => 'data ${version}', + component: Page, +}) +function Page() { + const data = Route.useLoaderData() + return

    page ${version} {${target === 'solid' ? 'data()' : 'data'}}

    +} +` + +/** `tsr.config.json` of an app of `target`, as the plugin reads it. */ +const tsrConfig = (target: 'react' | 'solid') => + JSON.stringify({ + target, + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + autoCodeSplitting: false, + }) + +// The router plugin, configured as the plugin's public API allows: each +// configuration serves route HMR, so an edit shows in place. +describe.concurrent('the router plugin configured with', () => { + const rows: Array<{ + name: string + target?: 'react' | 'solid' + options: Partial + }> = [ + { + // Source: route-hmr.test.ts "works with plugin.hmr.style alone". + name: 'plugin.hmr.style alone (vite)', + options: { + pluginOptions: { + autoCodeSplitting: false, + plugin: { hmr: { style: 'vite' } }, + }, + }, + }, + { + // Source: route-hmr.test.ts "is limited to the environment + // plugin.vite.environmentName names" (the excluded environment: the + // fast tier's route-hmr.test.ts). + name: 'plugin.vite.environmentName naming the client (vite)', + options: { + pluginOptions: { + autoCodeSplitting: false, + plugin: { vite: { environmentName: 'client' } }, + }, + }, + }, + { + // Source: route-hmr.test.ts "takes plugin options given as a + // function". + name: 'its options given as a function (vite, solid)', + target: 'solid', + options: { + pluginOptions: { autoCodeSplitting: false }, + pluginOptionsAsFunction: true, + }, + }, + // Source: route-hmr.test.ts "compiles for the target of a + // tsr.config.json in the Vite root". + ...(['react', 'solid'] as const).map((target) => ({ + name: `no options, reading tsr.config.json (vite, ${target})`, + target, + options: { + pluginOptions: null, + files: { 'tsr.config.json': tsrConfig(target) }, + }, + })), + { + // The Rspack entry uses Rspack's hot API whatever `plugin.hmr.style` + // says (src/rspack.ts). Source: plugin-options.test.ts. + name: 'plugin.hmr.style: vite (rsbuild)', + options: { + bundler: 'rsbuild', + pluginOptions: { plugin: { hmr: { style: 'vite' } } }, + }, + }, + ] + + it.for(rows)( + '$name applies an edit of a route file in place', + { timeout }, + async ({ target = 'react', options }, { expect, onTestFinished }) => { + const app = await openRouteHmrPage({ + ...options, + target, + onTestFinished, + files: { + ...options.files, + 'routes/index.tsx': indexRoute(target, 'v1'), + }, + ready: 'page v1 data v1', + }) + expect( + await app.edit( + { 'routes/index.tsx': indexRoute(target, 'v2') }, + 'page v2 data v2', + ), + ).toEqual(inPlace) + }, + ) +}) + +// Source: utils.test.ts routeFactoryCallCodeFilter (type arguments and +// whitespace before the call); the former code-splitter-route-options +// "hot-update a root route created with ...". +describe.concurrent('vite: an edited root route created with', () => { + it.for([ + 'createRootRoute({', + 'createRootRoute ({', + 'createRootRouteWithContext()({', + 'createRootRouteWithContext<{ user: string }>()({', + 'createRootRouteWithContext <{ user: string }>()({', + ])( + '`%s` shows in place', + { timeout }, + async (call: string, { expect, onTestFinished }: TestContext) => { + const root = (version: string) => + `import { createRootRoute, createRootRouteWithContext } from '@tanstack/react-router' +export const Route = ${call} + loader: () => 'data ${version}', + component: () =>

    root ${version} {Route.useLoaderData()}

    , +}) +` + const app = await openRouteHmrPage({ + onTestFinished, + files: { 'routes/__root.tsx': root('v1') }, + ready: 'root v1 data v1', + }) + expect( + await app.edit({ 'routes/__root.tsx': root('v2') }, 'root v2 data v2'), + ).toEqual(inPlace) + }, + ) +}) + +// A route the route file creates inside a function of the same file (a +// helper, an IIFE) is unsupported but user-fixable (user ruling 2026-10-17, +// decision D11): it is either split like a module-scope route and +// hot-updated in place, or left unsplit with a warning naming the file +// (code-splitter-plugin-pipeline.test.ts). Either way, an edit shows its +// new loader data and component: in place, or after a reload. (A helper +// function without automatic code splitting keeps its old component: +// pinned in known-bugs-route-hmr.test.ts.) +describe.concurrent.each(['vite', 'rsbuild'] as const)( + '%s: an edit of a route created inside', + (bundler) => { + const factoryRoutes = { + helper: (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +function makeRoute() { + return createFileRoute('/')({ + loader: () => 'data ${version}', + component: () =>

    helper ${version} {Route.useLoaderData()}

    , + }) +} +export const Route = makeRoute() +`, + iife: (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +export const Route = (() => + createFileRoute('/')({ + loader: () => 'data ${version}', + component: () =>

    iife ${version} {Route.useLoaderData()}

    , + }))() +`, + 'iife-module': (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +function Made() { + return

    iife-module ${version} {Route.useLoaderData()}

    +} +export const Route = (() => + createFileRoute('/')({ loader: () => 'data ${version}', component: Made }))() +`, + } + + it.for( + Object.keys(factoryRoutes) + .flatMap((shape) => + [true, false].map((autoCodeSplitting) => ({ + shape, + autoCodeSplitting, + })), + ) + .filter( + ({ shape, autoCodeSplitting }) => + shape !== 'helper' || autoCodeSplitting, + ), + )( + '$shape shows (autoCodeSplitting: $autoCodeSplitting)', + { timeout }, + async ( + { shape, autoCodeSplitting }, + { expect, onTestFinished }: TestContext, + ) => { + const route = factoryRoutes[shape as keyof typeof factoryRoutes] + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': route('v1') }, + ready: `${shape} v1 data v1`, + }) + const { errors } = await app.edit( + { 'routes/index.tsx': route('v2') }, + `${shape} v2 data v2`, + ) + expect(errors).toEqual([]) + }, + ) + }, +) + +// Route files the framework plugin gives no refresh boundary: a React route +// file without JSX (`.ts`), and a React app without `@vitejs/plugin-react` +// (Vite compiles the JSX itself). An edit shows its new code, after a hot +// update or a reload; whether in place: pinned in +// known-bugs-route-hmr.test.ts. +describe.concurrent('vite: an edit of', () => { + /** A root route rendering the loader data of the active matches. */ + const root = `import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router' +export const Route = createRootRoute({ + component: () => { + const data = useRouterState({ + select: (state) => state.matches.map((match) => match.loaderData ?? '').join(''), + }) + return

    root {data}

    + }, +}) +` + const loaderRoute = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ loader: () => 'data ${version}' }) +` + + it.for([true, false])( + 'a route file without JSX (.ts) shows its new loader data (autoCodeSplitting: %s)', + { timeout }, + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + const app = await openRouteHmrPage({ + onTestFinished, + pluginOptions: { autoCodeSplitting }, + files: { + 'routes/__root.tsx': root, + 'routes/index.ts': loaderRoute('v1'), + }, + ready: 'root data v1', + }) + const { errors } = await app.edit( + { 'routes/index.ts': loaderRoute('v2') }, + 'root data v2', + ) + expect(errors).toEqual([]) + }, + ) + + it.for([true, false])( + 'a route component in an app without @vitejs/plugin-react shows (autoCodeSplitting: %s)', + { timeout }, + async ( + autoCodeSplitting: boolean, + { expect, onTestFinished }: TestContext, + ) => { + const app = await openRouteHmrPage({ + onTestFinished, + frameworkPlugin: false, + pluginOptions: { autoCodeSplitting }, + files: { 'routes/index.tsx': indexRoute('react', 'v1') }, + ready: 'page v1 data v1', + }) + const { errors } = await app.edit( + { 'routes/index.tsx': indexRoute('react', 'v2') }, + 'page v2 data v2', + ) + expect(errors).toEqual([]) + }, + ) +}) diff --git a/packages/router-plugin/tests/integration/route-hmr-browser.test.ts b/packages/router-plugin/tests/integration/route-hmr-browser.test.ts index 69c295e309e..0f1672d727b 100644 --- a/packages/router-plugin/tests/integration/route-hmr-browser.test.ts +++ b/packages/router-plugin/tests/integration/route-hmr-browser.test.ts @@ -4,15 +4,20 @@ * file-based app with the real router package to a real browser page, and * the test edits its route files on disk (`route-hmr-page.ts`). An edit must * show its new code; where a hot update is feasible, without a full reload, - * keeping the state of the components React Refresh patches in place. + * keeping the state of the components React Refresh patches in place, and + * without errors in the page. + * + * Solid and Vue apps: route-hmr-browser-frameworks.test.ts. The router + * plugin's configurations and the route shapes it recognises: + * route-hmr-browser-plugin.test.ts. Known bugs: known-bugs-route-hmr.test.ts. * * Several edits share one dev server and page. The tests run concurrently * (each has its own dev server and page; most of their time is waiting). */ import { describe, it } from 'vitest' -import type { TestContext } from 'vitest' import { openRouteHmrPage } from '../route-hmr-page' -import type { RouteHmrPage } from '../route-hmr-page' +import type { TestContext } from 'vitest' +import type { EditResult, RouteHmrPage } from '../route-hmr-page' const timeout = 90_000 @@ -221,6 +226,22 @@ async function click(app: RouteHmrPage, shows: string, count: number) { await app.page.waitForText(`${shows} ${count}`, { selector: '#app' }) } +/** + * A hot update with no error the app did not cause itself: the errors of + * an edit without the ones mentioning `expected` (what the app's own code + * threw on purpose, which React and the router report). + */ +function hot(result: EditResult, ...expected: Array) { + return { + reloaded: result.reloaded, + errors: result.errors.filter( + (error) => !expected.some((message) => error.includes(message)), + ), + } +} + +const inPlace = { reloaded: false, errors: [] } + describe.concurrent.each(['vite', 'rsbuild'] as const)( '%s: a React app', (bundler) => { @@ -247,18 +268,24 @@ describe.concurrent.each(['vite', 'rsbuild'] as const)( ready: 'page v1 data v1 / count 0', }) await click(app, 'page v1 data v1 / count', 1) + // The loader of some versions throws on purpose; React and the + // router report it. + const edit = async ( + files: Record, + expected: string | RegExp, + ) => hot(await app.edit(files, expected), 'loader failed') // The new body, the new loader's data and the live route (`Route`) // show; the component keeps its state. expect( - await app.edit( + await edit( { 'routes/index.tsx': indexRoute('v2') }, 'page v2 data v2 / count 1', ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) // A component whose hooks changed loses its state (React remounts // it), as in any React app. expect( - await app.edit( + await edit( { 'routes/index.tsx': indexRoute( 'v3', @@ -267,65 +294,55 @@ describe.concurrent.each(['vite', 'rsbuild'] as const)( }, 'page v3 data v3 / count 0', ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) - // Component options the edit adds, changes or removes. + // Component options the edit adds or changes (removing one the + // page loaded: last). const failing = `() => { throw new Error('loader failed') }` - expect( - await app.edit( - { - 'routes/index.tsx': indexRouteWith( - 'v4', - 'errorComponent: Failed,', - failing, - ), - }, - 'failed v4', - ), - ).toEqual({ reloaded: false }) - expect( - await app.edit( - { - 'routes/index.tsx': indexRouteWith( - 'v5', - 'errorComponent: Failed,', - failing, - ), - }, - 'failed v5', - ), - ).toEqual({ reloaded: false }) - // Without its error component, the route shows the router's - // default one. (Whether without a reload: pinned in - // known-bugs-route-hmr.test.ts, "removing a split option".) - await app.edit( - { 'routes/index.tsx': indexRouteWith('v6', '', failing) }, - /^(?!.*failed v).*loader failed/, - ) - const pending = `() => new Promise(() => {})` - for (const version of ['v7', 'v8']) { + const withError = 'errorComponent: Failed,' + for (const version of ['v4', 'v5']) { expect( - await app.edit( + await edit( { 'routes/index.tsx': indexRouteWith( version, - 'pendingComponent: Pending,\n pendingMs: 0,\n pendingMinMs: 0,', - pending, + withError, + failing, + ), + }, + `failed ${version}`, + ), + ).toEqual(inPlace) + } + const withPending = `${withError} + pendingComponent: Pending, + pendingMs: 0, + pendingMinMs: 0,` + for (const version of ['v6', 'v7']) { + expect( + await edit( + { + 'routes/index.tsx': indexRouteWith( + version, + withPending, + `() => new Promise(() => {})`, ), }, `pending ${version}`, ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) } - for (const version of ['v9', 'v10']) { + const withNotFound = `${withPending} + notFoundComponent: Missing,` + for (const version of ['v8', 'v9']) { expect( - await app.edit( + await edit( { 'routes/index.tsx': indexRouteWith( version, - 'notFoundComponent: Missing,', + withNotFound, `() => { throw notFound() }`, @@ -333,7 +350,7 @@ describe.concurrent.each(['vite', 'rsbuild'] as const)( }, `missing ${version}`, ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) } // Other shapes of route files. (A loader split from the route @@ -354,13 +371,26 @@ describe.concurrent.each(['vite', 'rsbuild'] as const)( }) await click(app, shows('v1'), 1) expect( - await app.edit( + await edit( { [`routes/${path}.tsx`]: route('v2') }, `${shows('v2')} 1`, ), name, - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) } + + // An edit that removes the component options the page loaded: the + // route shows the router's default error component. Whether in + // place and without errors: pinned in known-bugs-route-hmr.test.ts + // ("removes a split option the page loaded"), per bundler and + // split setting. Last, as the pinned failure may reload the page + // later. + await navigate(app, '/') + await app.page.waitForText('missing v9', { selector: '#app' }) + await edit( + { 'routes/index.tsx': indexRouteWith('v10', '', failing) }, + /^(?!.*failed v).*loader failed/, + ) }, timeout, ) @@ -424,7 +454,7 @@ export const Route = createFileRoute('/')(makeOptions('${version}')) { 'routes/__root.tsx': root('v2') }, 'root v2 data v2 count 1', ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) // A shell changes the tree the route renders in: adding or removing it // may remount the route's component; editing it patches both in place. expect( @@ -432,20 +462,20 @@ export const Route = createFileRoute('/')(makeOptions('${version}')) { 'routes/__root.tsx': root('v3', 'v3') }, /shell v3root v3 data v3 count \d+made v1/, ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) await click(app, 'root v3 data v3 count', 1) expect( await app.edit( { 'routes/__root.tsx': root('v4', 'v4') }, 'shell v4root v4 data v4 count 1made v1', ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) expect( await app.edit( { 'routes/__root.tsx': root('v5') }, /^root v5 data v5 count \d+made v1$/, ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) // Route options a factory call builds: the edit shows, after a hot // update or a reload (ruling 2026-10-18). await app.edit({ 'routes/index.tsx': factoryRoute('v2') }, 'made v2') @@ -512,39 +542,50 @@ export const Route = createFileRoute('/')({ { 'routes/index.tsx': index('v2') }, 'root v1 index v2 count 1', ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) expect( await app.edit( { 'routes/__root.tsx': root('v2') }, 'root v2 index v2 count 1', ), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) }, ) }, ) // React Refresh patches route components that are not plain declarations -// in place. Sources: react-refresh-route-components "React Refresh registers -// split route components" (ReactFreshBabelPlugin-test "registers likely -// HOCs with inline functions"; #8504 "written inline in a memo / forwardRef -// call") and "hoists the components of the module's Route when another -// route is created first" (#8504), which read React Refresh's registrations -// and modelled how its runtime resolves memo / forwardRef (ruling -// 2026-10-18). The split bodies have no JSX: with JSX, the code splitter +// in place, without mounting them again. Sources: +// react-refresh-route-components "React Refresh registers split route +// components" (ReactFreshBabelPlugin-test "registers likely HOCs with +// inline functions"; #8504 "written inline in a memo / forwardRef call"), +// which read React Refresh's registrations and modelled how its runtime +// resolves memo / forwardRef (ruling 2026-10-18); the former +// known-bugs-route-hmr "an edit of a React route with only inline components +// is a hot update" (gap H6a: the route HMR plugin runs before React +// Refresh, so a route whose components are all inline still becomes a +// boundary). The split bodies have no JSX: with JSX, the code splitter // cannot split the call (pinned in known-bugs-code-splitter.test.ts "a // component written as memo of an inline arrow is split and renders"). -// Unsplit inline memo / forwardRef components: pinned in -// known-bugs-route-hmr.test.ts. +// Each component counts its mounts in a state initializer (React runs it on +// mount only; React Refresh keeps the state and re-runs effects): a body +// edit React Refresh patches in place mounts nothing anew. Unsplit inline memo / forwardRef +// components: pinned in known-bugs-route-hmr.test.ts. describe.concurrent('vite: an edited React route component written', () => { + const mounted = `useState(() => (window.mounts = (window.mounts ?? 0) + 1))` const rows = [ { name: 'inline in a memo call, split', autoCodeSplitting: true, route: (version: string) => `import { createFileRoute } from '@tanstack/react-router' -import { memo } from 'react' -export const Route = createFileRoute('/')({ component: memo(() => 'inline ${version}') }) +import { memo, useState } from 'react' +export const Route = createFileRoute('/')({ + component: memo(() => { + ${mounted} + return 'inline ${version}' + }), +}) `, shows: (version: string) => `inline ${version}`, }, @@ -553,46 +594,35 @@ export const Route = createFileRoute('/')({ component: memo(() => 'inline ${vers autoCodeSplitting: true, route: (version: string) => `import { createFileRoute } from '@tanstack/react-router' -import { forwardRef } from 'react' +import { forwardRef, useState } from 'react' export const Route = createFileRoute('/')({ component: forwardRef(function (props, ref) { + ${mounted} return 'inline ${version}' }), }) `, shows: (version: string) => `inline ${version}`, }, - // Split: the route module keeps `pendingComponent`. (`extra` is not - // exported: `@vitejs/plugin-react` reloads the page on an edit of a - // module that exports anything but components and the exports route HMR - // has it ignore.) - ...[false, true].map((autoCodeSplitting) => ({ - name: `inline, after another route the module creates (autoCodeSplitting: ${autoCodeSplitting})`, - autoCodeSplitting, + { + name: 'as an inline arrow function, unsplit', + autoCodeSplitting: false, route: (version: string) => - `import { createFileRoute, createRoute } from '@tanstack/react-router' -const extra = createRoute({ - getParentRoute: () => Route, - path: 'extra', - component: () =>

    extra

    , -}) + `import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' export const Route = createFileRoute('/')({ - ${ - autoCodeSplitting - ? `loader: () => new Promise(() => {}), - pendingMs: 0, - pendingMinMs: 0, - pendingComponent: () =>

    pending ${version}

    ,` - : `component: () =>

    home ${version}

    ,` - } + loader: () => 'data ${version}', + component: () => { + ${mounted} + return

    inline ${version} {Route.useLoaderData()}

    + }, }) `, - shows: (version: string) => - autoCodeSplitting ? `pending ${version}` : `home ${version}`, - })), + shows: (version: string) => `inline ${version} data ${version}`, + }, ] it.for(rows)( - '$name shows in place', + '$name shows in place without mounting again', { timeout }, async ( { autoCodeSplitting, route, shows }, @@ -604,53 +634,12 @@ export const Route = createFileRoute('/')({ files: { 'routes/index.tsx': route('v1') }, ready: shows('v1'), }) + const mounts = await app.evaluate('window.mounts') + expect(mounts).toBeGreaterThan(0) expect( await app.edit({ 'routes/index.tsx': route('v2') }, shows('v2')), - ).toEqual({ reloaded: false }) + ).toEqual(inPlace) + expect(await app.evaluate('window.mounts')).toBe(mounts) }, ) }) - -// Solid and Vue in Vite with their framework's plugin: an edit of a route -// file shows its new code. Whether it does without a full reload, keeping -// the split component (decision D10), is pinned in -// known-bugs-route-hmr.test.ts; Solid without code splitting is a hot update. -describe.concurrent.each(['solid', 'vue'] as const)( - 'vite: a %s app', - (target) => { - const route = (version: string) => - `import { createFileRoute } from '@tanstack/${target}-router' -export const Route = createFileRoute('/')({ - loader: () => 'data ${version}', - component: Page, -}) -function Page() { - const data = Route.useLoaderData() - return

    page ${version} {${target === 'vue' ? 'data.value' : 'data()'}}

    -} -` - it.for([true, false])( - 'shows the new code of an edited route file (autoCodeSplitting: %s)', - { timeout }, - async ( - autoCodeSplitting: boolean, - { expect, onTestFinished }: TestContext, - ) => { - const app = await openRouteHmrPage({ - target, - onTestFinished, - pluginOptions: { autoCodeSplitting }, - files: { 'routes/index.tsx': route('v1') }, - ready: 'page v1 data v1', - }) - const { reloaded } = await app.edit( - { 'routes/index.tsx': route('v2') }, - 'page v2 data v2', - ) - if (target === 'solid' && !autoCodeSplitting) { - expect(reloaded).toBe(false) - } - }, - ) - }, -) From e819a0f35c315cb14131aed3be63bb576b045383 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:27:24 +0200 Subject: [PATCH 0441/1425] test(start-plugin-core): tell an import-protection mock by safe behaviour, read reports by their sections The mock oracle (A6, IP-S01/S10/S25): mock-helpers.ts probes what a consumer does with a mocked binding (member reads, calls and construction to depth, primitive conversions, no secret) instead of comparing its label; the same probe is serialised for scripts run in another process. The unit rows of the mock modules use it; the pins they duplicated on internals (reserved names, namespace re-exports, enums, a read before the import) live on as app-level twins (integration/importProtection/known-bugs.test.ts). The report adapter (IP-S05): violation-report.ts reads reports by their documented sections (environment type, Importer, Import, Trace, Code with the >/^ convention), per log entry or paragraph, and fails on a diagnostic that announces import protection without them; a unit test feeds it other headers, numbering, quotes, spacing, boxing and plugin tags. IP-S16: usage-location.ts (a copy of the Vite adapter's location fallback order) is deleted; analysis.usage.test.ts checks only whether the Start compiler's output keeps the denied import. IP-S03: the unit row that expected a server-environment mock to stay silent is gone (SPEC J6.3 has no server exemption); the real-adapter rows follow in dev.test.ts. known-bugs-dev-server.test.ts (W3-hmr's file): the oracle import renamed. Co-Authored-By: Claude Fable 5.1 --- .../importProtection/analysis.exports.test.ts | 112 ++------- .../importProtection/analysis.usage.test.ts | 58 +++-- .../tests/importProtection/known-bugs.test.ts | 81 +------ .../importProtection/mock-helpers.test.ts | 129 ++++++++-- .../tests/importProtection/mock-helpers.ts | 167 ++++++++++++- .../tests/importProtection/start-apps.ts | 129 +++++----- .../tests/importProtection/trace.test.ts | 26 +- .../tests/importProtection/usage-location.ts | 83 ------- .../importProtection/violation-report.test.ts | 225 +++++++++++++++++ .../importProtection/violation-report.ts | 228 ++++++++++++++---- .../importProtection/virtualModules.test.ts | 131 ++++------ .../integration/known-bugs-dev-server.test.ts | 6 +- 12 files changed, 873 insertions(+), 502 deletions(-) delete mode 100644 packages/start-plugin-core/tests/importProtection/usage-location.ts create mode 100644 packages/start-plugin-core/tests/importProtection/violation-report.test.ts 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 d0f872cee63..9c247e283da 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts @@ -1,3 +1,12 @@ +/** + * The names the mock modules of import protection expose, in process: every + * name an importer uses from a denied source, every export of a denied file + * (and the import sources of a module). Each exposed name is a safe mock + * (`mock-helpers.ts`). The names the mocks miss (reserved words, namespace + * re-exports, enums and namespaces, a member read before the import) are + * pinned on real apps: integration/importProtection/known-bugs.test.ts, + * "app-level twins of the mock pins". + */ import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' import { describe, expect, test } from 'vitest' @@ -5,7 +14,13 @@ import { getImportSpecifierLocationFromResult, getImportSources, } from '../../src/import-protection/analysis' -import { evaluateWithMock, isMock, mockModules, selfMock } from './mock-helpers' +import { + evaluateWithMock, + expectSafeMock, + isSafeMock, + mockModules, + selfMock, +} from './mock-helpers' // Every name a consumer can import is exposed by each mock module import // protection generates for it (and the module evaluates). @@ -34,30 +49,13 @@ describe('mock modules expose every importable name', () => { async ({ kind, name }) => { const mock = await mockModules[kind]([name]) expect(name in mock).toBe(true) - expect(isMock(mock[name])).toBe(true) + expectSafeMock(mock[name]) }, ) test.each(kinds)('%s exposes several names at once', async (kind) => { const mock = await mockModules[kind](names) - expect(names.filter((name) => !isMock(mock[name]))).toEqual([]) - }) - - // Bug: mock modules declare each mocked name as `export const `, and - // reserved words such as `delete` pass the identifier check - // (`isValidExportName`); `eval` and `arguments` cannot be bound in a module - // either. Same bug as the `delete` pin in known-bugs.test.ts. - // Impact: the mock of a module that exports such a name, or is used - // through one (`api.delete()`), is a SyntaxError, which fails the build or - // the dev page instead of mocking the module. - test.fails.each( - kinds.flatMap((kind) => - ['delete', 'eval', 'arguments'].map((name) => ({ kind, name })), - ), - )('$kind exposes the reserved name "$name"', async ({ kind, name }) => { - const mock = await mockModules[kind]([name]) - expect(name in mock).toBe(true) - expect(isMock(mock[name])).toBe(true) + expect(names.filter((name) => !isSafeMock(mock[name]))).toEqual([]) }) }) @@ -123,7 +121,7 @@ export const values = [(denied).read(), (denied)['write']()]`, }, ])('$name', async ({ importer }) => { const { values } = await evaluateWithMock(importer, 'mod-a') - expect(values.map(isMock)).toEqual(values.map(() => true)) + expect(values.filter((value: unknown) => !isSafeMock(value))).toEqual([]) }) test.each([ @@ -146,7 +144,7 @@ export const used = alpha`, }, ])('$name', async ({ importer, exported }) => { const module = await evaluateWithMock(importer, 'mod-a') - expect(exported.filter((name) => !isMock(module[name]))).toEqual([]) + expect(exported.filter((name) => !isSafeMock(module[name]))).toEqual([]) }) test('imports of several denied sources', async () => { @@ -156,36 +154,8 @@ import { b } from 'mod-b' export const values = [a, b]`, ['mod-a', 'mod-b'], ) - expect(values.map(isMock)).toEqual([true, true]) - }) - - // Best effort: a namespace object cannot expose names it does not know, so - // only the module evaluating is required. - test('a namespace member read with a computed key', async () => { - const module = await evaluateWithMock( - `import * as ns from 'mod-a' -const key = 'epsilon' -export const value = ns[key]`, - 'mod-a', - ) - expect('value' in module).toBe(true) - }) - - // Bug: namespace member reads are collected only after the import - // declaration that binds the namespace, but imports are hoisted: a member - // read earlier in the file (e.g. in a function declared above the import) - // is not exported by the mock edge. - // Impact: the read gets `undefined` instead of the mock, so the importer - // crashes on it in dev (`ns.alpha()` is not a function). - // Source: PR #8504 (analysis.exports.test.ts "collects namespace member - // usage that precedes the import"). - test.fails('a namespace member read before the import', async () => { - const { values } = await evaluateWithMock( - `export const values = [ns.alpha] -import * as ns from 'mod-a'`, - 'mod-a', - ) - expect(values.map(isMock)).toEqual([true]) + expectSafeMock(values[0]) + expectSafeMock(values[1]) }) }) @@ -264,45 +234,15 @@ export { local as "foo-bar" }`, const mock = await selfMock(code) expect(exported.length).toBeGreaterThan(0) expect(Object.keys(mock)).toEqual(expect.arrayContaining(exported)) - expect(exported.filter((name) => !isMock(mock[name]))).toEqual([]) + expect(exported.filter((name) => !isSafeMock(mock[name]))).toEqual([]) }) - // The exports the mock misses (pinned below) must not cost the others. + // The exports the mock misses (pinned on real apps, see the header) 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`). - // 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 `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('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']) - expect(Object.keys(mock)).toEqual(expect.arrayContaining(exported)) - expect(exported.filter((name) => !isMock(mock[name]))).toEqual([]) + expectSafeMock(mock.secret) }) }) 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 b2d48510092..1df794dd6c5 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' import { findPostCompileUsagePos } from '../../src/import-protection/analysis' -import { locationOf, usageLocation } from './usage-location' +import { compileFor, importSources } from '../regression-helpers' function pos(code: string, source: string) { return findPostCompileUsagePos(code, source) @@ -289,15 +289,28 @@ export const later = x` }) }) -// The violation import protection reports for a denied import in a module the -// Start compiler compiled: none when every use is inside code the compiler -// removes from the environment (a safe boundary), so that the compiled module -// no longer imports it, else one at the use that leaks. -describe('usage location after the Start compiler', () => { +// Whether the Start compiler keeps a module's import of a denied source in +// the output of an environment: an import used only inside code the compiler +// removes from the environment (a safe boundary) does not survive, so there +// is no edge for import protection to report (SPEC J6.4); a use the +// compiler keeps does. Where a surviving use is reported is read from real +// apps: integration/importProtection/reported-location.test.ts. +describe('a denied import after the Start compiler', () => { const imports = `import { createServerFn, createServerOnlyFn, createClientOnlyFn, createIsomorphicFn, createMiddleware } from '@tanstack/react-start' import { denied } from 'denied' ` + /** Whether the output of `output` for `code` still imports `denied`. */ + async function survives( + code: string, + output: 'client' | 'ssr' | 'provider', + ) { + const compiled = await compileFor(output, code, { + id: '/test/src/module.tsx', + }) + return importSources(compiled.code ?? code).includes('denied') + } + test.each([ { name: 'createServerFn().handler', @@ -371,9 +384,9 @@ export const fn = Start.createServerOnlyFn(() => denied())`, body: `export const fn = createIsomorphicFn().client((() => denied()))`, }, ] as const)( - '$output: no violation is reported for a use inside $name', + '$output: an import used only inside $name does not survive', async ({ output, body }) => { - expect(await usageLocation(imports + body, output)).toBeUndefined() + expect(await survives(imports + body, output)).toBe(false) }, ) @@ -383,25 +396,20 @@ export const fn = Start.createServerOnlyFn(() => denied())`, output: 'provider', body: `export const fn = createServerFn().handler(async () => denied())`, }, - ] as const)( - '$output: a use inside $name is reported', - async ({ output, body }) => { - const code = imports + body - expect(await usageLocation(code, output)).toEqual( - locationOf(code, 3, 'denied()'), - ) - }, - ) - - test('client: a use after a server fn handler is reported', async () => { - const code = `${imports}export const fn = createServerFn().handler(async () => { + { + name: 'a function after a server fn handler, in the client', + output: 'client', + body: `export const fn = createServerFn().handler(async () => { return denied() }) export function leakyReference() { return denied() -}` - expect(await usageLocation(code, 'client')).toEqual( - locationOf(code, 7, 'denied()'), - ) - }) +}`, + }, + ] as const)( + '$output: an import used inside $name survives', + async ({ output, body }) => { + expect(await survives(imports + body, output)).toBe(true) + }, + ) }) 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 53339544148..78d8bc45dcf 100644 --- a/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/importProtection/known-bugs.test.ts @@ -1,60 +1,20 @@ /** - * Known import-protection bugs. Each test asserts correct behaviour for a bug - * on main and is marked .fails; remove .fails when the bug is fixed. + * Known import-protection bugs of the rewrite of a denied import, in + * process. Each test asserts correct behaviour for a bug on main and is + * marked .fails; remove .fails when the bug is fixed. The bugs of the mock + * modules are pinned on real apps (integration/importProtection/ + * known-bugs.test.ts, "app-level twins of the mock pins"). */ import { transformWithOxc } from 'vite' import { expect, test } from 'vitest' import { rewriteDeniedImports } from '../../src/import-protection/rewrite' -import { evaluateWithMock, isMock, selfMock } from './mock-helpers' +/** A sentinel mock injected to observe the rewrite's wiring alone. */ 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 -} - -// Control for the namespace re-export pin below (same harness). -test('a denied named import evaluates to the mock', async () => { - const exports = await evaluateRewritten( - `import { secret } from 'denied' -export const seen = secret`, - ['denied'], - ) - expect({ ...exports }).toEqual({ seen: 'mock:secret' }) -}) - -// Bug: rewriting a denied `export * as ns from 'denied'` drops the `ns` -// export. -// Impact: importers of `ns` get `undefined` (or a missing export error) -// instead of the mock, unlike every other denied import form. -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((exports.ns as Record | undefined)?.secret).toBe( - 'mock:secret', - ) -}) - /** * Rewrites the denied import of a module with JSX that `prologue` (comments, * directives) leads, and compiles its JSX with React's automatic runtime @@ -116,32 +76,3 @@ test.fails.each(leadingComments)( expect(await rewriteWithPrologue(comment, comment)).toEqual([true, runtime]) }, ) - -// Bug: mock modules declare each mocked name as `export const `, and -// reserved words such as `delete` pass the identifier check. -// Impact: the mock of a denied module that exports a reserved word -// (`export { remove as delete }`) or is used through one (`api.delete()`) is a -// SyntaxError, which fails the build instead of mocking the module. -test.fails( - 'the mock of a denied file exporting `delete` exports it', - async () => { - const mock = await selfMock( - `const remove = () => {} -export { remove as delete }`, - '/test/api.server.ts', - ) - expect('delete' in mock && mock.delete !== undefined).toBe(true) - }, -) - -test.fails( - 'a denied import used as `api.delete()` evaluates to a mock', - async () => { - const module = await evaluateWithMock( - `import * as api from 'denied' -export const result = api.delete()`, - 'denied', - ) - expect(isMock(module.result)).toBe(true) - }, -) diff --git a/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts b/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts index 9ca7a6909ca..00291bfccd9 100644 --- a/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts +++ b/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts @@ -1,19 +1,116 @@ /** - * Controls of the mock helpers (`mock-helpers.ts`): an importer evaluated with - * a denied import gets mocks for exactly what it uses from that import, and - * the self-contained mock of a denied file exposes its exports as mocks. + * Controls of the mock helpers (`mock-helpers.ts`): the safe-mock probe + * accepts the mock import protection serves and rejects real values and + * leaking stand-ins, in this process and serialised into a script; an + * importer evaluated with a denied import gets mocks for exactly what it + * uses from that import, and the self-contained mock of a denied file + * exposes its exports as mocks. */ -import { expect, test } from 'vitest' -import { evaluateWithMock, isMock, selfMock } from './mock-helpers' +import vm from 'node:vm' +import { describe, expect, test } from 'vitest' +import { + describeValue, + describeValuePrelude, + evaluateWithMock, + expectSafeMock, + isSafeMock, + probeMock, + probeMockPrelude, + safeMock, + selfMock, +} from './mock-helpers' -test('evaluateWithMock: every binding the importer uses from the denied source is a mock', async () => { +describe('the safe-mock probe', () => { + /** A stand-in of a mock: a recursive Proxy whose text is `label`. */ + function recursiveProxy(label: string): unknown { + const node = (): unknown => + new Proxy(function () {}, { + get: (_target, prop) => + prop === Symbol.toPrimitive || prop === 'toString' || prop === 'toJSON' + ? () => label + : node(), + apply: () => node(), + construct: () => node() as object, + }) + return node() + } + + test('accepts a recursive proxy, whatever its label', () => { + for (const label of ['[import-protection mock]', 'mock', '']) { + expectSafeMock(recursiveProxy(label), 'MARK_SECRET') + } + }) + + test.each([ + ['a function', () => 'x'], + ['a string', 'x'], + ['a number', 1], + ['undefined', undefined], + ['an object', {}], + ['an object with members', { member: { deeper: 1 } }], + ['a class', class Client {}], + ])('rejects %s', (_name, value) => { + expect(isSafeMock(value)).toBe(false) + expect(probeMock(value)).not.toEqual(safeMock) + }) + + test('rejects a proxy whose primitive conversion leaks the secret', () => { + expect(probeMock(recursiveProxy('MARK_SECRET'), 'MARK_SECRET')).toEqual({ + ...safeMock, + leaked: true, + }) + expect(isSafeMock(recursiveProxy('MARK_SECRET'))).toBe(true) + }) + + test('rejects a mock that throws on construction', () => { + const value = new Proxy(function () {}, { + get: () => value, + apply: () => value, + construct: () => { + throw new Error('not a constructor') + }, + }) + expect(probeMock(value)).toMatchObject({ + read: 'ok', + call: 'ok', + construct: 'threw: not a constructor', + }) + }) + + test('serialised into a script, it gives the same verdicts', () => { + const script = `${describeValuePrelude} +;[ + isSafeMock(mock, 'MARK_SECRET'), + isSafeMock(() => 'x'), + describeValue(mock), + describeValue('MARK_REAL'), + probeMock(leaking, 'MARK_SECRET').leaked, +]` + const context = vm.createContext({ + mock: recursiveProxy('mock'), + leaking: recursiveProxy('MARK_SECRET'), + }) + expect(vm.runInContext(script, context)).toEqual([ + true, + false, + 'a safe mock', + 'MARK_REAL', + true, + ]) + expect(describeValue(recursiveProxy('mock'))).toBe('a safe mock') + }) +}) + +test('evaluateWithMock: every binding the importer uses from the denied source is a safe mock', async () => { const module = await evaluateWithMock( `import def, { a } from 'denied' import * as ns from 'denied' export const values = [def, a, ns.b, ns.c(), a.deep.call()]`, 'denied', ) - expect(module.values.map(isMock)).toEqual([true, true, true, true, true]) + expect(module.values.map((value: unknown) => probeMock(value))).toEqual( + module.values.map(() => safeMock), + ) }) test('evaluateWithMock: only the denied source is mocked; other imports need stubs', async () => { @@ -23,29 +120,21 @@ export const values = [a, b]` const module = await evaluateWithMock(importer, 'denied', { stubs: { allowed: { b: 'real' } }, }) - expect(isMock(module.values[0])).toBe(true) + expectSafeMock(module.values[0]) expect(module.values[1]).toBe('real') await expect(evaluateWithMock(importer, 'denied')).rejects.toThrow( 'No stub for import allowed', ) }) -test('selfMock: the mock of a denied file exposes its exports as mocks', async () => { +test('selfMock: the mock of a denied file exposes its exports as safe mocks', async () => { const mock = await selfMock(`export const a = 1 export function b() {} export default 'x'`) expect(Object.keys(mock)).toEqual( expect.arrayContaining(['a', 'b', 'default']), ) - expect([mock.a, mock.b, mock.default].map(isMock)).toEqual([true, true, true]) -}) - -test('isMock is false for values that are not mocks', () => { - expect([() => 'x', 'x', 1, undefined, {}].map(isMock)).toEqual([ - false, - false, - false, - false, - false, - ]) + for (const value of [mock.a, mock.b, mock.default]) { + expectSafeMock(value) + } }) diff --git a/packages/start-plugin-core/tests/importProtection/mock-helpers.ts b/packages/start-plugin-core/tests/importProtection/mock-helpers.ts index b47c483e283..3b2b304f4a5 100644 --- a/packages/start-plugin-core/tests/importProtection/mock-helpers.ts +++ b/packages/start-plugin-core/tests/importProtection/mock-helpers.ts @@ -1,11 +1,28 @@ /** - * Evaluates the mocks import protection serves in place of a denied module, - * through the same pipeline the plugins use (the names an importer uses from - * a denied source → a mock-edge module importing the mock runtime, or the + * The mock import protection serves in place of a denied module, told by + * what it does, not by what it looks like (SPEC J6.8, §5 item 18): a safe + * mock is a value a consumer can read members of, call and construct, to any + * depth, without an exception, whose primitive conversions do not throw and + * carry no secret of the denied module. Its label, Proxy shape and where its + * runtime lives are the compiler's. + * + * `probeMock` exercises those operations on a value and reports each + * outcome; `safeMock` is the outcome of a safe mock, `isSafeMock` and + * `expectSafeMock` compare with it. A real value fails the probe: a function + * or an object has no member two levels deep, a primitive none at all. + * `probeMockPrelude` defines the same probe in the scripts the build and + * Rsbuild harnesses run in another process or VM context (a mock cannot + * cross a process boundary). Never `await` a mock: the mock runtime is a + * thenable that resolves to itself. + * + * The rest evaluates the mock modules import protection generates, through + * the same pipeline the plugins use (the names an importer uses from a + * denied source → a mock-edge module importing the mock runtime, or the * self-contained mock of a denied file), linked as real ES modules - * (`@tanstack/compiler-test-utils/module-linker`). Tests then assert what an - * importer gets, not the generated code. + * (`@tanstack/compiler-test-utils/module-linker`), so that tests assert what + * an importer gets, not the generated code. */ +import { expect } from 'vitest' import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' import { getMockExportNamesBySource, @@ -23,14 +40,137 @@ import { } from '../../src/vite/import-protection-plugin/virtualModules' import type { ModuleStub } from '@tanstack/compiler-test-utils/module-linker' -/** What a mock returns when it is converted to a primitive. */ -const mockText = '[import-protection mock]' +/** + * What each operation a consumer may perform on a mock did: `ok`, or the + * error it threw (`threw: ...`). + */ +export interface MockProbe { + /** `value.member` */ + read: string + /** `value.member.deeper.deepest` */ + deepRead: string + /** `value()` */ + call: string + /** `value.member()`, `value()()`, `value().member` */ + deepCall: string + /** `new value()`, `new value.member()` */ + construct: string + /** `String(value)`, `${value}`, `value + ''`, `Number(value)` */ + primitive: string + /** `JSON.stringify(value)` */ + json: string + /** The `secret` text appeared in a primitive conversion of a value. */ + leaked: boolean +} + +/** + * Exercises `value` the way a consumer of a mocked import may (member reads + * and calls to a depth of three, construction, primitive conversions) and + * reports each outcome. A primitive conversion whose text contains `secret` + * counts as a leak. Self-contained (no closure), so that scripts run in + * another process can define it too (`probeMockPrelude`). + */ +export function probeMock(value: unknown, secret?: string): MockProbe { + const attempt = (run: () => unknown): string => { + try { + run() + return 'ok' + } catch (error) { + return `threw: ${error instanceof Error ? error.message : String(error)}` + } + } + let leaked = false + const texts = (subject: unknown) => { + const results = [ + String(subject), + `${subject}`, + (subject as string) + '', + String(Number(subject)), + String(JSON.stringify(subject)), + ] + if (secret !== undefined && results.some((text) => text.includes(secret))) { + leaked = true + } + } + const any = value as any + const probe: MockProbe = { + read: attempt(() => any.member), + deepRead: attempt(() => any.member.deeper.deepest), + call: attempt(() => any()), + deepCall: attempt(() => { + any.member() + any()() + any().member + any.member.deeper() + }), + construct: attempt(() => { + new any() + new any.member() + }), + primitive: attempt(() => { + texts(any) + texts(any.member) + texts(any()) + texts(new any()) + }), + json: attempt(() => JSON.stringify(any)), + leaked, + } + probe.leaked = leaked + return probe +} + +/** The outcome of a safe mock: every operation `ok`, nothing leaked. */ +export const safeMock: MockProbe = { + read: 'ok', + deepRead: 'ok', + call: 'ok', + deepCall: 'ok', + construct: 'ok', + primitive: 'ok', + json: 'ok', + leaked: false, +} + +/** Whether `value` is a safe mock (`probeMock(value, secret)` is `safeMock`). */ +export function isSafeMock(value: unknown, secret?: string): boolean { + const probe = probeMock(value, secret) + return ( + !probe.leaked && + Object.keys(probe).every( + (key) => key === 'leaked' || probe[key as keyof MockProbe] === 'ok', + ) + ) +} -/** Whether `value` is a mock of import protection (or one of its members). */ -export function isMock(value: unknown): boolean { - return typeof value === 'function' && String(value) === mockText +/** Fails, listing the operations that threw or leaked, unless `value` is a safe mock. */ +export function expectSafeMock(value: unknown, secret?: string): void { + expect(probeMock(value, secret)).toEqual(safeMock) } +/** + * Defines `probeMock` and `isSafeMock` in a script run elsewhere (the body + * of a build's `run()` script, an Rsbuild client script): the same probe, + * serialised. A script reports `isSafeMock(value, 'MARK_SECRET')` or the + * probe itself; it cannot return the mock. + */ +export const probeMockPrelude = `const probeMock = ${probeMock.toString()} +const isSafeMock = ${isSafeMock.toString()} +` + +/** + * A value's description for a script's result: `a safe mock` for a safe + * mock, else its text (`describeValue` in `probeMockPrelude` scripts does the + * same). + */ +export function describeValue(value: unknown, secret?: string): string { + return isSafeMock(value, secret) ? 'a safe mock' : String(value) +} + +/** `describeValue` for `probeMockPrelude` scripts. */ +export const describeValuePrelude = `${probeMockPrelude}const describeValue = ${describeValue.toString()} +` + /** * Evaluates `importer` with each denied import `source` (one specifier or * several) replaced by the mock edge import protection serves for it: a @@ -105,8 +245,11 @@ function mockEdgeId(names: Array, runtimeId = MOCK_MODULE_ID) { return resolveInternalVirtualModuleId(makeMockEdgeModuleId(names, runtimeId))! } -/** Evaluates a generated mock module, linked to the mock runtime it imports. */ -function evaluateMockModule(code: string) { +/** + * Evaluates a generated mock module as an ES module; what it imports links + * to the virtual modules import protection serves (the mock runtime). + */ +export function evaluateMockModule(code: string): Promise> { const mockId = '/test/mock.js' return createModuleGraph({ modules: { [mockId]: code }, diff --git a/packages/start-plugin-core/tests/importProtection/start-apps.ts b/packages/start-plugin-core/tests/importProtection/start-apps.ts index 32998ed7bb5..e3e5a7202b2 100644 --- a/packages/start-plugin-core/tests/importProtection/start-apps.ts +++ b/packages/start-plugin-core/tests/importProtection/start-apps.ts @@ -1,10 +1,19 @@ /** * Helpers of the import-protection tests that run Start's import protection - * on a real app: a Vite dev server with Start's import-protection Vite - * plugin (after the Start compiler, like the Start plugin orders them), a - * stand-in `react` JSX runtime, and the violations import protection reports - * to the user (dev server warnings, build errors and warnings), read from the - * text it prints. + * on a real app. + * + * `startProtectedDevServer` is the focused adapter harness: a Vite dev + * server with Start's import-protection Vite plugin wired by hand after the + * Start compiler, like the Start plugin orders them, with the environments, + * entries and options a test chooses. It shows what the adapter does; that + * Start's whole plugin wires it (its entries, its provider environment, the + * `importProtection` option of `tanstackStart()`) is checked through + * `startDevServer({ start })` (dev.test.ts, "with Start's whole plugin"). + * + * The violations a run reports are read two ways: structured, as + * `onViolation` receives them (`server.violations`), and as the text the + * user sees, through the adapter of `violation-report.ts` + * (`reportedViolations`, `violationTexts`, `violationReports`). */ import path from 'node:path' import { expect } from 'vitest' @@ -13,9 +22,17 @@ import { startDevServer } from '../dev-server' 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' +import type { ViolationInfo } from '../../src/import-protection/trace' import type { StartDevServerOptions } from '../dev-server' import type { PluginOption } from 'vite' +export { + reportedViolations, + violationReports, + violationTexts, +} from './violation-report' +export type { ReportedViolation } from './violation-report' + export interface ProtectedDevServerOptions { /** * The app's router entry (relative to the app root), registered with @@ -41,17 +58,25 @@ export interface ProtectedDevServerOptions { bundledDev?: StartDevServerOptions['bundledDev'] } +export interface ProtectedDevServer extends DevServer { + /** + * Every violation `onViolation` received, in order (the app's own + * `onViolation`, when it has one, still decides). + */ + violations: Array +} + /** * 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`), and with Start's * entries registered (`options.router`). */ -export function startProtectedDevServer( +export async function startProtectedDevServer( files: Record, importProtection: ImportProtectionOptions = {}, options: ProtectedDevServerOptions = {}, -): Promise { +): Promise { const { router = 'src/entry.ts', providerEnvironment = 'ssr', @@ -66,7 +91,16 @@ export function startProtectedDevServer( ? [] : [{ name: providerEnvironment, type: 'server' as const }]), ] - return startDevServer({ + const violations: Array = [] + const userOnViolation = importProtection?.onViolation + const recording: ImportProtectionOptions = { + ...importProtection, + onViolation: (info) => { + violations.push(info as ViolationInfo) + return userOnViolation?.(info) + }, + } + const server = await startDevServer({ files, bundledDev, config, @@ -77,7 +111,7 @@ export function startProtectedDevServer( plugins: ({ root }) => [ importProtectionPlugin({ getConfig: (() => ({ - startConfig: { importProtection }, + startConfig: { importProtection: recording }, resolvedStartConfig: { srcDirectory: path.join(root, 'src'), routerFilePath: path.join(root, router), @@ -94,6 +128,7 @@ export function startProtectedDevServer( ...plugins, ], }) + return Object.assign(server, { violations }) } /** @@ -134,56 +169,6 @@ export { leak } from './leak' */ 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({ - name: 'react', - type: 'module', - exports: { - './jsx-runtime': './jsx-runtime.js', - './jsx-dev-runtime': './jsx-runtime.js', - }, - }), - 'node_modules/react/jsx-runtime.js': `export const Fragment = Symbol('Fragment') -export const jsx = (type, props) => (typeof type === 'function' ? type(props) : type) -export const jsxs = jsx -export const jsxDEV = jsx -`, -} - -export interface ReportedViolation { - /** `client` or `server`. */ - environment: string - /** - * Where the violation is reported (`Importer:`): the importer relative to - * the app root, with `:line:column` when the report has a location. - */ - importer: string -} - -/** - * The import-protection violations reported in `output` (dev server logs, - * build errors or warnings), in order. - */ -export function reportedViolations( - output: string | Array, -): Array { - const text = Array.isArray(output) ? output.join('\n') : output - return text - .split('[import-protection] Import denied in ') - .slice(1) - .map((report) => ({ - environment: /^(\w+)/.exec(report)![1]!, - importer: /Importer: (\S+)/.exec(report)?.[1] ?? '', - })) -} - -/** The text of each import-protection violation report in `output`, in order. */ -export function violationTexts(output: string | Array): Array { - const text = Array.isArray(output) ? output.join('\n') : output - return text.split('[import-protection] Import denied in ').slice(1) -} - /** * Checks the report of a marker violation (a module marked server-only * imported by the client, or client-only imported by the server): it names @@ -194,18 +179,18 @@ export function violationTexts(output: string | Array): Array { * location is fine (coordinator ruling on audit F ASK-1). */ export function expectMarkerViolation( - report: string, + report: { importer: string; text: string }, locations: { marker: string; use: string }, ) { const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const markedFile = locations.marker.replace(/:\d+$/, '') - expect(/Importer: (\S+)/.exec(report)?.[1]).toMatch( + expect(report.importer).toMatch( new RegExp( `^(?:${escape(locations.marker)}:\\d+|${escape(markedFile)}|${escape(locations.use)})$`, ), ) for (const file of [markedFile, locations.use.replace(/:\d+:\d+$/, '')]) { - expect(report).toContain(file) + expect(report.text).toContain(file) } } @@ -237,3 +222,23 @@ export async function rejection(promise: Promise): Promise { } throw new Error('expected the promise to reject') } + +/** + * The import-protection violations `onViolation` received, as + * ` ` relative to `root` (with `:line:column` + * when located), each distinct value once, in order. + */ +export function receivedViolations( + violations: Array, + root: string, +): Array { + return distinct( + violations.map((info) => { + const file = path.relative(root, info.importer).split(path.sep).join('/') + const location = info.importerLoc + ? `:${info.importerLoc.line}:${info.importerLoc.column}` + : '' + return `${info.envType} ${file}${location}` + }), + ) +} diff --git a/packages/start-plugin-core/tests/importProtection/trace.test.ts b/packages/start-plugin-core/tests/importProtection/trace.test.ts index 874adc0fab1..f354b7df1f6 100644 --- a/packages/start-plugin-core/tests/importProtection/trace.test.ts +++ b/packages/start-plugin-core/tests/importProtection/trace.test.ts @@ -4,6 +4,7 @@ import { buildTrace, formatViolation, } from '../../src/import-protection/trace' +import { violationReports } from './violation-report' import type { ViolationInfo } from '../../src/import-protection/trace' describe('ImportGraph', () => { @@ -211,12 +212,23 @@ describe('formatViolation', () => { const formatted = formatViolation(info, '/project') - expect(formatted).toContain('Import denied in client environment') + // (the report's sections, read by the adapter the app tests use) + expect(violationReports(formatted)).toEqual([ + expect.objectContaining({ + environment: 'client', + importer: 'src/routes/index.tsx', + specifier: '@tanstack/react-start/server', + trace: [ + { at: 'src/main.tsx', entry: true, imports: undefined }, + { + at: 'src/routes/index.tsx', + entry: false, + imports: './routes/index', + }, + ], + }), + ]) expect(formatted).toContain('specifier pattern') - expect(formatted).toContain('@tanstack/react-start/server') - expect(formatted).toContain('src/routes/index.tsx') - expect(formatted).toContain('Trace:') - expect(formatted).toContain('(entry)') }) test('formats file violation correctly', () => { @@ -298,7 +310,9 @@ describe('formatViolation', () => { } const formatted = formatViolation(info, '/project') - expect(formatted).toContain('Import denied in server environment') + expect(violationReports(formatted)).toEqual([ + expect.objectContaining({ environment: 'server' }), + ]) }) test('client-in-server violation with JSX suggests ClientOnly', () => { diff --git a/packages/start-plugin-core/tests/importProtection/usage-location.ts b/packages/start-plugin-core/tests/importProtection/usage-location.ts deleted file mode 100644 index ec0fb0b9672..00000000000 --- a/packages/start-plugin-core/tests/importProtection/usage-location.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Where import protection reports the use of a denied import in a module - * that the Start compiler compiled, the way the Vite plugin does - * (`resolveImporterLocation` in `src/vite/import-protection-plugin/plugin.ts`): - * the module is compiled for `output` by the real Start compiler; a module - * whose compiled code no longer imports the denied source has no violation - * (the edge does not survive compilation); otherwise the violation is reported - * at the first of: the use found in the compiled code mapped back through its - * source map (`findPostCompileUsageLocation`), the unsafe use found in the - * original code (`findOriginalUsageLocation`), the import statement. - * - * The Rsbuild adapter tries the two usage lookups in the opposite order; this - * harness follows Vite. reported-location.test.ts reads the location real - * Vite and Rsbuild apps report. - */ -import { - ImportLocCache, - createImportSpecifierLocationIndex, - findImportStatementLocationFromTransformed, - findOriginalUsageLocation, - findPostCompileUsageLocation, -} from '../../src/import-protection/sourceLocation' -import { compileFor, importSources } from '../regression-helpers' -import type { Output } from '../regression-helpers' -import type { - SourceMapLike, - TransformResultProvider, -} from '../../src/import-protection/sourceLocation' - -const id = '/test/src/module.tsx' - -/** - * The reported location of the violation for `source` in `code` compiled for - * `output` (1-based line and column in `code`), or `undefined` when no - * violation is reported because the compiled code no longer imports `source` - * (e.g. every use is inside code the compiler removes). - */ -export async function usageLocation( - code: string, - output: Output, - source = 'denied', -): Promise<{ line: number; column: number } | undefined> { - const compiled = await compileFor(output, code, { id }) - const compiledCode = compiled.code ?? code - if (!importSources(compiledCode).includes(source)) { - return undefined - } - const provider: TransformResultProvider = { - getTransformResult: () => ({ - code: compiledCode, - map: (compiled.map ?? undefined) as SourceMapLike | undefined, - originalCode: code, - }), - } - const location = - (await findPostCompileUsageLocation(provider, id, source)) || - findOriginalUsageLocation( - provider, - id, - source, - output === 'client' ? 'client' : 'server', - ) || - (await findImportStatementLocationFromTransformed( - provider, - id, - source, - new ImportLocCache(), - createImportSpecifierLocationIndex().find, - )) - if (!location) { - throw new Error(`No location for the surviving import of ${source}`) - } - return { line: location.line, column: location.column } -} - -/** Location of the first `needle` on 1-based line `line` of `code`. */ -export function locationOf(code: string, line: number, needle: string) { - const column = code.split('\n')[line - 1]!.indexOf(needle) - if (column === -1) { - throw new Error(`${needle} is not on line ${line}`) - } - return { line, column: column + 1 } -} diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.test.ts b/packages/start-plugin-core/tests/importProtection/violation-report.test.ts new file mode 100644 index 00000000000..8d1b3ec6b6c --- /dev/null +++ b/packages/start-plugin-core/tests/importProtection/violation-report.test.ts @@ -0,0 +1,225 @@ +/** + * Controls of the report adapter (`violation-report.ts`): it reads the + * reports import protection formats by their documented sections, whatever + * the header says, the numbering, quotes, spacing or boxing; it ignores a + * bundler's plugin tag and the runtime access reports of dev mocks; and it + * fails on a diagnostic that announces import protection without the + * documented sections. + */ +import { describe, expect, test } from 'vitest' +import { formatViolation } from '../../src/import-protection/trace' +import { + reportedViolations, + violationReports, + violationTexts, +} from './violation-report' +import type { ViolationInfo } from '../../src/import-protection/trace' + +const violation: ViolationInfo = { + env: 'client', + envType: 'client', + type: 'file', + behavior: 'mock', + pattern: '**/*.server.*', + specifier: './secret.server', + importer: '/project/src/b.ts', + importerLoc: { line: 4, column: 27 }, + resolved: '/project/src/secret.server.ts', + trace: [ + { file: '/project/src/entry.ts', specifier: './a', line: 2, column: 1 }, + { file: '/project/src/a.ts', specifier: './b', line: 3, column: 1 }, + { file: '/project/src/b.ts', specifier: './secret.server', line: 4, column: 27 }, + ], + snippet: { + lines: [ + ' 2 | // helper', + ' 3 | export const one = 1', + ' > 4 | export const leak = () => secret()', + ' | ^', + ], + highlightLine: 4, + location: '/project/src/b.ts:4:27', + }, +} + +const report = formatViolation(violation, '/project') + +const parsed = { + environment: 'client', + importer: 'src/b.ts:4:27', + specifier: './secret.server', + trace: [ + { at: 'src/entry.ts:2:1', entry: true, imports: './a' }, + { at: 'src/a.ts:3:1', entry: false, imports: './b' }, + { at: 'src/b.ts:4:27', entry: false, imports: './secret.server' }, + ], + code: { + line: 4, + pointsAt: 'secret()', + shows: [ + '// helper', + 'export const one = 1', + 'export const leak = () => secret()', + ], + location: 'src/b.ts:4:27', + }, +} + +describe('the report adapter', () => { + test("reads a report's sections", () => { + expect(violationReports(report)).toEqual([ + { ...parsed, text: expect.stringContaining('Importer:') }, + ]) + expect(reportedViolations(report)).toEqual([ + { environment: 'client', importer: 'src/b.ts:4:27' }, + ]) + expect(violationTexts(report)).toEqual([expect.stringContaining('Code:')]) + }) + + test('reads each report of a log, in order, with the entries around them', () => { + const server = formatViolation( + { ...violation, env: 'ssr', envType: 'server', importer: '/project/src/s.ts' }, + '/project', + ) + expect( + reportedViolations([ + 'info: vite dev server started', + `warn: ${report}`, + 'warn: a warning of another plugin', + `warn: ${server}`, + ]), + ).toEqual([ + { environment: 'client', importer: 'src/b.ts:4:27' }, + { environment: 'server', importer: 'src/s.ts:4:27' }, + ]) + }) + + test.each([ + { + name: 'another header', + text: report.replace( + 'Import denied in client environment', + 'Blocked import in the client environment', + ), + }, + { + name: 'a two-line header', + text: report.replace( + '[import-protection] Import denied in client environment', + '[import-protection] Import denied\nThe client environment cannot import this module', + ), + }, + { + name: 'no blank line after the header', + text: report.replace(/environment\n\n/, 'environment\n'), + }, + { + name: 'other indentation and trace numbering', + text: report + .replace(/^ {4}(\d+)\. /gm, '$1) ') + .replace(/^(?=.)/gm, ' '), + }, + { + name: 'single quotes', + text: report.replace(/"/g, "'"), + }, + { + name: "a bundler's plugin tag before it", + text: `[plugin tanstack-start-core:import-protection] ${report}`, + }, + { + name: 'Rsbuild boxing', + text: report + .split('\n') + .map((line) => ` │ ${line}`) + .join('\n'), + }, + { + name: 'ANSI colours', + text: report.replace('Importer:', '\u001b[33mImporter:\u001b[39m'), + }, + ])('reads the same sections with $name', ({ text }) => { + expect(violationReports(text)).toEqual([ + { ...parsed, text: expect.any(String) }, + ]) + }) + + test('reads two reports of one text, with or without a blank line between them', () => { + const second = formatViolation( + { ...violation, importer: '/project/src/c.ts' }, + '/project', + ) + for (const text of [ + `${report}${second}`, + `${report.trim()}\n${second.trim()}`, + ]) { + expect(reportedViolations(text)).toEqual([ + { environment: 'client', importer: 'src/b.ts:4:27' }, + { environment: 'client', importer: 'src/c.ts:4:27' }, + ]) + } + }) + + test('reads a report without a trace or a code frame', () => { + const bare = formatViolation( + { ...violation, importerLoc: undefined, trace: [], snippet: undefined }, + '/project', + ) + expect(violationReports(bare)).toEqual([ + { + environment: 'client', + importer: 'src/b.ts', + specifier: './secret.server', + trace: [], + code: undefined, + text: expect.any(String), + }, + ]) + }) + + test('ignores the runtime access report of a dev mock', () => { + const access = `[import-protection] Mocked import used in dev client + +Denied import: "./secret.server" +Importer: src/b.ts +Access: secret() (call) + +Trace: + 1. src/entry.ts + +Fix: ...` + expect(reportedViolations([access, report])).toEqual([ + { environment: 'client', importer: 'src/b.ts:4:27' }, + ]) + }) + + test.each([ + { + name: 'without the Importer section', + text: report.replace('Importer:', 'From:'), + }, + { + name: 'without the Import section', + text: report.replace(/^ {2}Import:.*$/m, ''), + }, + { + name: 'whose header names no environment type', + text: report.replace('client environment', 'the browser'), + }, + { + name: 'whose header names both environment types', + text: report.replace( + 'client environment', + 'client environment (not the server)', + ), + }, + ])('fails on an import-protection diagnostic $name', ({ text }) => { + expect(() => violationReports(text)).toThrow(/import-protection/) + }) + + test('a code frame without a highlighted line or caret fails', () => { + expect(() => + violationReports(report.replace(' > 4 |', ' 4 |')), + ).toThrow(/caret/) + }) +}) diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.ts b/packages/start-plugin-core/tests/importProtection/violation-report.ts index d5b1a016945..5c03632ba21 100644 --- a/packages/start-plugin-core/tests/importProtection/violation-report.ts +++ b/packages/start-plugin-core/tests/importProtection/violation-report.ts @@ -1,10 +1,28 @@ /** - * 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 `│`). + * Reads the import-protection violation reports in the text a user sees + * (dev server logs, build errors and warnings, Rsbuild errors boxed with + * `│`) by their documented sections (SPEC J6.5, J6.12), not by their prose: + * the environment type the header names, `Importer:` (`file:line:column`), + * `Import:`, the `Trace:` steps and the `Code:` frame with its `>`/`^` + * convention. Header wording, numbering, quotes and spacing are the + * compiler's (J6.12); a header that announces import protection without + * the documented sections is an unparsed candidate, which throws. + * + * `onViolation` gives the same violations structured (`ViolationInfo`); + * tests that need the user-visible text (what is logged, how often, the + * displayed trace and frame) read it through this adapter. */ +export interface ReportedViolation { + /** `client` or `server`. */ + environment: string + /** + * Where the violation is reported (`Importer:`): the importer relative to + * the app root, with `:line:column` when the report has a location. + */ + importer: string +} + export interface ReportedTraceStep { /** The module, as `file:line:column` when the step has a location. */ at: string @@ -25,57 +43,185 @@ export interface ReportedCodeFrame { location: string } -export interface ViolationReport { - /** `Importer:`, as `file:line:column`. */ - importer: string - /** The numbered trace steps, in order. */ +export interface ViolationReport extends ReportedViolation { + /** The raw specifier (`Import:`). */ + specifier: string + /** The trace steps, in order. */ trace: Array code?: ReportedCodeFrame + /** The whole report. */ + text: string +} + +/** A line a report labels (`Importer: ...`, `Trace:`), by its label. */ +const field = /^\s*([A-Z][A-Za-z ]*):(?:\s+(.*?))?\s*$/ + +/** A label line's value, or `undefined`. */ +function fieldValue(lines: Array, label: string): string | undefined { + for (const line of lines) { + const match = field.exec(line) + if (match && match[1] === label) { + return match[2] ?? '' + } + } + return undefined +} + +/** + * Lines that cannot start a report: labelled lines, list items, numbered + * trace steps, code-frame lines and bare locations. + */ +function isHeader(line: string): boolean { + return ( + line.trim() !== '' && + !field.test(line) && + !/^\s*[-*]\s/.test(line) && + !/^\s*\d+[.)]\s/.test(line) && + !/^\s*>?\s*\d*\s*\|/.test(line) && + !/^\s*\S+:\d+(:\d+)?\s*$/.test(line) + ) +} + +/** + * Splits `output` into candidate report blocks: each log entry of an array + * on its own; within a text, a block starts at a header line (the first + * line of a paragraph that is not a section of a report), or at a line + * announcing import protection once the current block has its `Importer:`. + */ +function blocks(output: string | Array): Array> { + if (Array.isArray(output)) { + return output.flatMap(blocks) + } + const text = output + // Rsbuild boxes the lines of an error with `│` + .replace(/^.*│ ?/gm, '') + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;]*m/g, '') + const lines = text.split('\n') + const result: Array> = [] + let current: Array | undefined + let previousBlank = true + for (const line of lines) { + const announces = + mentionsImportProtection(line) && + current !== undefined && + fieldValue(current, 'Importer') !== undefined + if ((previousBlank && isHeader(line)) || announces) { + current = [] + result.push(current) + } + current?.push(line) + previousBlank = line.trim() === '' + } + return result +} + +function mentionsImportProtection(line: string): boolean { + return /import[- ]protection/i.test(line) +} + +/** The lines of a block before its first labelled line: its header. */ +function headerOf(block: Array): Array { + const end = block.findIndex((line) => field.test(line)) + return block.slice(0, end === -1 ? block.length : end) +} + +/** + * Whether a block announces an import-protection violation: its header + * names import protection and a body follows (a bundler's one-line plugin + * tag before a report is not one), or it has an `Importer:` section. + */ +function isCandidate(block: Array): boolean { + const header = headerOf(block) + const body = block.slice(header.length).some((line) => line.trim() !== '') + return ( + (body && header.some(mentionsImportProtection)) || + fieldValue(block, 'Importer') !== undefined + ) } /** 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) + return blocks(output).filter(isCandidate).flatMap(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]!, +/** `environment` and `importer` of each report in `output`, in order. */ +export function reportedViolations( + output: string | Array, +): Array { + return violationReports(output).map(({ environment, importer }) => ({ + environment, + importer, + })) +} + +/** The text of each report in `output`, in order. */ +export function violationTexts(output: string | Array): Array { + return violationReports(output).map(({ text }) => text) +} + +/** + * Parses one candidate block. A runtime access report of a dev mock + * (`Access:`) is not a violation report; a block that announces import + * protection but lacks the documented sections throws. + */ +function parseReport(block: Array): Array { + const text = block.join('\n') + const importer = fieldValue(block, 'Importer') + const specifier = fieldValue(block, 'Import') + if (fieldValue(block, 'Access') !== undefined) { + return [] + } + if (importer === undefined || specifier === undefined) { + throw new Error( + `an import-protection diagnostic without the documented Importer and Import sections:\n${text}`, + ) + } + const environments = [ + ...new Set(headerOf(block).join(' ').match(/\b(client|server)\b/g) ?? []), + ] + if (environments.length !== 1) { + throw new Error( + `an import-protection report whose header names ${environments.length === 0 ? 'no' : 'more than one'} environment type:\n${text}`, ) - if (!step) { + } + return [ + { + environment: environments[0]!, + importer, + specifier: specifier.replace(/^["'](.*)["']$/, '$1'), + trace: parseTrace(block), + code: parseCodeFrame(block), + text, + }, + ] +} + +/** The steps listed under `Trace:`, in order (their numbering is not checked). */ +function parseTrace(lines: Array): Array { + const trace: Array = [] + const start = lines.findIndex((line) => field.exec(line)?.[1] === 'Trace') + for (let index = start + 1; start !== -1 && index < lines.length; index++) { + const step = + /^\s*(?:\d+[.)]|[-*])?\s*(\S+?)(?:\s+\((entry)\))?(?:\s+\(import\s+["'](.*)["']\))?\s*$/.exec( + lines[index]!, + ) + if (!step || lines[index]!.trim() === '') { 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), + trace.push({ at: step[1]!, entry: !!step[2], imports: step[3] }) } + return trace } +/** + * The frame under `Code:`: the line marked `>` (its code from the `^` + * caret), every shown line's code, and the location printed under it. + */ function parseCodeFrame(lines: Array): ReportedCodeFrame | undefined { - const start = lines.findIndex((line) => line.trim() === 'Code:') + const start = lines.findIndex((line) => field.exec(line)?.[1] === 'Code') if (start === -1) { return undefined } @@ -83,16 +229,16 @@ function parseCodeFrame(lines: Array): ReportedCodeFrame | undefined { for (let index = start + 1; lines[index]?.trim(); index++) { frame.push(lines[index]!) } - const highlighted = frame.findIndex((line) => /^\s*>\s*\d+ \|/.test(line)) + const highlighted = frame.findIndex((line) => /^\s*>\s*\d+\s*\|/.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]), + line: Number(/(\d+)\s*\|/.exec(frame[highlighted]!)![1]), pointsAt: frame[highlighted]!.slice(caret), shows: frame.flatMap( - (line) => /^\s*>?\s*\d+ \| ?(.*)$/.exec(line)?.[1] ?? [], + (line) => /^\s*>?\s*\d+\s*\| ?(.*)$/.exec(line)?.[1] ?? [], ), location: lines diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts index 3c1e17240a3..43efff4244f 100644 --- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts +++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts @@ -1,11 +1,13 @@ /** - * The mock modules import protection serves, evaluated as ES modules: what - * a mock is when used, that the build mock of a denied file stands alone, - * and that the dev mock of a client violation reports each use through the - * browser console. Which names each mock exposes is covered by - * analysis.exports.test.ts ("mock modules expose every importable name"). + * The mock modules import protection serves, evaluated as ES modules and + * linked to the virtual modules the adapter resolves for them: what a mock + * is when used (a safe mock, `mock-helpers.ts`), and that the dev mock of a + * client violation reports each use through the browser console as + * `mockAccess` says. Which names each mock exposes is covered by + * analysis.exports.test.ts ("mock modules expose every importable name"); + * what the mocks of a real app do, by the mock-mode rows of + * integration/importProtection/{build,dev}.test.ts. */ -import { createModuleGraph } from '@tanstack/compiler-test-utils/module-linker' import { afterEach, describe, expect, test, vi } from 'vitest' import { generateDevSelfDenialModule, @@ -14,97 +16,46 @@ import { mockRuntimeModuleIdFromViolation, } from '../../src/import-protection/virtualModules' import { - loadResolvedVirtualModule, - resolveInternalVirtualModuleId, -} from '../../src/vite/import-protection-plugin/virtualModules' -import { evaluateWithMock, isMock } from './mock-helpers' + evaluateMockModule, + evaluateWithMock, + expectSafeMock, +} from './mock-helpers' import type { ViolationInfo } from '../../src/import-protection/trace' -type MockValue = { - toString: () => string - valueOf: () => string - toJSON: () => string -} - -type MockFunction = { - (): MockValue -} - -/** - * Evaluates a generated module as an ES module; its imports link to the - * virtual modules import protection serves (none when `standalone`). - */ -function evaluateGeneratedModule( - code: string, - options: { standalone?: boolean } = {}, -): Promise { - const id = '/test/mock.js' - return createModuleGraph({ - modules: { [id]: code }, - ...(!options.standalone && { - resolve: (specifier: string) => resolveInternalVirtualModuleId(specifier), - load: (virtualId: string) => loadResolvedVirtualModule(virtualId)?.code, - }), - }).import(id) as Promise -} - afterEach(() => { vi.restoreAllMocks() }) describe('the silent mock', () => { - test('supports primitive conversion for called mocks', async () => { - const mod = await evaluateGeneratedModule<{ default: MockFunction }>( - loadSilentMockModule().code, - ) - const value = mod.default() - - expect(String(value)).toBe('[import-protection mock]') - expect(value.toString()).toBe('[import-protection mock]') - expect(value.valueOf()).toBe('[import-protection mock]') - expect(JSON.stringify(value)).toBe('"[import-protection mock]"') + test('is a safe mock, as is what it gives', async () => { + const mod = await evaluateMockModule(loadSilentMockModule().code) + expectSafeMock(mod.default) + expectSafeMock(mod.default()) + expectSafeMock(new mod.default.Client()) }) - // Replaces assertions on the purity annotations in the generated text; the - // build-level effect (an unused mock is dropped from the bundle) is pinned - // in known-bugs.test.ts. - test('links on its own, and evaluating and using it has no side effects', async () => { + // The build-level effect (an unused mock is dropped from the bundle) is + // pinned in integration/importProtection/known-bugs.test.ts. + test('evaluating and using it has no side effects', async () => { const globals = Object.getOwnPropertyNames(globalThis) const output = (['log', 'info', 'warn', 'error'] as const).map((method) => vi.spyOn(console, method).mockImplementation(() => {}), ) - const mod = await evaluateGeneratedModule<{ - default: Record - }>(loadSilentMockModule().code, { standalone: true }) - String(mod.default.anything!()) + const mod = await evaluateMockModule(loadSilentMockModule().code) + String(mod.default.anything()) 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', () => { - // Evaluated with nothing to link to: the build mock imports nothing. - test('supports primitive conversion for named export calls', async () => { - const mod = await evaluateGeneratedModule<{ getSecret: MockFunction }>( - generateSelfContainedMockModule(['getSecret']).code, - { standalone: true }, + test('exposes its exports as safe mocks, as is what they give', async () => { + const mod = await evaluateMockModule( + generateSelfContainedMockModule(['getSecret', 'api']).code, ) - - 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) + expectSafeMock(mod.getSecret) + expectSafeMock(mod.getSecret()) + expectSafeMock(new mod.api.Client()) }) }) @@ -142,8 +93,8 @@ function captureConsole() { } // 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. +// chosen for the violation (`mockAccess`): each use of the mock reports the +// violation in the browser console. describe('the dev mock of a denied file', () => { /** * Evaluates the dev mock of a denied file exporting `getSecret`, for @@ -158,13 +109,22 @@ describe('the dev mock of a denied file', () => { ) { const output = captureConsole() const runtimeId = mockRuntimeModuleIdFromViolation(info, mode, '/project') - const mod = await evaluateGeneratedModule<{ getSecret: MockFunction }>( + const mod = await evaluateMockModule( generateDevSelfDenialModule(['getSecret'], runtimeId).code, ) use(mod) return output() } + test('is a safe mock, whatever mockAccess', async () => { + for (const mode of ['error', 'warn', 'off'] as const) { + await useDevMock(mode, violation, (mod) => { + expectSafeMock(mod.getSecret) + expectSafeMock(mod.getSecret()) + }) + } + }) + test.each([ { mode: 'error', method: 'error' }, { mode: 'warn', method: 'warn' }, @@ -203,15 +163,8 @@ describe('the dev mock of a denied file', () => { }) }) - test.each([ - { name: 'mockAccess off', mode: 'off', info: violation }, - { - name: 'a server environment', - mode: 'error', - info: { ...violation, env: 'ssr', envType: 'server' }, - }, - ] as const)('$name: a use is not reported', async ({ mode, info }) => { - expect(await useDevMock(mode, info)).toEqual({ warn: [], error: [] }) + test('mockAccess off: a use is not reported', async () => { + expect(await useDevMock('off')).toEqual({ warn: [], error: [] }) }) }) diff --git a/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts index 5c5f6894552..324aa0422ec 100644 --- a/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts @@ -15,7 +15,7 @@ import { startDevServer, startServerEntryFiles, } from '../dev-server' -import { isMock } from '../importProtection/mock-helpers' +import { isSafeMock } from '../importProtection/mock-helpers' import type { ImportProtectionPluginOptions } from '../../src/vite/import-protection-plugin/types' const timeout = 30_000 @@ -418,7 +418,7 @@ export const values = [getRequest, getHeader, server.setCookie] async ({ client }) => { const { module } = await evaluateClient({ 'src/c.ts': client }) const { values } = module as { values: Array } - expect(values.map(isMock)).toEqual(values.map(() => true)) + expect(values.map((value: unknown) => isSafeMock(value))).toEqual(values.map(() => true)) }, timeout, ) @@ -448,7 +448,7 @@ export const values = [getRequest, getHeader, server.setCookie] 'src/c.ts': `import { getRequest } from './b'\nexport const v = getRequest\n`, }) expect( - isMock((module as Record).v) || + isSafeMock((module as Record).v) || messages(logs).some((line) => line.includes('export *')), ).toBe(true) }, From 10658b44f8aa6b860cc6b331a89e5d6d001aaf34 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:32:12 +0200 Subject: [PATCH 0442/1425] test(start-plugin-core): the building test of the concurrent block registers cleanup through its own context Vitest's global onTestFinished cannot tell which concurrent test runs. Co-Authored-By: Claude Opus 5.5 --- .../tests/integration/start-build-env-separation.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts b/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts index 0cf93c2ff50..43dc5734c4d 100644 --- a/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts +++ b/packages/start-plugin-core/tests/integration/start-build-env-separation.test.ts @@ -153,7 +153,9 @@ ${callByIdExports}`, ) // Concurrent: the rows share one build and run it in Node processes and - // browser pages; only the last test builds (the one build in the block). + // browser pages. The last test builds: runs give their working directory + // and environment explicitly, so the build's process-global state does not + // reach them. describe.concurrent('server code of each construct', () => { /** * `src/deps/.ts`: an export returning `marker`, in a module whose @@ -527,7 +529,7 @@ const next = (options) => options // compiler load another module, here the builder's). test( 'each server fn of the client runs by its id in the SSR build', - async () => { + async ({ onTestFinished }) => { const fnFiles = [ 'src/deps/handlerDep.ts', 'src/deps/baseHandlerDep.ts', @@ -554,6 +556,8 @@ const next = (options) => options ${callByIdExports}`, }, entry: { client: 'src/fns.ts', ssr: 'src/ssr-entry.ts' }, + // This test's own hook: the tests of the block run concurrently. + registerCleanup: onTestFinished, }) const ids = ( await client.run( From 3712f3acb7f844d44f7fae0cd628e6fa7c799b28 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:32:38 +0200 Subject: [PATCH 0443/1425] test(router-plugin): D11 route-factory HMR rows accept the ruling's either/or; page reports warnings Ruling R-09 / D11 (DECISIONS.md 2026-10-17): a route created inside a function of its file is unsupported but user-fixable; tests accept either a correct result (here: the edit takes effect, in place or after a reload, per the function-call ruling) or a warning naming the file in every mode. Silent wrong output fails. `openRouteHmrPage` gains `warnings()`: the dev server's logger (Vite `logs`, Rsbuild compile warnings), what the server's process logged with `console.warn` about a file of the app (one shared wrapper, listeners per page, so concurrent pages do not race on restores), and what the page logged as a warning (the compiler puts its warning into the served module). A harness control shows the channel on both bundlers with the compiler's real exported-component warning. The known-bugs D11 pin ("an edit of a route a helper function creates shows its new component, or the file is reported") and the loose route-factory rows of route-hmr-browser-plugin.test.ts pass when the edit takes effect without errors or a warning names `routes/index.tsx`. Main does neither on Vite and Rsbuild (stale `helper v1 data v2`, no warning): the pin stays `.fails`, verified red in a run without it. The Rsbuild unsplit Solid pin is untouched (no ruling covers it). Co-Authored-By: Claude Fable 5.1 --- .../integration/known-bugs-route-hmr.test.ts | 59 ++++++++++------- .../integration/regression-helpers.test.ts | 22 +++++++ .../route-hmr-browser-plugin.test.ts | 26 +++++--- .../router-plugin/tests/route-hmr-page.ts | 63 +++++++++++++++++++ 4 files changed, 142 insertions(+), 28 deletions(-) diff --git a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts index 7bfa14a6c32..79905dcd9e3 100644 --- a/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts +++ b/packages/router-plugin/tests/integration/known-bugs-route-hmr.test.ts @@ -315,30 +315,30 @@ function Page() { ) // Bug: a route a helper function of the route file creates - // (`export const Route = makeRoute()`, decision D11) edited without - // automatic code splitting (the route HMR plugin alone) shows its new - // loader data but keeps rendering the old inline component: the route HMR - // code keeps the previous component for React Refresh to patch, but the - // component inside the helper is neither hoisted nor registered. With + // (`export const Route = makeRoute()`) edited without automatic code + // splitting (the route HMR plugin alone) shows its new loader data but + // keeps rendering the old inline component, and nothing warns: the route + // HMR code keeps the previous component for React Refresh to patch, but + // the component inside the helper is neither hoisted nor registered. With // automatic code splitting, and for an IIFE, the edit shows // (route-hmr-browser-plugin.test.ts). - // Impact: edits of such a route's component do not show until a reload. + // Impact: edits of such a route's component do not show until a reload, + // silently. // Source: NEW (W2-g, found by the real page; the former in-process model // compared a component whose text did not change between versions). + // Ruling (R-09 / D11, 2026-10-17): same-file route factories are + // unsupported but user-fixable. Any fix passes: the edit takes effect (in + // place or after a reload, without errors), or the compiler warns, naming + // the route file, through a real channel (the dev server's logger or + // console, or the served module). Silent wrong output fails. test.fails.for(['vite', 'rsbuild'] as const)( - '%s: an edit of a route a helper function creates shows its new component in place (autoCodeSplitting: false)', + '%s: an edit of a route a helper function creates shows its new component, or the file is reported (autoCodeSplitting: false)', async ( bundler: 'vite' | 'rsbuild', { expect, onTestFinished }: TestContext, ) => { - expect( - await editRoute( - { - bundler, - pluginOptions: { autoCodeSplitting: false }, - file: 'routes/index.tsx', - route: (version) => - `import { createFileRoute } from '@tanstack/react-router' + const route = (version: string) => + `import { createFileRoute } from '@tanstack/react-router' function makeRoute() { return createFileRoute('/')({ loader: () => 'data ${version}', @@ -346,12 +346,29 @@ function makeRoute() { }) } export const Route = makeRoute() -`, - shows: (version) => `helper ${version} data ${version}`, - }, - onTestFinished, - ), - ).toEqual(inPlace) +` + const app = await openRouteHmrPage({ + bundler, + onTestFinished, + pluginOptions: { autoCodeSplitting: false }, + files: { 'routes/index.tsx': route('v1') }, + ready: 'helper v1 data v1', + }) + const reported = () => + app.warnings().some((warning) => warning.includes('routes/index.tsx')) + let result + try { + result = await app.edit( + { 'routes/index.tsx': route('v2') }, + 'helper v2 data v2', + ) + } catch (error) { + if (reported()) { + return + } + throw error + } + expect(result.errors).toEqual([]) }, ) diff --git a/packages/router-plugin/tests/integration/regression-helpers.test.ts b/packages/router-plugin/tests/integration/regression-helpers.test.ts index 441f20297f7..8dab1680fd3 100644 --- a/packages/router-plugin/tests/integration/regression-helpers.test.ts +++ b/packages/router-plugin/tests/integration/regression-helpers.test.ts @@ -105,6 +105,28 @@ export const Route = createFileRoute('/')({ component: Page }) expect(app.loads()).toBe(loads + 1) }, 60_000) + // The compiler warns about a route whose split component the module + // also exports (kept in the route module), naming the file: the page + // reports it from the real channels (the dev server's process console, + // the served module's warning in the page). + it("reports the warnings of the dev server naming the app's route file", async () => { + const app = await openRouteHmrPage({ + bundler, + files: { + 'routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' +export function Page() { + return

    exported page

    +} +export const Route = createFileRoute('/')({ component: Page }) +`, + }, + ready: 'exported page', + }) + expect( + app.warnings().some((warning) => warning.includes('routes/index.tsx')), + ).toBe(true) + }, 60_000) + it('fails an edit the page applied without showing it, without waiting for its timeout', async () => { const app = await openRouteHmrPage({ bundler, diff --git a/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts b/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts index b351fcda853..7cc953e4f32 100644 --- a/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts +++ b/packages/router-plugin/tests/integration/route-hmr-browser-plugin.test.ts @@ -170,8 +170,10 @@ export const Route = ${call} // decision D11): it is either split like a module-scope route and // hot-updated in place, or left unsplit with a warning naming the file // (code-splitter-plugin-pipeline.test.ts). Either way, an edit shows its -// new loader data and component: in place, or after a reload. (A helper -// function without automatic code splitting keeps its old component: +// new loader data and component, in place or after a reload, or the +// compiler warns naming the route file through a real channel (the dev +// server's logger or console, or the served module). (A helper function +// without automatic code splitting keeps its old component silently: // pinned in known-bugs-route-hmr.test.ts.) describe.concurrent.each(['vite', 'rsbuild'] as const)( '%s: an edit of a route created inside', @@ -232,11 +234,21 @@ export const Route = (() => files: { 'routes/index.tsx': route('v1') }, ready: `${shape} v1 data v1`, }) - const { errors } = await app.edit( - { 'routes/index.tsx': route('v2') }, - `${shape} v2 data v2`, - ) - expect(errors).toEqual([]) + const reported = () => + app.warnings().some((warning) => warning.includes('routes/index.tsx')) + let result + try { + result = await app.edit( + { 'routes/index.tsx': route('v2') }, + `${shape} v2 data v2`, + ) + } catch (error) { + if (reported()) { + return + } + throw error + } + expect(result.errors).toEqual([]) }, ) }, diff --git a/packages/router-plugin/tests/route-hmr-page.ts b/packages/router-plugin/tests/route-hmr-page.ts index 3c76b085f6f..f29b5d3c784 100644 --- a/packages/router-plugin/tests/route-hmr-page.ts +++ b/packages/router-plugin/tests/route-hmr-page.ts @@ -222,11 +222,22 @@ export interface RouteHmrPage { * `window.__TSR_ROUTER__`) and returns its JSON value. */ evaluate: (script: string) => Promise + /** + * Warnings reported so far, through the real channels: the dev server's + * logger, what the dev server's process logged with `console.warn` about + * a file of this app, and what the page logged as a warning (e.g. a + * warning the compiler put into the served module). + */ + warnings: () => Array } /** The part of a dev server the page needs. */ interface AppServer { url: string + /** The app's root on disk. */ + root: string + /** Warnings the dev server's logger reported so far. */ + warnings: () => Array /** * Writes files for the server's watcher and waits until the server * handled them (Vite: ran the HMR hooks for each file; Rsbuild: finished @@ -262,6 +273,32 @@ const quietTime = 100 const defaultEditTimeout = 15_000 +/** + * What the test process logs with `console.warn` while dev servers run in + * it (the compiler warns there, e.g. about a route it leaves unsplit), given + * to every open page's listener. One wrapper for the process, installed on + * first use and never removed: pages open and close concurrently. + */ +const consoleWarnListeners = new Set<(message: string) => void>() +let consoleWarnWrapped = false +function listenToConsoleWarn(listener: (message: string) => void) { + if (!consoleWarnWrapped) { + consoleWarnWrapped = true + const warn = console.warn.bind(console) + console.warn = (...args: Array) => { + warn(...args) + const message = args.map(String).join(' ') + for (const listen of consoleWarnListeners) { + listen(message) + } + } + } + consoleWarnListeners.add(listener) + return () => { + consoleWarnListeners.delete(listener) + } +} + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -281,6 +318,15 @@ export const Route = createRootRoute({}) `, ...options.files, } + const processWarnings: Array = [] + // Listening before the server starts: its first compile may warn + const stopListening = listenToConsoleWarn((message) => { + processWarnings.push(message) + }) + ;(options.onTestFinished ?? onTestFinished)(() => { + stopListening() + return Promise.resolve() + }) const server = bundler === 'vite' ? await viteServer(target, files, options) @@ -413,6 +459,13 @@ export const Route = createRootRoute({}) } }, evaluate: (script) => page.page.evaluate(script), + warnings: () => [ + ...server.warnings(), + ...processWarnings.filter((message) => message.includes(server.root)), + ...page.console + .filter((message) => message.startsWith('warning: ')) + .map((message) => message.slice('warning: '.length)), + ], } } @@ -509,6 +562,11 @@ async function viteServer( idle: () => server.idle(), updates: clientMessages('update'), fullReloads: clientMessages('full-reload'), + root: server.root, + warnings: () => + server.logs + .filter((log) => log.startsWith('warn: ')) + .map((log) => log.slice('warn: '.length)), } } @@ -547,12 +605,15 @@ async function rsbuildServer( ], }) let updates = 0 + /** Warnings of every compile so far (`server.warnings` holds the last). */ + const allWarnings = [...server.warnings] return { url: server.url!, write: async (edited) => { // Until the compile that includes the edit ended; its hot-update // chunks are what the page applies await server.edit(edited) + allWarnings.push(...server.warnings) if (Object.keys(await server.hotUpdates()).length > 0) { updates++ } @@ -561,5 +622,7 @@ async function rsbuildServer( updates: () => updates, // Rsbuild's client reloads the page itself: the page's loads tell fullReloads: () => 0, + root: server.root, + warnings: () => [...allWarnings], } } From 0d437e6c27910fe597617ca49522eb53c4039000 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:46:45 +0200 Subject: [PATCH 0444/1425] test(start-plugin-core): import-protection app tests on the safe-mock oracle and the report adapter build.test.ts (IP-S02, S20): mock-mode rows describe the import (a safe mock, no initializer run, no real code) with mockAccess 'off' for the silence; new rows pin mockAccess error/warn/default in a production mock-mode build (silent self-contained mocks today). The ignored-importer marker rows read the blamed importer from onViolation (Rsbuild blames the exempt importer through its BFS chain), split the no-report and real-module outcomes (the latter pinned on both bundlers), and add a mixed mock-mode row (exempt real, other mocked, pinned). dev.test.ts (IP-S03, S17, S18, S27, V4 timing pin): the React stand-in is gone (real React from the package's dependencies); the MDX stand-in is the real @mdx-js/rollup; mockAccess error/warn rows for an SSR import of a .client file (silent server mocks today, pinned); a wiring row through startDevServer({ start }) for onViolation and mockAccess; the marker-add pin split into a report pin and a mock pin (FOLLOWUPS H5); the pre-transform false-warning pin reproduced deterministically with an ordering plugin that holds the factory's client transform until the helper's landed. known-bugs.test.ts (IP-S13, S15, S25): the unused-mock pin compares the client bundle with the one import protection disabled gives; twins for eval/arguments export names and ns[key]() of a denied file and specifier (the latter pinned on Vite). exemptions, build-options, reported-location (an SSR/provider case, IP-S16), rsbuild (one server for the seven importer types, compile waits no shorter than the test timeout) follow. Co-Authored-By: Claude Fable 5.1 --- .../importProtection/violation-report.test.ts | 8 + .../importProtection/violation-report.ts | 37 +- .../importProtection/build-options.test.ts | 28 +- .../importProtection/build.test.ts | 320 +++++++++++++--- .../integration/importProtection/dev.test.ts | 358 +++++++++++++----- .../importProtection/exemptions.test.ts | 70 ++-- .../importProtection/known-bugs.test.ts | 246 ++++++++---- .../reported-location.test.ts | 90 ++++- .../rsbuild/import-protection.test.ts | 116 ++++-- 9 files changed, 961 insertions(+), 312 deletions(-) diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.test.ts b/packages/start-plugin-core/tests/importProtection/violation-report.test.ts index 8d1b3ec6b6c..e1548d6830f 100644 --- a/packages/start-plugin-core/tests/importProtection/violation-report.test.ts +++ b/packages/start-plugin-core/tests/importProtection/violation-report.test.ts @@ -127,6 +127,14 @@ describe('the report adapter', () => { name: "a bundler's plugin tag before it", text: `[plugin tanstack-start-core:import-protection] ${report}`, }, + { + name: "a Vite build error's plugin tag and error class before it", + text: `[plugin tanstack-start-core:import-protection]\nRolldownError: ${report}`, + }, + { + name: "a dev server log's level before it", + text: `warn: ${report}`, + }, { name: 'Rsbuild boxing', text: report diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.ts b/packages/start-plugin-core/tests/importProtection/violation-report.ts index 5c03632ba21..c0b9eecf7fd 100644 --- a/packages/start-plugin-core/tests/importProtection/violation-report.ts +++ b/packages/start-plugin-core/tests/importProtection/violation-report.ts @@ -53,15 +53,24 @@ export interface ViolationReport extends ReportedViolation { text: string } -/** A line a report labels (`Importer: ...`, `Trace:`), by its label. */ -const field = /^\s*([A-Z][A-Za-z ]*):(?:\s+(.*?))?\s*$/ +/** + * A line a report labels (`Importer: ...`, `Trace:`): its label and value. + * A bundler's error-class prefix (`RolldownError:`) is not one. + */ +function fieldOf(line: string): { label: string; value: string } | undefined { + const match = /^\s*([A-Z][A-Za-z ]*):(?:\s+(.*?))?\s*$/.exec(line) + if (!match || /^\w*(Error|Warning)$/.test(match[1]!)) { + return undefined + } + return { label: match[1]!, value: match[2] ?? '' } +} /** A label line's value, or `undefined`. */ function fieldValue(lines: Array, label: string): string | undefined { for (const line of lines) { - const match = field.exec(line) - if (match && match[1] === label) { - return match[2] ?? '' + const field = fieldOf(line) + if (field?.label === label) { + return field.value } } return undefined @@ -74,7 +83,7 @@ function fieldValue(lines: Array, label: string): string | undefined { function isHeader(line: string): boolean { return ( line.trim() !== '' && - !field.test(line) && + !fieldOf(line) && !/^\s*[-*]\s/.test(line) && !/^\s*\d+[.)]\s/.test(line) && !/^\s*>?\s*\d*\s*\|/.test(line) && @@ -85,8 +94,8 @@ function isHeader(line: string): boolean { /** * Splits `output` into candidate report blocks: each log entry of an array * on its own; within a text, a block starts at a header line (the first - * line of a paragraph that is not a section of a report), or at a line - * announcing import protection once the current block has its `Importer:`. + * line of a paragraph that is not a section of a report, or any line + * announcing import protection). */ function blocks(output: string | Array): Array> { if (Array.isArray(output)) { @@ -102,11 +111,7 @@ function blocks(output: string | Array): Array> { let current: Array | undefined let previousBlank = true for (const line of lines) { - const announces = - mentionsImportProtection(line) && - current !== undefined && - fieldValue(current, 'Importer') !== undefined - if ((previousBlank && isHeader(line)) || announces) { + if (isHeader(line) && (previousBlank || mentionsImportProtection(line))) { current = [] result.push(current) } @@ -122,7 +127,7 @@ function mentionsImportProtection(line: string): boolean { /** The lines of a block before its first labelled line: its header. */ function headerOf(block: Array): Array { - const end = block.findIndex((line) => field.test(line)) + const end = block.findIndex((line) => fieldOf(line)) return block.slice(0, end === -1 ? block.length : end) } @@ -202,7 +207,7 @@ function parseReport(block: Array): Array { /** The steps listed under `Trace:`, in order (their numbering is not checked). */ function parseTrace(lines: Array): Array { const trace: Array = [] - const start = lines.findIndex((line) => field.exec(line)?.[1] === 'Trace') + const start = lines.findIndex((line) => fieldOf(line)?.label === 'Trace') for (let index = start + 1; start !== -1 && index < lines.length; index++) { const step = /^\s*(?:\d+[.)]|[-*])?\s*(\S+?)(?:\s+\((entry)\))?(?:\s+\(import\s+["'](.*)["']\))?\s*$/.exec( @@ -221,7 +226,7 @@ function parseTrace(lines: Array): Array { * caret), every shown line's code, and the location printed under it. */ function parseCodeFrame(lines: Array): ReportedCodeFrame | undefined { - const start = lines.findIndex((line) => field.exec(line)?.[1] === 'Code') + const start = lines.findIndex((line) => fieldOf(line)?.label === 'Code') if (start === -1) { return undefined } diff --git a/packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts b/packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts index d3140ec9166..2da4b538a64 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/build-options.test.ts @@ -7,14 +7,14 @@ */ import { describe, expect, test } from 'vitest' import { buildStart, startBundlers } from '../../build-harness' +import { describeValuePrelude } from '../../importProtection/mock-helpers' import { distinct, expectMarkerViolation, rejection, reportedViolations, - violationTexts, + violationReports, } from '../../importProtection/start-apps' -import { violationReports } from '../../importProtection/violation-report' import type { ImportProtectionOptions } from '../../../src/schema' const timeout = 60_000 @@ -248,10 +248,11 @@ export const read = () => getRequest() }, }), ) - const reports = violationTexts(error.message) + const reports = violationReports(error.message) expect(reports).not.toEqual([]) for (const report of reports) { - expect(report).toMatch(/Denied by [^\n]*\/\^db-lib\$\//) + // (the rule that matched is in the report) + expect(report.text).toContain('/^db-lib$/') } }, timeout, @@ -372,9 +373,9 @@ export const secret = () => 'MARK_SECRET' start: { importProtection: { client: { excludeFiles: [] } } }, }), ) - const [report, ...others] = violationTexts(error.message) + const [report, ...others] = violationReports(error.message) expect(others).toEqual([]) - expect(report).toMatch(/^client environment/) + expect(report!.environment).toBe('client') expectMarkerViolation(report!, { marker: 'node_modules/db-lib/index.js:1', use: 'src/entry.ts:2:27', @@ -488,7 +489,7 @@ export const read = () => getRequest() ) test( - 'in mock mode, it gets each import of a denied specifier with the file the specifier resolves to; the mocks are silent', + 'in mock mode, it gets each import of a denied specifier with the file the specifier resolves to; the mocks are safe (and silent with mockAccess off)', async () => { const seen: Array = [] const { client } = await buildStart({ @@ -499,10 +500,10 @@ export const read = () => getRequest() '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() +export const a = () => [secret(), mail(), b()] `, 'src/b.ts': `import { secret } from 'db-lib' -export const b = () => String(secret()) +export const b = () => secret() `, 'src/entry.ts': `import { a } from './a' export const read = () => a() @@ -512,6 +513,7 @@ export const read = () => a() start: { importProtection: { behavior: 'mock', + mockAccess: 'off', client: { specifiers: ['db-lib', 'mail-lib'] }, onViolation: (info) => { seen.push(info as Violation) @@ -535,9 +537,11 @@ export const read = () => a() '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]', + expect( + await client.run(`${describeValuePrelude} +return entry.read().map((value) => describeValue(value, 'MARK_SECRET'))`), + ).toMatchObject({ + result: ['a safe mock', 'a safe mock', 'a safe mock'], logs: [], }) }, diff --git a/packages/start-plugin-core/tests/integration/importProtection/build.test.ts b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts index 43c1fcba5eb..4247844320b 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/build.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts @@ -2,16 +2,22 @@ * Import protection in a real Start build (Vite and Rsbuild): what survives * into the client bundle is reported, and invalid markers fail the build. * Import protection checks the modules of the source directory (`src`). + * A mock is told by what it does (`mock-helpers.ts`, `describeValue`: + * `a safe mock`), and a report by its sections (`violation-report.ts`). */ +import path from 'node:path' import { describe, expect, test } from 'vitest' import { buildStart, startBundlers } from '../../build-harness' +import { describeValuePrelude } from '../../importProtection/mock-helpers' import { + distinct, expectMarkerViolation, rejection, reportedViolations, - violationTexts, + violationReports, } from '../../importProtection/start-apps' import type { TanStackStartInputConfig } from '../../../src/schema' +import type { ViolationInfo } from '../../../src/import-protection/trace' const timeout = 60_000 @@ -50,6 +56,29 @@ export const secret = () => 'MARK_SECRET' `, } +/** + * `files` with each module recording that it ran (`globalThis.secretLoaded`), + * after its marker import when it has one. For the mock-mode rows: a mock + * runs no initializer of the denied module. (A side effect keeps a module + * in a bundle, so the tree-shaking rows use the modules as they are.) + */ +function recording(files: Record): Record { + const record = `;(globalThis as any).secretLoaded = 'MARK_SECRET_LOADED'\n` + return Object.fromEntries( + Object.entries(files).map(([file, code]) => { + if (!/\.[cm]?[jt]sx?$/.test(file) || code.includes('secretLoaded')) { + return [file, code] + } + return [ + file, + code.startsWith('import ') + ? code.replace('\n', `\n${record}`) + : record + code, + ] + }), + ) +} + /** An app whose client entry `src/entry.ts` uses `denied`. */ function appUsing(denied: DeniedModule) { const { importedAs, files } = deniedModules[denied] @@ -84,6 +113,14 @@ async function clientRead( } } +/** The one report of a failed build, read by its sections. */ +async function failureReport(build: Promise) { + const error = await rejection(build) + const [report, ...others] = violationReports(error.message) + expect(others).toEqual([]) + return report! +} + describe.each(startBundlers)('%s build', (bundler) => { // A denied pure re-export barrel whose export the client uses survives // into the client bundle (as the code it re-exports), so it must be @@ -93,7 +130,7 @@ describe.each(startBundlers)('%s build', (bundler) => { test( 'a denied re-export barrel the client uses fails the build', async () => { - await expect( + const report = await failureReport( buildStart({ bundler, files: { @@ -104,7 +141,9 @@ describe.each(startBundlers)('%s build', (bundler) => { entry: 'src/entry.ts', start: importProtection(['**/barrel.ts']), }), - ).rejects.toThrow(/Import denied in client environment/) + ) + expect(report.environment).toBe('client') + expect(report.importer).toMatch(/^src\/entry\.ts/) }, timeout, ) @@ -182,17 +221,15 @@ export const read = () => getRequest() test( 'a client import of a server-only-marked module fails the build, naming the module and its importer', async () => { - const error = await rejection( + const report = await failureReport( buildStart({ bundler, files: appUsing('a server-only-marked module'), entry: 'src/entry.ts', }), ) - const [report, ...others] = violationTexts(error.message) - expect(others).toEqual([]) - expect(report).toMatch(/^client environment/) - expectMarkerViolation(report!, { + expect(report.environment).toBe('client') + expectMarkerViolation(report, { marker: 'src/marked.ts:1', use: 'src/entry.ts:2:27', }) @@ -203,7 +240,7 @@ export const read = () => getRequest() test( 'a server import of a client-only-marked module fails the build, naming the module and its importer', async () => { - const error = await rejection( + const report = await failureReport( buildStart({ bundler, files: { @@ -216,10 +253,8 @@ export const read = () => secret() entry: { client: 'src/client.ts', ssr: 'src/server.ts' }, }), ) - const [report, ...others] = violationTexts(error.message) - expect(others).toEqual([]) - expect(report).toMatch(/^server environment/) - expectMarkerViolation(report!, { + expect(report.environment).toBe('server') + expectMarkerViolation(report, { marker: 'src/marked.ts:1', use: 'src/server.ts:2:27', }) @@ -261,11 +296,11 @@ export const read = () => secret() + '|' + d() 'MARK_SECRET|MARK_SECRET', ) expect( - [ + reportedViolations([ ...built.client.warnings, ...built.ssr.warnings, ...built.client.consoleMessages, - ].filter((message) => message.includes('import-protection')), + ]), ).toEqual([]) }, timeout, @@ -325,7 +360,10 @@ export const read = () => label() }) // `behavior: 'mock'` builds: a denied import is replaced with a mock (and -// a warning); the real module's code stays out of the bundle. +// a warning); the real module's code stays out of the bundle and its +// initializer never runs; `mockAccess` says whether using the mock is +// reported in the console (SPEC J6.3: it applies wherever a mock is served, +// a production build included; `off` for silence). describe.each(startBundlers)('%s build, mock mode', (bundler) => { interface MockedImport { /** The specifier `src/entry.ts` imports `secret` with. */ @@ -339,14 +377,19 @@ describe.each(startBundlers)('%s build, mock mode', (bundler) => { /** * What the environment that imports `secret` gets for it in a mock-mode - * build. + * build with `mockAccess`: whether a warning was reported, whether the + * real code is in the bundle and its initializer ran, what the import is + * (`a safe mock`), and what using it (a call) logged, by console method. */ - async function mockedRead(denied: MockedImport) { + async function mockedRead( + denied: MockedImport, + mockAccess: 'error' | 'warn' | 'off' | undefined = 'off', + ) { const env = denied.env ?? 'client' const built = await buildStart({ bundler, files: { - ...denied.files, + ...recording(denied.files), 'src/entry.ts': `${denied.imports ?? ''}import { secret } from '${denied.importedAs}' export const read = () => secret `, @@ -356,32 +399,49 @@ export const read = () => secret env === 'client' ? 'src/entry.ts' : { client: 'src/other.ts', ssr: 'src/entry.ts' }, - start: { importProtection: { behavior: 'mock' } }, + start: { importProtection: { behavior: 'mock', mockAccess } }, }) - const { result, logs } = await built[env].run(`const secret = entry.read() -return String(secret())`) + const { result } = await built[env].run(`${describeValuePrelude} +const logged = [] +for (const level of ['warn', 'error']) { + console[level] = (...args) => { + logged.push(level + ': ' + (args[0] instanceof Error ? args[0].message : String(args[0]))) + } +} +const secret = entry.read() +const read = describeValue(secret, 'MARK_SECRET') +secret() +return { read, ran: globalThis.secretLoaded ?? null, logged }`) + const { read, ran, logged } = result as { + read: string + ran: unknown + logged: Array + } return { 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, + ran, + read, + /** The console methods a use of the mock logged with, each once. */ + loggedWith: distinct(logged.map((entry) => entry.split(':')[0]!)), } } - /** A mock converts to this text; the mocks of a build are silent. */ + /** A safe mock, silent with `mockAccess: 'off'`; the build warns. */ const mocked = { warned: true, hasRealCode: false, - read: '[import-protection mock]', - logs: [], + ran: null, + read: 'a safe mock', + loggedWith: [], } const deniedFile = (extension: string): MockedImport => ({ importedAs: `./secret.server.${extension}`, files: { - [`src/secret.server.${extension}`]: `export const secret = () => 'MARK_SECRET'\n`, + [`src/secret.server.${extension}`]: `globalThis.secretLoaded = 'MARK_SECRET_LOADED' +export const secret = () => 'MARK_SECRET' +`, }, }) @@ -407,6 +467,31 @@ return String(secret())`) timeout, ) + // Bug: a mock-mode build serves self-contained mocks that never report + // their use (`generateSelfContainedMockModule`, import-protection/ + // virtualModules.ts; the Vite transform's `isBuild` branch and the Rsbuild + // loader), whatever `mockAccess` says; only dev mocks carry the runtime + // diagnostic (`mockRuntimeModuleIdFromViolation`). `mockAccess` is + // documented for `behavior: 'mock'` without a dev restriction + // (schema.ts), and SPEC J6.3 applies it wherever a mock is served. + // Impact: in a production build with `behavior: { build: 'mock' }`, code + // that runs on a mocked import does so without any console diagnostic; + // the warning at build time is the only trace of it. + // Source: V1 ip S02 (the rows above demanded silence with the default). + test.fails.each([ + { mockAccess: undefined, method: 'error' }, + { mockAccess: 'error', method: 'error' }, + { mockAccess: 'warn', method: 'warn' }, + ] as const)( + 'with mockAccess $mockAccess, using the mock of a denied file is reported with console.$method', + async ({ mockAccess, method }) => { + expect( + await mockedRead(deniedModules['a denied file'], mockAccess), + ).toEqual({ ...mocked, loggedWith: [method] }) + }, + timeout, + ) + const markedModules: Record = { 'a server-only-marked module': deniedModules['a server-only-marked module'], 'a client-only-marked module imported by the server': { @@ -425,8 +510,8 @@ return String(secret())`) // (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 marked module's real code in - // the bundle of the environment it is denied to (only a warning is - // logged). + // the bundle of the environment it is denied to, and runs it (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 @@ -471,23 +556,27 @@ 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(',') +export const read = () => [secret, ok(), fine(), s()] `, }, entry: 'src/entry.ts', start: { importProtection: { behavior: 'mock', + mockAccess: 'off', client: { specifiers: ['denied-pkg'] }, }, }, }) expect({ hasRealCode: client.where('MARK_SECRET') !== 'nowhere', - read: (await client.run(`return entry.read()`)).result, + read: ( + await client.run(`${describeValuePrelude} +return entry.read().map((value) => describeValue(value, 'MARK_SECRET'))`) + ).result, }).toEqual({ hasRealCode: false, - read: '[import-protection mock],MARK_SAFE,MARK_FINE,[import-protection mock]', + read: ['a safe mock', 'MARK_SAFE', 'MARK_FINE', 'a safe mock'], }) }, timeout, @@ -597,25 +686,47 @@ describe.each(startBundlers)( // Which client imports of a server-only-marked module are checked: a // marker violation is reported for each importer that is in scope and -// compiles, whatever kind of module imports it. +// compiles, whatever kind of module imports it; an importer `ignoreImporters` +// exempts is neither reported nor mocked (SPEC J6.7). // Source: replaces the Rsbuild "registerImportProtection marker scope" tests // that ran on a hand-built fake of the Rspack compilation // (rsbuild/import-protection.test.ts); rubric-start item 25. describe.each(startBundlers)('%s build, server-only marker', (bundler) => { const marked = deniedModules['a server-only-marked module'].files + /** + * The importer a violation blames, relative to the app root: `Importer:` + * when it is not the marked module itself, else the trace step that + * imports the marked module (a marker violation may be reported at + * either, audit F ASK-1). + */ + const blamedImporter = (info: ViolationInfo) => { + const fromRoot = (file: string | undefined) => + file?.split(path.sep).join('/').replace(/^.*?\/(?=src\/)/, '') + const importer = fromRoot(info.importer) + return importer === 'src/marked.ts' + ? fromRoot(info.trace.at(-2)?.file) + : importer + } + + /** + * `src/ignored.ts` (exempt) and `src/other.ts` both use the marked + * module; the entry re-exports both. + */ + const ignoredAndOther = { + ...marked, + 'src/ignored.ts': `import { secret } from './marked'\nexport const a = () => secret()\n`, + 'src/other.ts': `import { secret } from './marked'\nexport const b = () => secret()\n`, + 'src/entry.ts': `export { a } from './ignored'\nexport { b } from './other'\n`, + } + test( 'imported by an ignored importer and another importer fails the build', async () => { const error = await rejection( buildStart({ bundler, - files: { - ...marked, - 'src/ignored.ts': `import { secret } from './marked'\nexport const a = () => secret()\n`, - 'src/other.ts': `import { secret } from './marked'\nexport const b = () => secret()\n`, - 'src/entry.ts': `export { a } from './ignored'\nexport { b } from './other'\n`, - }, + files: ignoredAndOther, entry: 'src/entry.ts', start: { importProtection: { ignoreImporters: ['**/ignored.ts'] } }, }), @@ -625,6 +736,49 @@ describe.each(startBundlers)('%s build, server-only marker', (bundler) => { timeout, ) + // Bug (Rsbuild): the Rsbuild adapter reports a marker violation at the + // marked module (audit F ASK-1 allows it) with the shortest import chain + // from the entry to the marked module (`buildTrace`, + // import-protection/trace.ts, a BFS to the nearest entry), whichever + // importer it runs through: here the exempt `src/ignored.ts`, two steps + // from the entry, while the importer the violation is for, `src/other.ts`, + // is three steps away. An exempt edge is no violation (SPEC J6.7), so a + // report whose chain ends with one names the wrong importer. Vite reports + // the violation at the importer's use, and blames the right one. + // Impact: on Rsbuild, the report of a server-only-marked module imported + // by an exempt importer and another one blames the exempt importer; the + // user looks at code the project ignores on purpose, and the real + // importer is not named. + // Source: V1 ip S20. + ;(bundler === 'rsbuild' ? test.fails : test)( + 'imported by an ignored importer and, further from the entry, another importer: the violation blames the other importer', + async () => { + const seen: Array = [] + await rejection( + buildStart({ + bundler, + files: { + ...ignoredAndOther, + 'src/mid.ts': `export { b } from './other'\n`, + 'src/entry.ts': `export { a } from './ignored'\nexport { b } from './mid'\n`, + }, + entry: 'src/entry.ts', + start: { + importProtection: { + ignoreImporters: ['**/ignored.ts'], + onViolation: (info) => { + seen.push(info as ViolationInfo) + }, + }, + }, + }), + ) + expect(seen).not.toEqual([]) + expect(distinct(seen.map(blamedImporter))).toEqual(['src/other.ts']) + }, + timeout, + ) + // Bug (Vite): the Vite adapter reports a marker violation for the marked // module itself (`importer` is the module importing the marker) and does // not look at which modules import it, so `ignoreImporters` cannot exempt @@ -635,17 +789,21 @@ describe.each(startBundlers)('%s build, server-only marker', (bundler) => { // fails for an import the project ignores. // Source: found replacing the Rsbuild marker-scope test "skips marker // violations imported only by an ignored importer". - const ignoredOnlyTest = bundler === 'vite' ? test.fails : test - ignoredOnlyTest( + const ignoredTest = bundler === 'vite' ? test.fails : test + + /** Only the exempt `src/ignored.ts` imports the marked module. */ + const ignoredOnly = { + ...marked, + 'src/ignored.ts': `import { secret } from './marked'\nexport const read = () => secret()\n`, + 'src/entry.ts': `export { read } from './ignored'\n`, + } + + ignoredTest( 'imported only by an ignored importer does not fail the build', async () => { const { client } = await buildStart({ bundler, - files: { - ...marked, - 'src/ignored.ts': `import { secret } from './marked'\nexport const read = () => secret()\n`, - 'src/entry.ts': `export { read } from './ignored'\n`, - }, + files: ignoredOnly, entry: 'src/entry.ts', start: { importProtection: { ignoreImporters: ['**/ignored.ts'] } }, }) @@ -654,6 +812,70 @@ describe.each(startBundlers)('%s build, server-only marker', (bundler) => { timeout, ) + // Bug: an importer `ignoreImporters` exempts still gets the mock of a + // server-only-marked module in a mock-mode build: Rsbuild replaces the + // marked module itself with a mock in its loader, for every importer + // (the exemption pins of exemptions.test.ts, user ruling 2026-10-17); on + // Vite the marker violation is not exempt at all (the pin above), and in + // error mode the build fails. + // Impact: code a project exempts on purpose silently gets a mock instead + // of the module it imports. + test.fails( + 'imported only by an ignored importer: the ignored importer gets the real module', + async () => { + expect( + await clientRead(bundler, ignoredOnly, { + importProtection: { + behavior: 'mock', + ignoreImporters: ['**/ignored.ts'], + }, + }), + ).toEqual({ hasRealCode: true, ranReal: true, warnings: [] }) + }, + timeout, + ) + + // Bug: same two bugs, on one app: in a mock-mode build, the exempt + // importer of a server-only-marked module gets a mock (Rsbuild: the marked + // module is mocked for every importer), or the real module with a + // violation reported for it (Vite: markers are not mocked at all, and + // `ignoreImporters` does not apply to them). + // Impact: as above; the non-exempt importer is fine on Rsbuild (a safe + // mock, reported), and runs the real server-only code on Vite. + test.fails( + 'in mock mode, an ignored importer gets the real module, another importer a safe mock, and only the other importer is reported', + async () => { + const { client } = await buildStart({ + bundler, + files: ignoredAndOther, + entry: 'src/entry.ts', + start: { + importProtection: { + behavior: 'mock', + mockAccess: 'off', + ignoreImporters: ['**/ignored.ts'], + }, + }, + }) + const { result } = await client.run(`${describeValuePrelude} +return { + ignored: describeValue(entry.a(), 'MARK_SECRET'), + other: describeValue(entry.b(), 'MARK_SECRET'), +}`) + expect({ + ...(result as object), + reported: reportedViolations(client.warnings).map(({ importer }) => + importer.replace(/:\d+:\d+$/, ''), + ), + }).toEqual({ + ignored: 'MARK_SECRET', + other: 'a safe mock', + reported: ['src/other.ts'], + }) + }, + timeout, + ) + test( 'imported by a split route component fails the build', async () => { diff --git a/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts b/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts index 0ec214b2d6f..5d7f8c37aad 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts @@ -7,20 +7,26 @@ * before the modules it imports. */ import path from 'node:path' +import mdx from '@mdx-js/rollup' +import * as jsxRuntime from 'react/jsx-runtime' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterEach, describe, expect, test, vi } from 'vitest' import { startDevServer } from '../../dev-server' +import { + describeValue, + expectSafeMock, +} from '../../importProtection/mock-helpers' import { distinct, factoryTopLevelCall, locationIn, - reactRuntimeFiles, + receivedViolations, rejection, reportedViolations, serverFnFactoryApp, startProtectedDevServer, + violationReports, } from '../../importProtection/start-apps' -import { violationReports } from '../../importProtection/violation-report' import type { ViolationReport } from '../../importProtection/violation-report' import type { ImportProtectionOptions } from '../../../src/schema' import type { ViolationInfo } from '../../../src/import-protection/trace' @@ -46,11 +52,25 @@ function loggedViolations(logs: Array) { ) } -/** 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]) +/** The modules of the trace of `report`, without locations. */ +const traceModules = (report: ViolationReport) => + report.trace.map(({ at }) => at.replace(/:\d+:\d+$/, '')) + +/** + * Captures what the dev mocks report to the browser console (`mockAccess`), + * silenced: the messages, by console method. Call before evaluating. + */ +function captureMockAccess() { + const logged: Array<{ method: 'error' | 'warn'; message: string }> = [] + for (const method of ['error', 'warn'] as const) { + vi.spyOn(console, method).mockImplementation((message: unknown) => { + logged.push({ + method, + message: message instanceof Error ? message.message : String(message), + }) + }) + } + return logged } /** @@ -123,14 +143,12 @@ describe('a client import of a denied file', () => { '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(reports).map((v) => v.importer)), - ).toEqual([deniedFileUse]) + const reports = violationReports(server.logs) + expect(distinct(reports.map((v) => v.importer))).toEqual([ + deniedFileUse, + ]) for (const report of reports) { - expect(traceFiles(report)).toEqual(['src/entry.ts', 'src/a.ts']) + expect(traceModules(report)).toEqual(['src/entry.ts', 'src/a.ts']) } }, timeout, @@ -170,10 +188,6 @@ describe('a client import of a denied file', () => { }) describe('the trace of a client import of a denied file', () => { - /** The modules of the trace of `report`, without locations. */ - const traceModules = (report: ViolationReport) => - report.trace.map(({ at }) => at.replace(/:\d+:\d+$/, '')) - // A module under `src/routes` is not an entry: the trace goes on to the // router entry. // Source: mutation run R4 (spc:4986, 4987: route modules taken as entries). @@ -550,7 +564,7 @@ describe('a client import of a denied specifier', () => { '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 read = () => secret() export const request = () => getRequest() `, 'src/entry.ts': `export * from './a'\n`, @@ -563,7 +577,7 @@ export const request = () => getRequest() 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:3:27', 'client src/a.ts:4:30', ]) }, @@ -575,14 +589,14 @@ export const request = () => getRequest() '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 logged = captureMockAccess() 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]') + expect(logged).toEqual([]) + expectSafeMock(module.request(), 'MARK_SECRET') + expectSafeMock(module.read(), 'MARK_SECRET') + const errors = logged + .filter(({ method }) => method === 'error') + .map(({ message }) => message) const reports = errors .filter((error) => error.includes('Mocked import used in dev client')) .map((error) => ({ @@ -613,21 +627,17 @@ export const request = () => getRequest() const server = await startProtectedDevServer(deniedSpecifierApp, { mockAccess, }) - const reported = { errors: false, warnings: false } - const isReport = (message: unknown) => - String(message instanceof Error ? message.message : message).includes( - 'Mocked import used in dev client', - ) - vi.spyOn(console, 'error').mockImplementation((message: unknown) => { - reported.errors ||= isReport(message) - }) - vi.spyOn(console, 'warn').mockImplementation((message: unknown) => { - reported.warnings ||= isReport(message) - }) + const logged = captureMockAccess() const module = await evaluateServed(server, '/src/entry.ts') - expect(String(module.request())).toBe('[import-protection mock]') - expect(module.read()).toBe('[import-protection mock]') - expect(reported).toEqual(expected) + expectSafeMock(module.request(), 'MARK_SECRET') + expectSafeMock(module.read(), 'MARK_SECRET') + const reports = logged.filter(({ message }) => + message.includes('Mocked import used in dev client'), + ) + expect({ + errors: reports.some(({ method }) => method === 'error'), + warnings: reports.some(({ method }) => method === 'warn'), + }).toEqual(expected) }, timeout, ) @@ -639,40 +649,38 @@ export const request = () => getRequest() async () => { const server = await startProtectedDevServer({ 'src/a.ts': `import { getRequest } from '@tanstack/react-start/server' -export const read = () => String(getRequest()) +export const read = () => [getRequest()] `, 'src/entry.ts': `export * from './a'\n`, }) - vi.spyOn(console, 'error').mockImplementation(() => {}) - expect((await evaluateServed(server, '/src/entry.ts')).read()).toBe( - '[import-protection mock]', - ) + captureMockAccess() + const described = async () => + ((await evaluateServed(server, '/src/entry.ts')).read() as Array).map( + (value) => describeValue(value, 'MARK_'), + ) + expect(await described()).toEqual(['a safe mock']) await server.edit({ 'src/a.ts': `import { getRequest, getResponse } from '@tanstack/react-start/server' -export const read = () => String(getRequest()) + String(getResponse()) +export const read = () => [getRequest(), getResponse()] `, }) - expect((await evaluateServed(server, '/src/entry.ts')).read()).toBe( - '[import-protection mock][import-protection mock]', - ) + expect(await described()).toEqual(['a safe mock', 'a safe mock']) }, timeout, ) }) // A module another Vite plugin compiles to JavaScript from a file that is -// not JavaScript or TypeScript (MDX, Vue and Svelte components...). +// not JavaScript or TypeScript (MDX, Vue and Svelte components...): the +// real MDX plugin (`@mdx-js/rollup`), before the Start plugins like a user +// lists it; its output imports React's JSX runtime. describe('a module of another compiler (`.mdx`)', () => { - /** A stand-in of an MDX plugin: drops the `# ` heading lines. */ - const mdx: Plugin = { - name: 'mdx-stand-in', - enforce: 'pre', - transform(code, id) { - if (!id.endsWith('.mdx')) { - return undefined - } - return { code: code.replace(/^# .*$/gm, ''), map: null } - }, + const mdxPlugin: Plugin = { enforce: 'pre', ...mdx() } + const plugins = [mdxPlugin] + /** The real JSX runtime, in place of the dependency the page imports. */ + const stubs = { + fallbackStub: (specifier: string) => + /\/react\/jsx(-dev)?-runtime\b/.test(specifier) ? jsxRuntime : {}, } // Source: mutation run R4 (spc:4639: the module has no compiled imports @@ -697,7 +705,7 @@ export const read = () => secret() + leak() 'src/entry.ts': `export * from './page.mdx'\n`, }, {}, - { plugins: [mdx] }, + { plugins }, ) const client = await server.load('/src/entry.ts') expect(Object.keys(client)).toContain('/src/page.mdx') @@ -737,10 +745,11 @@ export const request = () => getRequest() 'src/entry.ts': `export * from './page.mdx'\n`, }, {}, - { plugins: [mdx] }, + { plugins }, ) - const module = await evaluateServed(server, '/src/entry.ts') - expect(String(module.request())).toBe('[import-protection mock]') + captureMockAccess() + const module = await evaluateServed(server, '/src/entry.ts', stubs) + expectSafeMock(module.request(), 'MARK_') expect( reportedViolations(server.logs).map(({ importer }) => importer), ).toContainEqual(expect.stringMatching(/^src\/page\.mdx/)) @@ -985,29 +994,60 @@ export const peek = () => other() timeout, ) + /** + * Loads the app with `src/b.ts` unmarked, edits only `src/b.ts` to add + * the server-only marker, and loads it again (SPEC J6.13: edits + * reclassify in dev). The violations logged after the edit, and the + * client modules then served with the marked module's code. + */ + async function addMarkerByAnEdit() { + const server = await startProtectedDevServer(markedApp('')) + expect( + modulesContaining(await server.load('/src/entry.ts'), 'MARK_B_CODE'), + ).toEqual(['/src/b.ts']) + const logged = server.logs.length + await server.edit({ 'src/b.ts': markedApp(serverOnly)['src/b.ts'] }) + await server.load('/src/b.ts') + const client = await server.load('/src/entry.ts') + await server.idle() + return { + reported: loggedViolations(server.logs.slice(logged)), + realCodeIn: modulesContaining(client, 'MARK_B_CODE'), + } + } + // Bug: when an edit adds a server-only marker to a module the client - // already loaded, import protection logs the violation, but the importers - // keep their transform, and so their import of the real module (same - // mechanism as the pin above; the opposite edit is pinned in - // known-bugs-dev-server.test.ts "the importer of a module whose server-only - // marker was removed loads the module"). + // already loaded, nothing happens to its importers: import protection + // only clears its own caches on the edit (`hotUpdate`), and Vite keeps the + // importers' transforms (a soft invalidation), so their import of the + // module is never resolved again, where the marker violation would be + // found (the retroactive marker check of `resolveId`, + // vite/import-protection-plugin/plugin.ts). Neither the report nor the + // mock follows (the opposite edit is pinned in known-bugs-dev-server.test.ts + // "the importer of a module whose server-only marker was removed loads the + // module"). The two outcomes are pinned apart (FOLLOWUPS H5: the + // report was claimed to work). // Impact: after marking a module server-only in dev, the browser keeps - // running its real code until the importers are edited or the server - // restarts. + // running its real code, without any warning, until the importers are + // edited or the server restarts. // Source: gap DV37, adding direction (gaps/dev-hmr.md). test.fails( - 'is mocked after an edit adds the marker, and the violation is logged', + 'after an edit adds the marker, the violation is logged', async () => { - const server = await startProtectedDevServer(markedApp('')) - expect( - modulesContaining(await server.load('/src/entry.ts'), 'MARK_B_CODE'), - ).toEqual(['/src/b.ts']) - // Only the marked module changes - await server.edit({ 'src/b.ts': markedApp(serverOnly)['src/b.ts'] }) - await server.load('/src/b.ts') - const client = await server.load('/src/entry.ts') - expect(reportedViolations(server.logs)).not.toEqual([]) - expect(modulesContaining(client, 'MARK_B_CODE')).toEqual([]) + const { reported } = await addMarkerByAnEdit() + // (at the importer's use, or at the marker: audit F ASK-1) + expect(reported).toContainEqual( + expect.stringMatching(/^client src\/(a\.ts:2:27|b\.ts\b)/), + ) + }, + timeout, + ) + + test.fails( + 'after an edit adds the marker, the importer gets the mock', + async () => { + const { realCodeIn } = await addMarkerByAnEdit() + expect(realCodeIn).toEqual([]) }, timeout, ) @@ -1088,6 +1128,76 @@ export const read = () => widget() timeout, ) +// `mockAccess` applies wherever a mock is served, the server environments +// included (SPEC J6.3; schema.ts documents it for `behavior: 'mock'` +// without a client restriction): the mock an SSR import of a `.client` file +// gets reports its use in the server console as configured. +describe('an SSR import of a .client file: mockAccess', () => { + /** + * Imports the app in the SSR environment and calls the mocked `widget`; + * what it is, and what the call logged (by console method), with + * `mockAccess`. + */ + async function useOnTheServer(mockAccess: 'error' | 'warn' | 'off') { + const server = await startProtectedDevServer( + { + 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, + 'src/a.ts': `import { widget } from './widget.client' +export const read = () => widget() +`, + 'src/entry.ts': `export * from './a'\n`, + }, + { mockAccess }, + ) + const module = await server.import('/src/entry.ts', 'ssr') + const logged = captureMockAccess() + const value = module.read() + return { + read: describeValue(value, 'MARK_'), + loggedWith: distinct( + logged + .filter(({ message }) => message.includes('Mocked import used')) + .map(({ method }) => method), + ), + } + } + + // Control for the pins below (same harness): a safe mock, silent. + test( + 'off: the server gets a safe mock, and using it is not reported', + async () => { + expect(await useOnTheServer('off')).toEqual({ + read: 'a safe mock', + loggedWith: [], + }) + }, + timeout, + ) + + // Bug: the mock runtime of a violation in a server environment is always + // the silent one (`mockRuntimeModuleIdFromViolation`, import-protection/ + // virtualModules.ts: `if (info.env !== 'client') return MOCK_MODULE_ID`), + // whatever `mockAccess` says; the client's runtime diagnostic has no + // server counterpart. + // Impact: on the server (SSR, the server fn provider), code that runs on + // a mocked client-only import does so without any console diagnostic; the + // violation warning of the dev server is the only trace of it. + // Source: V1 ip S03 (the unit row demanded that silence). + test.fails.each([ + { mockAccess: 'error', method: 'error' }, + { mockAccess: 'warn', method: 'warn' }, + ] as const)( + '$mockAccess: the server gets a safe mock, and using it is reported with console.$method', + async ({ mockAccess, method }) => { + expect(await useOnTheServer(mockAccess)).toEqual({ + read: 'a safe mock', + loggedWith: [method], + }) + }, + timeout, + ) +}) + describe('reachability from the router entry', () => { // Source: mutation run R3 ip-vite V4(a). test( @@ -1149,6 +1259,44 @@ export const createSecretFn = createServerFn().middleware([mw]) timeout, ) + /** + * Orders two transforms of the client environment the way a loaded + * machine may: holds the client transform of `src/factory.ts` (the module + * itself, not the Start compiler's lookup of it) until the client has + * transformed `src/helper.ts`, which the lookup's import analysis + * pre-transforms. Fix-agnostic: with the bug fixed, the order does not + * matter, and the hold ends on its own when the helper is never + * transformed for the client. + */ + function helperBeforeFactory(): Plugin { + let root = '' + return { + name: 'helper-before-factory', + enforce: 'pre', + configResolved(config) { + root = config.root + }, + async transform(_code, id) { + if ( + this.environment.name !== 'client' || + id !== path.join(root, 'src/factory.ts') + ) { + return null + } + const helper = path.join(root, 'src/helper.ts') + const deadline = Date.now() + 5_000 + while ( + !this.environment.moduleGraph.getModuleById(helper) + ?.transformResult && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return null + }, + } + } + // Bug: with Vite's default pre-transform requests, the Start compiler's // lookup of the server fn factory module (`?server-fn-module-lookup`, in // the client environment) goes through Vite's import analysis, which @@ -1158,7 +1306,9 @@ export const createSecretFn = createServerFn().middleware([mw]) // until the factory's own client transform lands, and the 'unknown' // reachability fallback of `processPendingViolations` // (vite/import-protection-plugin/plugin.ts) logs the helper's denied file - // import conservatively. + // import conservatively, when the helper's transform is processed before + // the factory's: `helperBeforeFactory` forces that order (on an idle + // machine the factory often wins, and the warning does not appear). // Impact: in dev (Vite's defaults), a module a server fn factory uses only // inside `.server()` is logged as a client violation, with a trace through // an import the client never has; the client is correct (it never loads @@ -1168,7 +1318,11 @@ export const createSecretFn = createServerFn().middleware([mw]) test.fails( 'a helper a server fn factory module uses only inside .server() is not logged', async () => { - const server = await startProtectedDevServer(handlerOnlyHelperApp()) + const server = await startProtectedDevServer( + handlerOnlyHelperApp(), + {}, + { plugins: [helperBeforeFactory()] }, + ) const client = await server.load('/src/entry.ts') // (what the transforms Vite runs in the background log) await server.idle() @@ -1553,6 +1707,37 @@ export const leak = () => secret() timeout, ) + // The `importProtection` option of `tanstackStart()` reaches the plugin + // (the adapter harness above wires the plugin by hand): `onViolation` + // receives the violation, `mockAccess: 'off'` silences the mock's use. + // Source: V1 ip S18. + test( + "Start's plugin passes the importProtection option on: onViolation and mockAccess apply", + async () => { + const violations: Array = [] + const server = await startDevServer({ + start: { + importProtection: { + mockAccess: 'off', + onViolation: (info) => { + violations.push(info as ViolationInfo) + }, + }, + }, + files: leakApp('src/router.ts'), + config: { server: { hmr: false } }, + }) + const logged = captureMockAccess() + const router = await evaluateServed(server, '/src/router.ts') + expectSafeMock(router.getRouter()(), 'MARK_') + expect(logged).toEqual([]) + expect(receivedViolations(violations, server.root)).toEqual([ + `client ${leakUse}`, + ]) + }, + timeout, + ) + // Control for the pin below (same app, same module the page loads): the // page reaches the app's client entry and the leaking module, which gets // the mock. @@ -2105,7 +2290,6 @@ export const Page = () =>
    {String(1)}
    async ({ a, environment, importProtection, expected, marker }) => { 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`, @@ -2139,7 +2323,6 @@ export const Page = () =>
    {String(1)}
    'in a JSX module, a use of a denied file imported through a subpath import alias is located on its source line', async () => { const server = await startProtectedDevServer({ - ...reactRuntimeFiles, 'package.json': JSON.stringify({ name: 'app', type: 'module', @@ -2169,7 +2352,6 @@ export const Page = () =>
    {String(secret())}
    async () => { const server = await startProtectedDevServer( { - ...reactRuntimeFiles, 'node_modules/@acme/private/package.json': JSON.stringify({ name: '@acme/private', type: 'module', diff --git a/packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts b/packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts index 19a7714d535..61babcf29c0 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/exemptions.test.ts @@ -11,6 +11,10 @@ import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterAll, afterEach, describe, expect, test, vi } from 'vitest' import { buildStart, startBundlers } from '../../build-harness' +import { + describeValue, + describeValuePrelude, +} from '../../importProtection/mock-helpers' import { reportedViolations, startProtectedDevServer, @@ -20,8 +24,28 @@ import type { ImportProtectionOptions } from '../../../src/schema' const timeout = 60_000 -/** What a use of a mock returns, converted to a string. */ -const mockText = '[import-protection mock]' +/** + * What a use gives, described: the real value's text, `a safe mock` + * (`mock-helpers.ts`), or what calling it threw. + */ +const mockText = 'a safe mock' +const read = (use: () => unknown) => { + try { + return describeValue(use(), 'MARK_') + } catch (error) { + return `throws: ${String(error)}` + } +} +/** `read` for the scripts of a build (`describeValuePrelude`). */ +const readPrelude = `${describeValuePrelude} +const read = (use) => { + try { + return describeValue(use(), 'MARK_') + } catch (error) { + return 'throws: ' + String(error) + } +} +` afterEach(() => { vi.restoreAllMocks() @@ -107,7 +131,7 @@ const reportedFiles = (output: string | Array) => ].sort() /** What each re-exported use returns, and whether the denied file ran. */ -const readScript = `const read = (use) => { try { return String(use()) } catch (error) { return 'throws: ' + error } } +const readScript = `${readPrelude} return { exempt: read(entry.exemptRead), other: read(entry.otherRead), @@ -207,13 +231,6 @@ describe.each(exemptions)( // The mock reports each use in the browser console. vi.spyOn(console, 'error').mockImplementation(() => {}) const module = await evaluateServed(server, '/src/entry.ts') - const read = (use: () => unknown) => { - try { - return String(use()) - } catch (error) { - return `throws: ${String(error)}` - } - } return { exempt: read(module.exemptRead), other: read(module.otherRead), @@ -286,15 +303,12 @@ type ExcludedOutcome = Record<'vendor' | 'widget' | 'package', Use> & { reported: Array } -const excludedReadScript = `const read = (use, loaded) => { - let value - try { value = String(use()) } catch (error) { value = 'throws: ' + error } - return { read: value, loaded: globalThis[loaded] ?? null } -} +const excludedReadScript = `${readPrelude} +const use = (fn, loaded) => ({ read: read(fn), loaded: globalThis[loaded] ?? null }) return { - vendor: read(entry.vendorRead, 'vendorLoaded'), - widget: read(entry.widgetRead, 'widgetLoaded'), - package: read(entry.packageRead, 'packageLoaded'), + vendor: use(entry.vendorRead, 'vendorLoaded'), + widget: use(entry.widgetRead, 'widgetLoaded'), + package: use(entry.packageRead, 'packageLoaded'), }` /** The real module of a use: its value, and its file ran. */ @@ -430,21 +444,13 @@ describe('vite dev: excludeFiles', () => { const module = await evaluateServed(server, '/src/entry.ts', { skip: /^\/@(vite|react-refresh)\b/, }) - const read = (use: () => unknown, loaded: string): Use => { - let value: string - try { - value = String(use()) - } catch (error) { - value = `throws: ${String(error)}` - } - return { - read: value, - loaded: (globalThis as Record)[loaded] ?? null, - } - } + const use = (fn: () => unknown, loaded: string): Use => ({ + read: read(fn), + loaded: (globalThis as Record)[loaded] ?? null, + }) return { - vendor: read(module.vendorRead, 'vendorLoaded'), - package: read(module.packageRead, 'packageLoaded'), + vendor: use(module.vendorRead, 'vendorLoaded'), + package: use(module.packageRead, 'packageLoaded'), reported: reportedFiles(server.logs), } } diff --git a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts index ced8f7bc21a..a97ecff1efc 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts @@ -2,7 +2,9 @@ // in-process tests are in ../../importProtection/known-bugs.test.ts (fast tier). /** * Known import-protection bugs. Each test asserts correct behaviour for a bug - * on main and is marked .fails; remove .fails when the bug is fixed. + * on main and is marked .fails; remove .fails when the bug is fixed. A mock + * is told by what it does (`mock-helpers.ts`, `describeValue`: `a safe + * mock`), and a report by its sections (`violation-report.ts`). */ import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterEach, describe, expect, test, vi } from 'vitest' @@ -11,15 +13,19 @@ import { runRsbuildClient, startRsbuildDevServer, } from '../../rsbuild-dev-server' +import { + describeValue, + describeValuePrelude, +} from '../../importProtection/mock-helpers' import { factoryTopLevelCall, - reactRuntimeFiles, rejection, reportedViolations, serverFnFactoryApp, startProtectedDevServer, } from '../../importProtection/start-apps' import type { RsbuildConfig } from '@rsbuild/core' +import type { StartBuild } from '../../build-harness' import type { ImportProtectionOptions } from '../../../src/schema' /** @@ -53,11 +59,17 @@ export const nav = createServerFn().handler(async () => 1) 'src/entry.ts': `export { nav } from './layout'\n`, } +/** The code of every chunk of a build's client bundle, by file name. */ +const clientChunks = ({ client }: StartBuild) => + Object.fromEntries( + client.chunks.map(({ fileName, code }) => [fileName, code]), + ) + describe.each(startBundlers)('%s build', (bundler) => { // Control for the pin below (same harness): a denied module whose code the // client bundle contains fails the build. test('a denied module the client uses fails the build', async () => { - await expect( + const error = await rejection( buildStart({ bundler, files: { @@ -67,7 +79,10 @@ describe.each(startBundlers)('%s build', (bundler) => { entry: 'src/entry.ts', start: denyToClient(['**/corpus.ts']), }), - ).rejects.toThrow(/Import denied in client environment/) + ) + expect( + reportedViolations(error.message).map(({ environment }) => environment), + ).toEqual(['client']) }, 60_000) // Control for the pin below (same files, no import protection): the client @@ -129,21 +144,26 @@ describe.each(startBundlers)('%s build', (bundler) => { // imports, and report violations the client never reaches. // Source: weak check virtualModules.test.ts "returns mock code" (asserted // the purity annotations as text), oracle-gaps REPORT.md "Weak checks". + // The client bundle is compared with the one import protection disabled + // gives (the control above): with nothing left of the unused edge, the + // two are the same. test.fails( - 'in mock mode, an unused denied module leaves no mock in the client bundle and no warning', + 'in mock mode, an unused denied module leaves nothing in the client bundle and no warning', async () => { - const { client } = await buildStart({ - bundler, - files: droppedCorpusFiles, - entry: 'src/entry.ts', - start: mockToClient(['**/corpus.ts']), - }) + const build = (start: Parameters[0]['start']) => + buildStart({ + bundler, + files: droppedCorpusFiles, + entry: 'src/entry.ts', + start, + }) + // (one build at a time: a Vite build runs in its app's directory) + const mocked = await build(mockToClient(['**/corpus.ts'])) + const disabled = await build({ importProtection: { enabled: false } }) expect({ - mock: client.where('[import-protection mock]'), - warnings: client.warnings.filter((warning) => - warning.includes('[import-protection]'), - ), - }).toEqual({ mock: 'nowhere', warnings: [] }) + chunks: clientChunks(mocked), + warnings: reportedViolations(mocked.client.warnings), + }).toEqual({ chunks: clientChunks(disabled), warnings: [] }) }, 60_000, ) @@ -242,6 +262,13 @@ describe('Vite dev, behavior "error"', () => { }) describe('Rsbuild dev', () => { + /** + * Only a guard against a compile that never comes, never shorter than the + * test's timeout (rsbuild/import-protection.test.ts). + */ + const timeout = 60_000 + const compileTimeout = timeout + /** A module type another loader compiles to JS (a stand-in for `.mdx`). */ const pageModules: RsbuildConfig = { tools: { @@ -255,6 +282,7 @@ describe('Rsbuild dev', () => { // module of that type is compiled, and its code runs in the client. test('a module of another type without a denied import runs in the client', async () => { const server = await startRsbuildDevServer({ + compileTimeout, importProtection: { behavior: 'error', client: { specifiers: ['db-lib'] }, @@ -267,7 +295,7 @@ describe('Rsbuild dev', () => { }) expect(server.errors).toEqual([]) expect(await runRsbuildClient(server)).toEqual(['MARK_PAGE']) - }, 30_000) + }, timeout) // Bug: Rsbuild import protection checks importers by file extension // (`isImportProtectionSourceFile`, rsbuild/import-protection.ts, only @@ -279,7 +307,7 @@ describe('Rsbuild dev', () => { // 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". + // when it is a .$extension file". test.fails.each<{ name: string; files: Record }>([ { name: 'a denied file', @@ -304,6 +332,7 @@ describe('Rsbuild dev', () => { 'a module of another type importing $name is reported, and its real code does not run', async ({ files }) => { const server = await startRsbuildDevServer({ + compileTimeout, importProtection: { behavior: 'error', client: { specifiers: ['db-lib'] }, @@ -323,7 +352,7 @@ describe('Rsbuild dev', () => { ranRealCode: messages.some((message) => message.includes('MARK_')), }).toEqual({ reported: ['client src/home.page'], ranRealCode: false }) }, - 30_000, + timeout, ) // In Rsbuild dev, an error thrown by `onViolation` (awaited in the @@ -341,9 +370,8 @@ describe('Rsbuild dev', () => { throw new Error('MARK_THROWN') }, }, - // Only a guard: the failed compile ends the wait (a slow first - // compile on a loaded machine must not fail the test). - compileTimeout: 10_000, + // Only a guard: the failed compile ends the wait. + compileTimeout, files: { 'src/secret.server.ts': `export const secret = () => 'secret'\n`, 'src/entry.ts': `import { secret } from './secret.server'\nconsole.log(secret())\n`, @@ -353,7 +381,7 @@ describe('Rsbuild dev', () => { (error: Error) => error.message, ) expect(outcome).toContain('MARK_THROWN') - }, 15_000) + }, timeout) }) describe.each(startBundlers)('%s build', (bundler) => { @@ -408,16 +436,19 @@ describe.each(startBundlers)('%s build', (bundler) => { files: { [`src/${file}`]: code, 'src/entry.ts': `${importStatement} -export const read = () => String(secret) +export const read = () => secret `, }, entry: 'src/entry.ts', - start: { importProtection: { behavior: 'mock' } }, + start: { importProtection: { behavior: 'mock', mockAccess: 'off' } }, }) return { warned: reportedViolations(client.warnings).length > 0, hasRealCode: client.where('MARK_SECRET') !== 'nowhere', - read: (await client.run(`return entry.read()`)).result, + read: ( + await client.run(`${describeValuePrelude} +return describeValue(entry.read(), 'MARK_SECRET')`) + ).result, } } @@ -441,11 +472,7 @@ export const read = () => String(secret) `import data from './secret.server.json' const secret = data.secret`, ), - ).toEqual({ - warned: true, - hasRealCode: false, - read: '[import-protection mock]', - }) + ).toEqual({ warned: true, hasRealCode: false, read: 'a safe mock' }) }, 60_000, ) @@ -469,56 +496,56 @@ const secret = data.secret`, `exports.secret = () => 'MARK_SECRET'\n`, `import { secret } from './secret.server.cjs'`, ), - ).toEqual({ - warned: true, - hasRealCode: false, - read: '[import-protection mock]', - }) + ).toEqual({ warned: true, hasRealCode: false, read: 'a safe mock' }) }, 60_000, ) }) /** - * App-level twins of the pins above on internal functions (rewrite and mock - * modules): a real app with each input, on a Vite dev server (mock mode, the - * dev default) and in mock-mode builds. `src/entry.ts` exports `read()`; a - * use of a denied import must read the mock, whose text is `expected` - * unless a row says otherwise, and the app must load or build. - * Source: audit F, missing coverage 1. + * App-level twins of the pins on internal functions (the rewrite and the + * mock modules): a real app with each input, on a Vite dev server (mock + * mode, the dev default) and in mock-mode builds. `src/entry.ts` exports + * `read()`, which returns what the app gets from its denied import, as an + * array of values; each must be a safe mock (`a safe mock`) unless a row + * expects a real value, and the app must load or build. + * Source: audit F, missing coverage 1; V1 ip S13 (computed keys), S25. */ describe('app-level twins of the mock pins', () => { afterEach(() => { vi.restoreAllMocks() }) - /** A dependency `name` exporting `query`, denied by `client.specifiers`. */ + /** A dependency `name` exporting `query` and `epsilon`, denied by `client.specifiers`. */ const deniedDependency = (name: string) => ({ [`node_modules/${name}/package.json`]: JSON.stringify({ name, type: 'module', exports: { '.': './index.js' }, }), - [`node_modules/${name}/index.js`]: `export const query = () => 'MARK_SECRET'\n`, + [`node_modules/${name}/index.js`]: `export const query = () => 'MARK_SECRET' +export const epsilon = () => ({ deep: () => 'MARK_SECRET' }) +`, }) - const mockText = '[import-protection mock]' + const mock = 'a safe mock' interface TwinCase { files: Record importProtection?: ImportProtectionOptions - /** What `String(entry.read())` returns (default: the mock's text). */ - expected?: string + /** What `entry.read()` gives, described (default: one safe mock). */ + expected?: Array } const twins: Record = { - // Twin of "a denied namespace re-export keeps its exported name". + // Twin of the rewrite pin "a denied namespace re-export keeps its + // exported name" (known-bugs.test.ts, fast tier, until W2-i). 'a module re-exporting a denied file as a namespace': { files: { 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, 'src/barrel.ts': `export * as secrets from './secret.server'\n`, 'src/entry.ts': `import { secrets } from './barrel' -export const read = () => secrets.secret() +export const read = () => [secrets.secret()] `, }, }, @@ -528,7 +555,7 @@ export const read = () => secrets.secret() 'src/helpers.ts': `export const format = () => 'MARK_SECRET'\n`, 'src/lib.server.ts': `export * as helpers from './helpers'\n`, 'src/entry.ts': `import { helpers } from './lib.server' -export const read = () => helpers.format() +export const read = () => [helpers.format()] `, }, }, @@ -540,15 +567,28 @@ export const read = () => helpers.format() export { remove as delete } `, 'src/entry.ts': `import * as api from './api.server' -export const read = () => api.delete() +export const read = () => [api.delete()] `, }, }, + // Same bug, the other names a module cannot bind. + 'a denied file exporting `eval` and `arguments`': { + files: { + 'src/api.server.ts': `const a = () => 'MARK_SECRET' +const b = () => 'MARK_SECRET' +export { a as eval, b as arguments } +`, + 'src/entry.ts': `import * as api from './api.server' +export const read = () => [api.eval(), api.arguments()] +`, + }, + expected: [mock, mock], + }, // Twin of "a JSX pragma that leads a denied import stays in effect": the - // page renders with the runtime its pragma names. + // page renders with the runtime its pragma names (a custom JSX runtime + // is valid user input). 'a JSX pragma leading a denied import': { files: { - ...reactRuntimeFiles, 'node_modules/custom-jsx/package.json': JSON.stringify({ name: 'custom-jsx', type: 'module', @@ -568,23 +608,47 @@ import { secret } from './secret.server' export const Page = () =>
    {String(secret())}
    `, 'src/entry.ts': `import { Page } from './page' -export const read = () => Page() +export const read = () => [Page()] `, }, - expected: 'MARK_CUSTOM_JSX', + expected: ['MARK_CUSTOM_JSX'], }, // Twin of "a namespace member read before the import". 'a namespace member read in a function declared before the import': { files: { ...deniedDependency('db-lib'), 'src/entry.ts': `export function read() { - return db.query() + return [db.query()] } import * as db from 'db-lib' `, }, importProtection: { client: { specifiers: ['db-lib'] } }, }, + // A namespace member read with a computed key (SPEC J6.8, `ns[key]`): + // the key names a real export of the denied file, and a deeper member + // of what it gives. + 'a namespace member read with a computed key, of a denied file': { + files: { + 'src/model.server.ts': `export const epsilon = () => ({ deep: () => 'MARK_SECRET' })\n`, + 'src/entry.ts': `import * as ns from './model.server' +const key = 'epsilon' +export const read = () => [ns[key](), ns[key]().deep()] +`, + }, + expected: [mock, mock], + }, + 'a namespace member read with a computed key, of a denied specifier': { + files: { + ...deniedDependency('db-lib'), + 'src/entry.ts': `import * as ns from 'db-lib' +const key = 'epsilon' +export const read = () => [ns[key](), ns[key]().deep()] +`, + }, + importProtection: { client: { specifiers: ['db-lib'] } }, + expected: [mock, mock], + }, // Twin of "an enum and a namespace" (the mock of a denied file). 'a denied file exporting an enum and a namespace': { files: { @@ -596,10 +660,10 @@ export namespace Util { } `, 'src/entry.ts': `import { Color, Util } from './model.server' -export const read = () => [String(Color.Red), String(Util.x)].join() +export const read = () => [Color.Red, Util.x] `, }, - expected: `${mockText},${mockText}`, + expected: [mock, mock], }, } @@ -610,19 +674,24 @@ export const read = () => [String(Color.Red), String(Util.x)].join() * (the internal pin needs a JSX plugin that runs after the rewrite). */ const failsOn: Record> = { - // Bug: same as "a denied namespace re-export keeps its exported name" - // (the dev rewrite of the importer drops `export * as ns`). + // Bug: the dev rewrite of the importer drops `export * as ns` (the + // rewrite pin "a denied namespace re-export keeps its exported name", + // import-protection/rewrite.ts). // Impact: in dev, the page fails to load [SyntaxError: '/src/barrel.ts' // does not provide an export named 'secrets']. Builds mock the denied // file itself and pass. 'a module re-exporting a denied file as a namespace': ['vite dev'], - // Bug: same as "a namespace re-export" (the self-contained mock of a - // denied file skips `export * as ns`). + // Bug: the export names of a denied file skip namespace re-exports + // (`export * as ns from`; import-protection/analysis.ts collects only + // the names of `ExportSpecifier`s), so the self-contained mock of the + // file has no `helpers`. // Impact: a mock-mode build fails [Vite MISSING_EXPORT, Rspack // ESModulesLinkingError 'helpers']. Vite dev passes. 'a denied file re-exporting a namespace': ['vite build', 'rsbuild build'], - // Bug: same as "the mock of a denied file exporting `delete` exports it" - // (`export const delete = mock.delete` in every mock module). + // Bug: mock modules declare each mocked name as `export const `, + // and reserved words such as `delete` pass the identifier check + // (`isValidExportName`, import-protection/analysis.ts); `eval` and + // `arguments` cannot be bound in a module either. // Impact: the dev page and mock-mode builds fail with a parse error // [Unexpected token 'delete'; PARSE_ERROR; Expected ident]. 'a denied file exporting `delete`, used as `api.delete()`': [ @@ -630,22 +699,40 @@ export const read = () => [String(Color.Red), String(Util.x)].join() 'vite build', 'rsbuild build', ], - // Bug: same as "a namespace member read before the import" (the mock - // edge of a denied specifier only exports the members read after the - // import). Rsbuild mocks the whole namespace and passes. + 'a denied file exporting `eval` and `arguments`': [ + 'vite dev', + 'vite build', + 'rsbuild build', + ], + // Bug: namespace member reads are collected only after the import + // declaration that binds the namespace, but imports are hoisted: a + // member read earlier in the file is not exported by the mock edge of + // a denied specifier (the mock edge exports only the members read + // after the import). Rsbuild mocks the whole namespace and passes. // Impact: the read gets `undefined` [db.query is not a function; Vite // build: (void 0) is not a function]. + // Source: PR #8504 (analysis.exports.test.ts "collects namespace member + // usage that precedes the import"). 'a namespace member read in a function declared before the import': [ 'vite dev', 'vite build', ], + // Bug: the mock edge of a denied specifier exports only the names the + // importer reads statically; a member read with a computed key is no + // export of it, so `ns[key]` is `undefined`. A denied file is mocked by + // its own exports and passes; Rsbuild mocks the whole namespace. + // Impact: the read gets `undefined` [ns[key] is not a function]. + 'a namespace member read with a computed key, of a denied specifier': [ + 'vite dev', + 'vite build', + ], // NEW bug (Rsbuild), not the internal pin's cause (Vite passes): a // mock-mode Rsbuild production build ships the real value of an enum - // member of a denied file [read 'MARK_SECRET,[import-protection mock]', - // the bundle contains MARK_SECRET]; the namespace member is mocked. + // member of a denied file [read 'MARK_SECRET', the bundle contains + // MARK_SECRET]; the namespace member is mocked. // Cause: Rsbuild's production SWC config collects exported enums // (`collectTypeScriptInfo.exportedEnum: isProd`), and Rspack inlines - // `Color.Red` into the importer (`String("MARK_SECRET")`) from the + // `Color.Red` into the importer (`"MARK_SECRET"`) from the // denied file's own source; the import-protection loader's mock (which // does export `Color`) is never read. A scratch build with // `exportedEnum: false` mocks it. @@ -659,7 +746,7 @@ export const read = () => [String(Color.Red), String(Util.x)].join() (failsOn[name] ?? []).includes(channel) ? test.fails : test for (const [name, twin] of Object.entries(twins)) { - const { files, importProtection = {}, expected = mockText } = twin + const { files, importProtection = {}, expected = [mock] } = twin twinTest('vite dev', name)( `vite dev: ${name}`, async () => { @@ -669,7 +756,11 @@ export const read = () => [String(Color.Red), String(Util.x)].join() const module = await evaluateServed(server, '/src/entry.ts', { skip: /^\/@(vite|react-refresh)\b/, }) - expect(String(module.read())).toBe(expected) + expect( + (module.read() as Array).map((value) => + describeValue(value, 'MARK_SECRET'), + ), + ).toEqual(expected) }, 30_000, ) @@ -682,11 +773,18 @@ export const read = () => [String(Color.Red), String(Util.x)].join() files, entry: 'src/entry.ts', start: { - importProtection: { ...importProtection, behavior: 'mock' }, + importProtection: { + ...importProtection, + behavior: 'mock', + mockAccess: 'off', + }, }, }) expect({ - read: (await client.run(`return String(entry.read())`)).result, + read: ( + await client.run(`${describeValuePrelude} +return entry.read().map((value) => describeValue(value, 'MARK_SECRET'))`) + ).result, hasRealCode: client.where('MARK_SECRET') !== 'nowhere', }).toEqual({ read: expected, hasRealCode: false }) }, diff --git a/packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts b/packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts index 7105d58e3b0..a2fceb56796 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/reported-location.test.ts @@ -14,12 +14,11 @@ import { startRsbuildDevServer } from '../../rsbuild-dev-server' import { distinct, locationIn, - reactRuntimeFiles, rejection, reportedViolations, startProtectedDevServer, + violationReports, } from '../../importProtection/start-apps' -import { violationReports } from '../../importProtection/violation-report' import type { StartBuildOptions, StartBundler } from '../../build-harness' import type { ReportedTraceStep, @@ -33,14 +32,14 @@ type Channel = 'vite dev' | 'rsbuild dev' | 'vite build' | 'rsbuild build' /** * The importer locations reported for an app whose `src/a.tsx` is `code` * (imported by the entry `src/entry.ts`) and imports from the denied - * `src/secret.server.ts` (none when nothing is reported). + * `src/secret.server.ts` (none when nothing is reported). JSX compiles + * with the real React (a dependency of this package). */ async function reportedLocations( channel: Channel, code: string, ): Promise> { const files = { - ...reactRuntimeFiles, 'src/secret.server.ts': `export const eq = () => 1 export const secret = () => 'secret' export default function Secret() { @@ -293,6 +292,89 @@ export const fn = myServerFn.handler(async () => secret()) ) }) +// The server side: a `.client` file (denied to the server by the default +// rules) used only inside code the Start compiler removes from the server +// (`createClientOnlyFn`) is not reported; a use the server keeps is reported +// on the server, at that use. +// Source: V1 ip S16 (the compiler-removal rows of analysis.usage.test.ts had +// no server-side twin on a real adapter). +describe('the reported location of a server violation is the leaking use', () => { + type ServerChannel = 'vite dev' | 'vite build' | 'rsbuild build' + const serverChannels: Array = [ + 'vite dev', + 'vite build', + 'rsbuild build', + ] + + const insideOnly = `import { createClientOnlyFn } from '@tanstack/react-start' +import { widget } from './widget.client' +export const fn = createClientOnlyFn(() => widget()) +` + const insideAndOutside = `${insideOnly}export const leak = () => widget() +` + + /** + * ` ` of each violation reported for an app whose + * `src/a.ts` is `code` (the server entry re-exports it) and imports the + * client-only `src/widget.client.ts`. + */ + async function serverReports( + channel: ServerChannel, + code: string, + ): Promise> { + const files = { + 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, + 'src/a.ts': code, + 'src/entry.ts': `export * from './a'\n`, + 'src/client.ts': `export const read = () => 'client'\n`, + } + let output: string | Array + if (channel === 'vite dev') { + const server = await startProtectedDevServer(files) + await server.load('/src/entry.ts', { environment: 'ssr' }) + output = server.logs + } else { + const bundler = channel === 'vite build' ? 'vite' : 'rsbuild' + output = await buildStart({ + bundler, + files, + entry: { client: 'src/client.ts', ssr: 'src/entry.ts' }, + }).then( + ({ client, ssr }) => [...client.warnings, ...ssr.warnings], + (error: Error) => { + if (reportedViolations(error.message).length === 0) { + throw error + } + return error.message + }, + ) + } + return distinct( + reportedViolations(output).map( + ({ environment, importer }) => `${environment} ${importer}`, + ), + ) + } + + test.each(serverChannels)( + '%s: a use outside createClientOnlyFn is reported on the server at the use; the use inside is not', + async (channel) => { + expect(await serverReports(channel, insideAndOutside)).toEqual([ + `server ${locationIn('src/a.ts', insideAndOutside, 4, 'widget()')}`, + ]) + }, + timeout, + ) + + test.each(serverChannels)( + '%s: a use only inside createClientOnlyFn is not reported', + async (channel) => { + expect(await serverReports(channel, insideOnly)).toEqual([]) + }, + timeout, + ) +}) + /** * The report of the one violation of a build of `files` (client entry * `src/entry.ts`, default rules). diff --git a/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts index 42a8ff7b8f8..d0bb3b2f154 100644 --- a/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { describe, expect, test } from 'vitest' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { buildStart } from '../../build-harness' import { distinct, @@ -11,9 +11,21 @@ import { startRsbuildDevServer, startRsbuildStartDevServer, } from '../../rsbuild-dev-server' +import type { RsbuildDevServer } from '@tanstack/compiler-test-utils/rsbuild-dev-server' import type { ViolationInfo } from '../../../src/import-protection/trace' import type { ImportProtectionOptions } from '../../../src/schema' +/** + * Each test's timeout, and the time an Rsbuild dev server may take to + * compile: only a guard against a compile that never comes, never shorter + * than the test's own timeout (a slow compile on a loaded machine fails the + * test by its timeout, not by a shorter deadline of the harness). + */ +const timeout = 60_000 +const compileTimeout = timeout + +// One dev server for every importer: a client module of each JavaScript and +// TypeScript type imports the same `.server` file. describe('Rsbuild dev: a client module importing a .server file', () => { const esm = `import { secret } from './secret.server.js' export const page = () => secret @@ -21,8 +33,7 @@ export const page = () => secret const commonJs = `const { secret } = require('./secret.server.js') exports.page = () => secret ` - - test.each([ + const importers = [ { extension: 'ts', page: esm }, { extension: 'js', page: esm }, { extension: 'jsx', page: esm }, @@ -30,32 +41,60 @@ exports.page = () => secret { extension: 'cjs', page: commonJs }, { extension: 'mts', page: esm }, { extension: 'cts', page: commonJs }, - ])( - 'is reported and mocked when it is a .$extension file', - async ({ extension, page }) => { - const violations: Array = [] - const server = await startRsbuildDevServer({ - importProtection: { - onViolation: (info) => { - violations.push(info as ViolationInfo) - }, - }, - files: { - 'src/secret.server.js': `export const secret = 'MARK_SERVER_SECRET'\n`, - [`src/page.${extension}`]: page, - 'src/entry.ts': `import { page } from './page.${extension}' -console.log(page()) -`, + ] + + let violations: Array + let server: RsbuildDevServer + let dispose: (() => Promise) | undefined + afterAll(() => dispose?.()) + beforeAll(async () => { + violations = [] + server = await startRsbuildDevServer({ + registerCleanup: (cleanup) => { + dispose = cleanup + }, + compileTimeout, + importProtection: { + onViolation: (info) => { + violations.push(info as ViolationInfo) }, - }) + }, + files: { + 'src/secret.server.js': `export const secret = 'MARK_SERVER_SECRET'\n`, + ...Object.fromEntries( + importers.map(({ extension, page }) => [ + `src/page.${extension}`, + page, + ]), + ), + 'src/entry.ts': importers + .map( + ({ extension }) => + `import { page as ${extension} } from './page.${extension}'\nconsole.log(${extension}())\n`, + ) + .join(''), + }, + }) + }, timeout) - expect(server.errors).toEqual([]) + test('the compile succeeds, without the real module', async () => { + expect(server.errors).toEqual([]) + expect(Object.values(await server.outputs()).join('\n')).not.toContain( + 'MARK_SERVER_SECRET', + ) + }) + + test.each(importers)( + 'is reported when it is a .$extension file', + ({ extension }) => { expect( - violations.map((info) => ({ - type: info.type, - importer: path.relative(server.root, info.importer), - specifier: info.specifier, - })), + violations + .filter((info) => info.importer.endsWith(`/src/page.${extension}`)) + .map((info) => ({ + type: info.type, + importer: path.relative(server.root, info.importer), + specifier: info.specifier, + })), ).toEqual([ { type: 'file', @@ -63,11 +102,7 @@ console.log(page()) specifier: './secret.server.js', }, ]) - expect(Object.values(await server.outputs()).join('\n')).not.toContain( - 'MARK_SERVER_SECRET', - ) }, - 30_000, ) }) @@ -77,6 +112,7 @@ console.log(page()) // the client environment only). test('Rsbuild dev with Start: a server import of a .client file is mocked on the server and reported', async () => { const server = await startRsbuildStartDevServer({ + compileTimeout, files: { 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, 'src/entry.ts': `import { widget } from './widget.client' @@ -97,7 +133,7 @@ console.log(read()) Object.values(await server.outputs(environment)).join('\n') expect(await code('ssr')).not.toContain('MARK_CLIENT') expect(await code('client')).toContain('MARK_CLIENT') -}, 30_000) +}, timeout) // Source: round 4 mutation testing (rsbuild host, `behavior` in dev). describe('Rsbuild dev: behavior', () => { @@ -112,6 +148,7 @@ describe('Rsbuild dev: behavior', () => { '$behavior: a violation is reported in the compile $reportedAs', async ({ behavior, reportedAs }) => { const server = await startRsbuildDevServer({ + compileTimeout, importProtection: { behavior }, files: { 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, @@ -128,7 +165,7 @@ console.log(secret()) ).toEqual(['client src/entry.ts:2:13']) expect(reportedViolations(server[other])).toEqual([]) }, - 30_000, + timeout, ) }) @@ -153,6 +190,7 @@ console.log(secret(), other) "log '$log': a rebuild for an unrelated edit reports $afterRebuild", async ({ log, afterRebuild }) => { const server = await startRsbuildDevServer({ + compileTimeout, importProtection: { log }, files: app, }) @@ -165,7 +203,7 @@ console.log(secret(), other) expect(server.rebuilt).toContain('src/other.ts') expect(reported()).toEqual(afterRebuild) }, - 30_000, + timeout, ) // Source: round 4 mutation testing (rsbuild host, entries without a @@ -177,6 +215,7 @@ console.log(secret(), other) 'a compile in which $module is missing reports only that', async ({ file }) => { const server = await startRsbuildDevServer({ + compileTimeout, importProtection: {}, files: app, }) @@ -187,7 +226,7 @@ console.log(secret(), other) ), ]) }, - 30_000, + timeout, ) // Source: mutation run R3 ip-rsbuild R4 (the resolve cache of a previous @@ -195,6 +234,7 @@ console.log(secret(), other) test('a denied specifier that an edit makes resolve elsewhere is reported with its new target', async () => { const resolved: Array = [] const server = await startRsbuildDevServer({ + compileTimeout, importProtection: { client: { specifiers: [/^\.\/lib$/] }, onViolation: (info) => { @@ -216,7 +256,7 @@ console.log(secret(), other) 'src/entry.ts': `import { lib } from './lib'\nconsole.log(lib, 2)\n`, }) expect(resolved).toEqual(['src/lib.ts', 'src/lib/index.ts']) - }, 30_000) + }, timeout) }) // Rsbuild's `source.transformImport` rewrites a library's named imports @@ -346,6 +386,7 @@ export const read = () => secret() + other // 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({ + compileTimeout, importProtection: { client: { specifiers: ['db-lib'] } }, files: { 'node_modules/db-lib/package.json': JSON.stringify({ @@ -377,7 +418,7 @@ console.log(a(), b(), query()) ), ].sort(), ).toEqual(['db-lib', 'src/a.server.ts', 'src/b.server.ts']) -}, 30_000) +}, timeout) // Source: round 4 mutation testing (rsbuild host, `mockAccess`). describe('Rsbuild dev: mockAccess', () => { @@ -393,6 +434,7 @@ describe('Rsbuild dev: mockAccess', () => { '$mockAccess: the use of a mocked import is reported with console $reports', async ({ mockAccess, reports }) => { const server = await startRsbuildDevServer({ + compileTimeout, importProtection: { mockAccess, client: { specifiers: ['db-lib'] }, @@ -437,6 +479,6 @@ console.log(a(), query()) ]), ) }, - 30_000, + timeout, ) }) From cdbf47fc0bf8fe9766599dadd12f549b8170c1de Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:46:56 +0200 Subject: [PATCH 0445/1425] test(compiler-test-utils): the real file watcher in the Vite dev-server harness The dev-server harness no longer replays file events from a filesystem diff (FIX-PLAN A5, GEN-26). Vite's own chokidar watcher runs in every mode; write() writes files and settle() waits, by events only, for: - the watcher's report of each written file (a test writes like a user, after chokidar's per-file change throttle passed: it drops a second change within that moment), - the end of Vite's handling of each reported event (hotUpdateEnvironments, or the error Vite logs; AsyncLocalStorage ties both to the event), - files plugins wrote meanwhile: a barrier file change, then every app file as the watcher last saw it; until a round sees no new event (a plugin that keeps writing never settles: error after 10 rounds). Full-bundle dev (bundledDev) waits for the dev engine's watcher, the plugins' watchChange hooks of each environment and the dev engine output, instead of forcing a full rebuild. Removed with the replay: FileEventOptions (order, batch) and their tests. The same-contents write test (H.3 load flake) now waits for the watcher's own change event. start-plugin-core (W2-j's file, minimal): the bundledDev factory edit test passed only because the replay forced a full rebuild. Split into a passing #8062 control (bundles again without errors) and a .fails pin: the importer of an edited factory is not recompiled in full-bundle dev (start-compiler-plugin/plugin.ts:412-424). Verified red without .fails: "expected [ 'assets/a.js' ] to deeply equal []". Co-Authored-By: Claude Opus 5.5 --- .../compiler-test-utils/src/dev-server.ts | 925 ++++++++++++------ .../tests/dev-server.test.ts | 165 ++-- .../tests/integration/browser.test.ts | 1 - .../start-compiler-dev-server.test.ts | 69 +- 4 files changed, 757 insertions(+), 403 deletions(-) diff --git a/packages/compiler-test-utils/src/dev-server.ts b/packages/compiler-test-utils/src/dev-server.ts index fde3c933063..a9bb8c54ea7 100644 --- a/packages/compiler-test-utils/src/dev-server.ts +++ b/packages/compiler-test-utils/src/dev-server.ts @@ -1,25 +1,42 @@ /** * Dev-server harness for tests of what a dev server serves after edits: * starts a real Vite dev server (JS API, unbundled dev) on a temporary app, - * lets the test create, edit, rename and delete files, replays the file - * events the way Vite's watcher reports them and waits until the server has - * handled them (plugin `watchChange` and `hotUpdate` hooks, HMR), and then - * requests modules per environment and reports the HMR payloads sent to each - * environment. + * lets the test create, edit, rename and delete files, which Vite's own file + * watcher (chokidar) reports, waits until the server has handled what the + * watcher reported (plugin `watchChange` and `hotUpdate` hooks, HMR), and + * then requests modules per environment and reports the HMR payloads sent to + * each environment. * - * File events are replayed deterministically instead of coming from a real - * file watcher: `settle()` compares the app's files (contents and - * modification times) with what it saw last, emits an `add`, `change` or - * `unlink` event for each difference (including files plugins wrote while - * handling earlier events, e.g. a generated route tree, even with the same - * contents), waits for the server to handle each one, and repeats until the - * files are stable. The order of the events and whether they are emitted one - * at a time are options (`FileEventOptions`). + * Nothing replays file events: `write()` writes files, the watcher reports + * them, and `settle()` waits for the server, by events only: + * - each file the test wrote is reported by the watcher (a test writes like + * a user, a moment after the last change the watcher reported: chokidar + * drops a second change of a file within that moment); + * - each reported event is handled: Vite calls `hotUpdateEnvironments` at + * the end of its handling of a file event, or logs the error that stopped + * it (`AsyncLocalStorage` ties both to the event); + * - files plugins wrote meanwhile (e.g. a generated route tree): the harness + * changes a barrier file, which the watcher reports after the writes made + * before it, once the server handled everything, and checks that every + * app file is as the watcher last saw it (when the server finished + * handling its event, or when chokidar dropped a change of it); until a + * round sees no new event. + * A plugin that keeps writing files on each event never settles (an error + * after 10 rounds). + * + * In Vite's full-bundle dev (`bundledDev`), Rolldown's dev engine watches + * the modules it bundled and Vite's watcher watches what plugins add to it: + * `settle()` waits for each watcher that watches a written file, for the + * plugins' `watchChange` hooks of each environment (Vite sends no hot update + * there), and for the dev engine's output. * * Plugin-agnostic: the caller supplies the plugins and the directory the apps * live in (where their imports resolve). */ +import { AsyncLocalStorage } from 'node:async_hooks' +import { readFileSync, statSync } from 'node:fs' import { + lstat, mkdir, readFile, readdir, @@ -41,6 +58,7 @@ import type { AppOptions } from './build-harness' import type { ModuleGraphOptions, ModuleStub } from './module-linker' import type { DevEnvironment, + FSWatcher, HotPayload, InlineConfig, LogErrorOptions, @@ -53,7 +71,6 @@ import type { /** The part of Rolldown's dev engine (`bundledDev`) the harness uses. */ interface BundledDevEngine { ensureLatestBuildOutput: () => Promise - triggerFullBuild: () => void getBundleState: () => Promise<{ lastBuildErrored: boolean }> } @@ -67,8 +84,9 @@ export interface DevServerOptions extends AppOptions { /** Serves over HTTP on a free port, for `fetch()`. Default `false`. */ listen?: boolean /** - * How long `settle()` waits for the server to handle one file event, in - * milliseconds. Default 10 000. + * How long `settle()` waits for one step (the watcher reporting a file, + * the server handling a file event), in milliseconds: only a guard against + * an event that never comes. Default 20 000. */ eventTimeout?: number /** @@ -76,43 +94,20 @@ export interface DevServerOptions extends AppOptions { * (`experimental.bundledDev`: Rolldown's dev engine bundles it) from * `entry` (relative to the app root), eagerly (no lazy chunks). * `clientBundle()` returns the output; the other environments are served - * as usual. `settle()` makes the dev engine rebuild the client bundle from - * the edited files. + * as usual. `settle()` covers files the dev engine or Vite's watcher + * watches (see the module comment). */ bundledDev?: { entry: string } - /** How `settle()` reports file events by default (see `FileEventOptions`). */ - fileEvents?: FileEventOptions /** * Serves the app to a real browser page (`./browser`) as `vite dev` does: - * listens on a free port (`url`) with Vite's HMR WebSocket, Vite's own - * file watcher reports the app's file changes (`write()` them; `settle()` - * and `edit()` are not available), dependencies are pre-bundled by Vite's - * dependency optimizer (discovery on, cache in the app directory), and - * `index.html` is served (`appType: 'spa'`). Default `false`. + * listens on a free port (`url`) with Vite's HMR WebSocket, dependencies + * are pre-bundled by Vite's dependency optimizer (discovery on, cache in + * the app directory), and `index.html` is served (`appType: 'spa'`). + * Default `false`. */ browser?: boolean } -/** - * How `settle()` reports the file events of a round. A real watcher gives no - * order guarantee: chokidar may report a rename as `add` then `unlink`, and - * emits events while the server still handles earlier ones. - */ -export interface FileEventOptions { - /** - * `deletions-first` (default): deletions, then creations, then edits (a - * rename is `unlink` then `add`). `creations-first`: creations, then - * deletions, then edits (a rename is `add` then `unlink`). - */ - order?: 'deletions-first' | 'creations-first' - /** - * Emits all the events of a round at once, then waits until the server has - * handled each of them, instead of waiting for each event before emitting - * the next one (default `false`). - */ - batch?: boolean -} - export interface HmrMessage { /** Environment the payload was sent to (`client`, `ssr`, ...). */ environment: string @@ -140,27 +135,28 @@ export interface DevServer { /** Content of an app file (relative to the app root). */ read: (relative: string) => Promise /** - * Writes app files (relative to the app root); `null` deletes a file. The - * server only sees the changes on the next `settle()`. + * Writes app files (relative to the app root); `null` deletes a file. + * Vite's file watcher reports them; `settle()` waits until the server has + * handled them. */ write: (files: Record) => Promise - /** Moves an app file. The server sees it on the next `settle()`. */ + /** Moves an app file (the watcher reports it; see `write`). */ rename: (from: string, to: string) => Promise /** - * Reports every change to the app's files since the last call to the - * server as watcher events (by default deletions, then creations, then - * edits, one at a time; see `FileEventOptions`) and waits until it has - * handled each one, until no file changes anymore. A write that leaves a - * file's contents as they were is an edit too. Returns the HMR payloads - * sent meanwhile. + * Waits until the watcher has reported every file written since the last + * call (by the test or by plugins) and the server has handled each event + * (see the module comment). Rejects with the error that stopped the + * handling of an event. Returns the HMR payloads sent meanwhile. Not + * available with `server.hmr: false` (then nothing tells when Vite handled + * an event, and the server runs without a watcher). + */ + settle: () => Promise> + /** `write(files)`, then `settle()`. */ + edit: (files: Record) => Promise> + /** + * File events the watcher reported, in order (paths relative to the root; + * the harness's own barrier file left out). */ - settle: (options?: FileEventOptions) => Promise> - /** `write(files)`, then `settle(options)`. */ - edit: ( - files: Record, - options?: FileEventOptions, - ) => Promise> - /** File events `settle()` reported, in order (paths relative to the root). */ events: Array<{ event: FileEvent; file: string }> /** * Transformed code of a module in an environment (default `client`), as @@ -204,35 +200,143 @@ export interface DevServer { close: () => Promise } -/** Directories `settle()` never reports changes in. */ -const ignoredDirectories = new Set(['node_modules', '.git', 'dist']) +/** + * Directories the harness does not expect the watcher to report: the ones + * Vite's watcher ignores (`node_modules`, which holds the dependency + * optimizer's cache, `.git`, `test-results`), and the harness's own. + */ +const unwatchedDirectories = new Set([ + 'node_modules', + '.git', + 'test-results', + '.dev-server-harness', +]) + +/** + * The harness's barrier files: a change of one, which the watcher reports + * like any file change, follows every write made before it. Several, as the + * watcher does not report a second change of a file within a moment of the + * first (see `changeThrottled`). + */ +const barrierFiles = [0, 1, 2, 3].map( + (index) => `.dev-server-harness/barrier-${index}`, +) + +/** An app file as the harness reads it. */ +interface AppFile { + code: string + mtimeMs: number +} + +/** A file event the watcher reported, and how the server handled it. */ +interface ReportedEvent { + /** Order of the report. */ + seq: number + event: FileEvent + /** Absolute path. */ + file: string + /** The watcher that reported it (a restart replaces the watcher). */ + watcher: FSWatcher + barrier: boolean + handled: boolean + /** The `watchChange` hooks of each environment running for it. */ + watchChanges: Array> +} /** * Writes an app to a temporary directory in `options.dir` and starts a Vite - * dev server on it, without a file watcher (see `settle()`). `close()`, which - * `registerCleanup` also receives, closes the server and removes the - * directory. + * dev server on it with its file watcher, and resolves once the watcher + * watches the app. `close()`, which `registerCleanup` also receives, closes + * the server and removes the directory. */ export async function createDevServer( options: DevServerOptions, ): Promise { const { plugins = [], config = {}, bundledDev, browser = false } = options const listen = options.listen ?? browser - const eventTimeout = options.eventTimeout ?? 10_000 + const eventTimeout = options.eventTimeout ?? 20_000 + // Vite handles file events with HMR only: without it, nothing tells when + // the server has handled one, so the server runs without a watcher. In + // full-bundle dev, Vite's watcher watches only what plugins add to it, and + // Rolldown's dev engine watches the modules it bundled. + const watching = !bundledDev && config.server?.hmr !== false + const devEngineWatching = !!bundledDev && config.server?.hmr !== false const app = await createApp({ ...options, prefix: options.prefix ?? '.dev-server-', + files: { + ...options.files, + ...(watching && + Object.fromEntries(barrierFiles.map((file) => [file, '0']))), + }, }) const root = app.root + const file = (relative: string) => path.join(root, relative) const logs: Array = [] const hmr: Array = [] const events: Array<{ event: FileEvent; file: string }> = [] - let pendingEvent: - | { - resolve: () => void - reject: (error: Error) => void + + /** Every file event the watcher reported, in order. */ + const reported: Array = [] + /** The file event whose handling runs in the current async context. */ + const handling = new AsyncLocalStorage() + /** Errors that stopped the handling of an event, for the next `settle()`. */ + const failures: Array = [] + /** + * Each app file (relative path) as the watcher last saw it: its contents, + * and the modification time of the stats chokidar took (it reports a + * later write of the file when its modification time changed), updated + * when the server finished handling the file's event (writes during the + * handling are served) and when chokidar drops a change. A file that + * differs from it is owed an event: the harness waits for that event, + * never takes a timestamp for one. + */ + const known = new Map() + /** + * Files written by `write()` whose event the watcher has to report: the + * event must come after `seq`. + */ + const expected: Array<{ file: string; seq: number; devEngineSeq: number }> = + [] + const expect = (absolute: string) => + expected.push({ + file: absolute, + seq: reported.length, + devEngineSeq: devEngineReports.length, + }) + let barrierCount = 0 + + /** Wakes up the waits whenever something they wait for may have happened. */ + let wake = () => {} + let woken = new Promise((resolve) => { + wake = resolve + }) + const notify = () => { + wake() + woken = new Promise((resolve) => { + wake = resolve + }) + } + + const handled = (event: ReportedEvent, error?: unknown) => { + if (!event.handled) { + event.handled = true + // The server has seen the file as it is now: writes of it while the + // server handled its event (before Vite invalidated its modules) are + // served, whether or not the watcher reports them again. + if (!event.barrier && event.event !== 'unlink') { + see(event.event, event.file, undefined) + } + if (error !== undefined) { + failures.push( + error instanceof Error + ? error + : new Error(`dev server error: ${String(error)}`), + ) } - | undefined + notify() + } + } const record = (level: string) => (logged: string, logOptions?: LogErrorOptions) => { @@ -244,8 +348,11 @@ export async function createDevServer( : String(message), ) logs.push(`${level}: ${text}`) - if (level === 'error' && pendingEvent) { - pendingEvent.reject( + // An error that stopped the handling of a file event + const event = handling.getStore() + if (level === 'error' && event) { + handled( + event, message instanceof Error ? message : new Error(`dev server error: ${text}`, { @@ -253,10 +360,12 @@ export async function createDevServer( }), ) } + notify() } const warned = new Set() const logger: Logger = { - info: () => {}, + // e.g. "server restarted." + info: () => notify(), warn: record('warn'), warnOnce: (message) => { if (!warned.has(message)) { @@ -272,17 +381,34 @@ export async function createDevServer( /** Code of each chunk of the latest client bundle (`bundledDev`). */ let clientOutput: Record = {} + /** The modules of the latest client bundle (`bundledDev`). */ + const bundledModules = new Set() + /** Files the dev engine's watcher reported (`bundledDev`), in order. */ + const devEngineReports: Array = [] const clientBundlePlugin: Plugin = { name: 'dev-server-harness:client-bundle', applyToEnvironment: (environment) => environment.name === 'client', generateBundle(_outputOptions, bundle) { clientOutput = {} + bundledModules.clear() for (const output of Object.values(bundle)) { if (output.type === 'chunk') { clientOutput[output.fileName] = output.code + for (const id of output.moduleIds) { + bundledModules.add(id) + } } } }, + watchChange(id, { event }) { + devEngineReports.push(path.resolve(id)) + events.push({ + event: + event === 'create' ? 'add' : event === 'delete' ? 'unlink' : 'change', + file: relativeOf(path.resolve(id)), + }) + notify() + }, } const bundledDevConfig: InlineConfig = bundledDev ? { @@ -300,6 +426,151 @@ export async function createDevServer( } : {} + const relativeOf = (absolute: string) => + path.relative(root, absolute).split(path.sep).join('/') + + /** + * The files a watcher reported a change of a moment ago: chokidar drops + * another change of one of them until the moment passed (its private + * per-file throttle, `_throttled`). + */ + const changeThrottled = (watcher: FSWatcher = vite.watcher) => { + const throttled = (watcher as unknown as { _throttled?: unknown }) + ._throttled + if (!(throttled instanceof Map)) { + throw new Error( + "the harness waits on chokidar's per-file change throttle (`_throttled`), which this version of Vite's watcher does not have", + ) + } + const changes = throttled.get('change') as Map | undefined + return [...(changes?.keys() ?? [])] as Array + } + + /** + * Records an app file as the watcher saw it (reported, or dropped): its + * contents now, and the modification time of the stats the watcher took. + */ + const see = (name: string, absolute: string, stats: unknown) => { + const relative = relativeOf(absolute) + if (name === 'unlink') { + known.delete(relative) + return + } + try { + known.set(relative, { + code: readFileSync(absolute, 'utf8'), + mtimeMs: + (stats as { mtimeMs?: number } | undefined)?.mtimeMs ?? + statSync(absolute).mtimeMs, + }) + } catch { + // Removed meanwhile: the watcher reports that next + known.delete(relative) + } + } + + /** + * Follows a server (the first one, and the one each restart creates): + * the file events its watcher reports, and the HMR payloads it sends. + */ + const followed = new WeakSet() + const follow = (server: ViteDevServer) => { + const watcher = server.watcher + if ((watching || devEngineWatching) && !followed.has(watcher)) { + followed.add(watcher) + const emit = watcher.emit.bind(watcher) as ( + name: string, + ...args: Array + ) => boolean + ;(watcher as { emit: typeof emit }).emit = (name, ...args) => { + if (name !== 'add' && name !== 'change' && name !== 'unlink') { + return emit(name, ...args) + } + const absolute = path.resolve(root, String(args[0])) + const relative = path.relative(root, absolute).split(path.sep).join('/') + const event: ReportedEvent = { + seq: reported.length, + event: name, + file: absolute, + watcher, + barrier: barrierFiles.includes(relative), + handled: false, + watchChanges: [], + } + reported.push(event) + if (!event.barrier) { + events.push({ event: name, file: relative }) + } + if (!event.barrier) { + see(name, absolute, args[1]) + } + notify() + // Vite's listeners start handling the event in this context + const result = handling.run(event, () => emit(name, ...args)) + if (devEngineWatching) { + // In full-bundle dev, Vite's handling of a file event ends with + // the plugins' `watchChange` hooks of each environment (and the + // invalidation of the file's modules right after): it sends no + // hot update. + void Promise.allSettled(event.watchChanges).then(() => { + setImmediate(() => handled(event)) + }) + } + return result + } + // chokidar drops a change of a file within a moment of the last one + // it reported (`_throttled`, see `changeThrottled`): the watcher saw + // that write too, and will not report it. + const internals = watcher as unknown as { + _emit: (name: string, file: string, ...rest: Array) => unknown + } + const internalEmit = internals._emit.bind(watcher) + internals._emit = (name, changed, ...rest) => { + if (name === 'change') { + const absolute = path.resolve(root, changed) + if ( + changeThrottled(watcher).includes(absolute) && + !barrierFiles.includes(relativeOf(absolute)) + ) { + see(name, absolute, rest[0]) + notify() + } + } + return internalEmit(name, changed, ...rest) + } + } + for (const [name, environment] of Object.entries(server.environments)) { + const container = environment.pluginContainer + if (!followed.has(container.watchChange)) { + const watchChange = container.watchChange.bind(container) + const followedWatchChange: typeof watchChange = (...args) => { + const running = watchChange(...args) + handling.getStore()?.watchChanges.push(running) + return running + } + followed.add(followedWatchChange) + container.watchChange = followedWatchChange + } + const hot = environment.hot + if (followed.has(hot.send)) { + continue + } + const send = hot.send.bind(hot) as (...args: Array) => void + const recordingSend = (...args: Array) => { + hmr.push(hmrMessage(name, args)) + send(...args) + } + followed.add(recordingSend) + hot.send = recordingSend as typeof hot.send + } + } + const followPlugin: Plugin = { + name: 'dev-server-harness:follow', + configureServer: (server) => { + follow(server) + }, + } + let vite: ViteDevServer try { vite = await createServer( @@ -325,20 +596,30 @@ export async function createDevServer( // the client environment transforms the static imports of each // module it serves in the background, before the browser // requests them. - // `settle()` reports file events instead of a watcher, unless a - // browser page runs the app. - ...(!browser && { ws: false as const, watch: null }), + // Without a page, no HMR WebSocket (and no port to share). + ...(!browser && { ws: false as const }), + ...(!watching && !devEngineWatching && { watch: null }), + // Called at the end of Vite's handling of each file event hotUpdateEnvironments: async ( server: ViteDevServer, update: (environment: DevEnvironment) => Promise, ) => { - await Promise.all(Object.values(server.environments).map(update)) - pendingEvent?.resolve() + try { + await Promise.all( + Object.values(server.environments).map(update), + ) + } finally { + const event = handling.getStore() + if (event) { + handled(event) + } + } }, }, plugins: [ ...(typeof plugins === 'function' ? plugins({ root }) : plugins), ...(bundledDev ? [clientBundlePlugin] : []), + followPlugin, ], } satisfies InlineConfig, config, @@ -349,43 +630,6 @@ export async function createDevServer( throw error } - /** - * Records the HMR payloads of the current environments (again after a - * server restart, which replaces them). - */ - const recording = new WeakSet() - let recordedEnvironments: ViteDevServer['environments'] | undefined - /** The plugins' `watchChange` hooks running for the current file event. */ - const watchChanges: Array> = [] - const recordHmr = () => { - recordedEnvironments = vite.environments - for (const [name, environment] of Object.entries(vite.environments)) { - const container = environment.pluginContainer - if (!recording.has(container.watchChange)) { - const watchChange = container.watchChange.bind(container) - const recordingWatchChange: typeof watchChange = (...args) => { - const running = watchChange(...args) - watchChanges.push(running) - return running - } - recording.add(recordingWatchChange) - container.watchChange = recordingWatchChange - } - const hot = environment.hot - if (recording.has(hot.send)) { - continue - } - const send = hot.send.bind(hot) as (...args: Array) => void - const recordingSend = (...args: Array) => { - hmr.push(hmrMessage(name, args)) - send(...args) - } - recording.add(recordingSend) - hot.send = recordingSend as typeof hot.send - } - } - recordHmr() - let closed: Promise | undefined const close = () => { closed ??= (async () => { @@ -399,185 +643,268 @@ export async function createDevServer( } options.registerCleanup?.(close) - if (listen) { - await vite.listen(0) + /** + * Waits until `check` holds, checking again whenever something happened + * (a reported or handled event, a log), and at least every 25 ms (e.g. for + * a restart). `describe` says what did not happen within `eventTimeout`. + */ + async function until( + check: () => boolean | Promise, + describe: () => string | Promise, + ) { + const deadline = Date.now() + eventTimeout + while (!(await check())) { + if (closed) { + throw new Error('the dev server was closed') + } + if (Date.now() > deadline) { + throw new Error(`${await describe()} within ${eventTimeout} ms`) + } + let timer: ReturnType | undefined + await Promise.race([ + woken, + new Promise((resolve) => { + timer = setTimeout(resolve, 25) + }), + ]) + clearTimeout(timer) + } } - const file = (relative: string) => path.join(root, relative) - let snapshot = await readFiles(root) + /** + * Events whose handling has not ended. Those of a watcher a restart + * replaced are abandoned with the server that handled them. + */ + const unhandled = () => + reported.filter((event) => !event.handled && event.watcher === vite.watcher) + + const waitForHandling = () => + until( + () => unhandled().length === 0, + () => + `the dev server did not handle ${unhandled() + .map((event) => `"${event.event} ${relativeOf(event.file)}"`) + .join(', ')}`, + ) + + const barrierPaths = barrierFiles.map((relative) => path.join(root, relative)) /** - * Emits watcher events, all at once, and waits until the server has - * handled each of them. + * Waits until the watcher would report a change of any app file: a user's + * edits come a moment after the changes the last one caused (e.g. a route + * tree a plugin wrote), which the watcher would otherwise drop. */ - async function report(batch: Array<{ event: FileEvent; file: string }>) { - events.push(...batch) - recordHmr() - const described = batch - .map(({ event, file: relative }) => `"${event} ${relative}"`) - .join(', ') - if (bundledDev) { - // Vite's full-bundle mode sends no hot updates: the server has handled - // the event once the plugins' `watchChange` hooks have, and Vite has - // invalidated the file's modules right after. - watchChanges.length = 0 - for (const { event, file: relative } of batch) { - vite.watcher.emit(event, file(relative)) - } - let timer: ReturnType | undefined - try { - await Promise.race([ - Promise.all(watchChanges), - new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `the dev server did not handle ${described} within ${eventTimeout} ms`, - ), - ), - eventTimeout, - ) - }), - ]) - } finally { - clearTimeout(timer) + const userPace = () => + until( + () => + changeThrottled().every((throttled) => + barrierPaths.includes(throttled), + ), + () => + `the file watcher still drops changes of ${changeThrottled().join(', ')}`, + ) + + /** Waits until the current watcher watches the app (its initial scan). */ + const watcherReady = () => + until( + () => + (vite.watcher as unknown as { _readyEmitted?: boolean }) + ._readyEmitted === true, + () => 'the file watcher did not finish its initial scan', + ) + + /** The app files the watcher reports, with their contents. */ + async function appFiles() { + const files = new Map() + const visit = async (directory: string) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (!unwatchedDirectories.has(entry.name)) { + await visit(full) + } + } else if (entry.isFile()) { + try { + const { mtimeMs } = await stat(full) + files.set(relativeOf(full), { + code: await readFile(full, 'utf8'), + mtimeMs, + }) + } catch { + // Removed while reading + } + } } - await new Promise((resolve) => setImmediate(resolve)) - return } - let unhandled = batch.length - const handled = new Promise((resolve, reject) => { - const timer = setTimeout( - () => - finish( - new Error( - `the dev server did not handle ${described} within ${eventTimeout} ms`, - ), + await visit(root) + return files + } + + /** App files whose current state the watcher has not reported yet. */ + async function unreportedFiles() { + const current = await appFiles() + return [ + ...[...current.entries()] + .filter(([relative, { code, mtimeMs }]) => { + const reported = known.get(relative) + return reported?.code !== code || reported.mtimeMs !== mtimeMs + }) + .map(([relative]) => relative), + ...[...known.keys()].filter((relative) => !current.has(relative)), + ] + } + + /** Waits for the event of each file `write()` wrote, and its handling. */ + const waitForExpected = async () => { + const missing = () => + expected.filter( + ({ file: absolute, seq }) => + !reported.some( + (event) => event.file === absolute && event.seq >= seq, ), - eventTimeout, ) - // A server restart (e.g. a plugin restarting the server on a config - // file edit) abandons the HMR handling of the event: done once the - // restarted server's environments exist. - const restart = setInterval(() => { - if (vite.environments !== recordedEnvironments) { - recordHmr() - finish() - } - }, 20) - const finish = (error?: Error) => { - clearTimeout(timer) - clearInterval(restart) - if (error) { - reject(error) - } else { - resolve() - } - } - // Vite updates the environments once per event - pendingEvent = { - resolve: () => { - unhandled-- - if (unhandled === 0) { - finish() - } - }, - reject: finish, - } - }) - try { - for (const { event, file: relative } of batch) { - vite.watcher.emit(event, file(relative)) - } - await handled - // The payloads are sent right after the last environment updates - await new Promise((resolve) => setImmediate(resolve)) - } finally { - pendingEvent = undefined - } + await until( + () => missing().length === 0, + () => + `the file watcher did not report the write of ${missing() + .map(({ file: absolute }) => relativeOf(absolute)) + .join(', ')}`, + ) + expected.length = 0 } /** - * Rolldown's dev engine, which bundles the client (`bundledDev`). Vite has - * no public API to await the bundled output (vitejs/vite#22991). + * Changes a barrier file and waits until the server handled its event and + * everything reported before. */ - const devEngine = () => { - const client = vite.environments.client as - | { bundledDev?: { devEngine: BundledDevEngine } } - | undefined - if (!client?.bundledDev) { - throw new Error('the client is only bundled with `bundledDev`') + async function barrier() { + await watcherReady() + let barrierFile: string | undefined + await until( + () => { + const throttled = changeThrottled() + barrierFile = barrierPaths.find( + (barrierPath) => !throttled.includes(barrierPath), + ) + return barrierFile !== undefined + }, + () => 'no barrier file the watcher reports a change of', + ) + const seq = reported.length + await writeFile(barrierFile!, String(++barrierCount)) + await until( + () => + reported.some( + (event) => event.file === barrierFile && event.seq >= seq, + ), + () => 'the file watcher did not report the barrier file', + ) + await waitForHandling() + } + + const throwFailure = () => { + const failure = failures.shift() + if (failure) { + failures.length = 0 + throw failure } - return client.bundledDev.devEngine } - async function settle(eventOptions: FileEventOptions = {}) { - if (browser) { + /** + * `bundledDev`: waits until the dev engine's watcher reported each file + * `write()` wrote that the last bundle includes (it watches only those), + * and until the dev engine bundled the current files. + */ + async function settleBundle() { + const from = hmr.length + /** Whether Vite's watcher watches a file (plugins may make it watch). */ + const watched = (absolute: string) => + path.dirname(absolute) in vite.watcher.getWatched() + const missing = () => + expected.filter( + ({ file: absolute, seq, devEngineSeq }) => + (bundledModules.has(absolute) && + !devEngineReports.slice(devEngineSeq).includes(absolute)) || + (watched(absolute) && + !reported.some( + (event) => event.file === absolute && event.seq >= seq, + )), + ) + await until( + () => missing().length === 0, + () => + `the watchers did not report ${missing() + .map(({ file: absolute }) => relativeOf(absolute)) + .join(', ')}`, + ) + expected.length = 0 + await waitForHandling() + throwFailure() + await devEngine().ensureLatestBuildOutput() + return hmr.slice(from) + } + + async function settle() { + if (devEngineWatching) { + return settleBundle() + } + if (!watching) { throw new Error( - "settle() and edit() replay file events: with `browser`, Vite's own watcher reports them", + 'settle() and edit() need the file watcher and HMR, which a server with `server.hmr: false` runs without', ) } - const order = - eventOptions.order ?? options.fileEvents?.order ?? 'deletions-first' - const batch = eventOptions.batch ?? options.fileEvents?.batch ?? false const from = hmr.length for (let round = 0; ; round++) { - const current = await readFiles(root) - const removed = [...snapshot.keys()].filter((key) => !current.has(key)) - const added = [...current.keys()].filter((key) => !snapshot.has(key)) - const changed = [...current.keys()].filter( - (key) => snapshot.has(key) && snapshot.get(key) !== current.get(key), + await waitForExpected() + await waitForHandling() + throwFailure() + const before = events.length + await barrier() + // Files written before the barrier whose events come after it + let pending: Array = [] + await until( + async () => { + pending = await unreportedFiles() + return pending.length === 0 + }, + () => `the file watcher did not report ${pending.join(', ')}`, ) - snapshot = current - if (!removed.length && !added.length && !changed.length) { + await waitForHandling() + throwFailure() + if (events.length === before) { break } - // The dev engine watches no files: it bundles the edited files anew. - if (bundledDev) { - devEngine().triggerFullBuild() - } - if (round === 20) { + if (round === 10) { throw new Error( - `the app's files keep changing after 20 rounds of file events: ${[...removed, ...added, ...changed].join(', ')}`, + `the app's files keep changing after 10 rounds of file events: ${[ + ...new Set(events.slice(before).map((event) => event.file)), + ].join(', ')}`, ) } - const deletions = removed.map((relative) => ({ - event: 'unlink' as const, - file: relative, - })) - const creations = added.map((relative) => ({ - event: 'add' as const, - file: relative, - })) - const roundEvents = [ - ...(order === 'creations-first' - ? [...creations, ...deletions] - : [...deletions, ...creations]), - ...changed.map((relative) => ({ - event: 'change' as const, - file: relative, - })), - ] - if (batch) { - await report(roundEvents) - } else { - for (const item of roundEvents) { - await report([item]) - } - } } return hmr.slice(from) } async function write(files: Record) { + if (watching || devEngineWatching) { + await userPace() + } for (const [relative, code] of Object.entries(files)) { + const absolute = file(relative) + const exists = await lstat(absolute).then( + () => true, + () => false, + ) if (code === null) { - await rm(file(relative), { force: true }) - } else { - await mkdir(path.dirname(file(relative)), { recursive: true }) - await writeFile(file(relative), code) + if (exists) { + expect(absolute) + await rm(absolute, { force: true }) + } + continue } + expect(absolute) + await mkdir(path.dirname(absolute), { recursive: true }) + await writeFile(absolute, code) } } @@ -641,6 +968,35 @@ export async function createDevServer( return result.code } + /** + * Rolldown's dev engine, which bundles the client (`bundledDev`). Vite has + * no public API to await the bundled output (vitejs/vite#22991). + */ + const devEngine = () => { + const client = vite.environments.client as + | { bundledDev?: { devEngine: BundledDevEngine } } + | undefined + if (!client?.bundledDev) { + throw new Error('the client is only bundled with `bundledDev`') + } + return client.bundledDev.devEngine + } + + try { + if (listen) { + await vite.listen(0) + } + if (watching) { + await watcherReady() + for (const [relative, code] of await appFiles()) { + known.set(relative, code) + } + } + } catch (error) { + await close() + throw error + } + return { root, vite, @@ -649,13 +1005,19 @@ export async function createDevServer( read: (relative) => readFile(file(relative), 'utf8'), write, rename: async (from, to) => { - await mkdir(path.dirname(file(to)), { recursive: true }) - await rename(file(from), file(to)) + if (watching || devEngineWatching) { + await userPace() + } + const target = file(to) + expect(file(from)) + expect(target) + await mkdir(path.dirname(target), { recursive: true }) + await rename(file(from), target) }, settle, - edit: async (files, eventOptions) => { + edit: async (files) => { await write(files) - return settle(eventOptions) + return settle() }, events, transform, @@ -819,39 +1181,6 @@ function hmrMessage(environment: string, args: Array): HmrMessage { return { environment, type: payload.type, paths, payload } } -/** - * Each app file by path relative to the root: its contents and its - * modification time. A watcher reports a write that leaves the contents as - * they were as a change too (chokidar compares the modification time). The - * kernel updates modification times once per clock tick (a few ms): a - * rewrite in the tick of the last snapshot goes unseen. - */ -async function readFiles(root: string): Promise> { - const files = new Map() - const visit = async (directory: string) => { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const full = path.join(directory, entry.name) - if (entry.isDirectory()) { - if (!ignoredDirectories.has(entry.name)) { - await visit(full) - } - } else if (entry.isFile() || entry.isSymbolicLink()) { - try { - const { mtimeNs } = await stat(full, { bigint: true }) - files.set( - path.relative(root, full).split(path.sep).join('/'), - `${mtimeNs}\n${await readFile(full, 'utf8')}`, - ) - } catch { - // Removed while reading, or a dangling link - } - } - } - } - await visit(root) - return files -} - function stripAnsi(text: string) { // eslint-disable-next-line no-control-regex return text.replace(/\u001b\[[0-9;]*m/g, '') diff --git a/packages/compiler-test-utils/tests/dev-server.test.ts b/packages/compiler-test-utils/tests/dev-server.test.ts index 6bbafacf782..ead1fc0550c 100644 --- a/packages/compiler-test-utils/tests/dev-server.test.ts +++ b/packages/compiler-test-utils/tests/dev-server.test.ts @@ -1,10 +1,12 @@ /** - * Control tests of the dev-server harness (dev-server.ts): after `settle()`, - * the server serves the edited files in every environment, has seen the + * Control tests of the dev-server harness (dev-server.ts): the server's own + * file watcher reports the files a test writes, and after `settle()` the + * server serves the edited files in every environment, has seen the * creations, renames and deletions (and the files plugins wrote meanwhile), * and the reported HMR payloads tell updates from full reloads. */ import { existsSync } from 'node:fs' +import { readFile, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, onTestFinished, test } from 'vitest' @@ -48,6 +50,9 @@ export const dep = 'dep:' + leaf 'chunk.ts': `export const chunk = 'chunk'\n`, } +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + describe('dev server harness', () => { test( 'serves an edited module in each environment after settle()', @@ -105,21 +110,33 @@ describe('dev server harness', () => { 'reports created, renamed and deleted files to the plugins, and the files they write meanwhile', async () => { const seen: Array = [] - /** Writes `generated.ts` listing the `.ts` files whenever one changes. */ + /** + * Writes `generated.ts` listing the other `.ts` files when one of them + * is created, changed or deleted (and the list changed). + */ const generator: Plugin = { name: 'generator', async watchChange(id, { event }) { const relative = path.relative(server.root, id) + // e.g. the harness's barrier files + if (!relative.endsWith('.ts')) { + return + } seen.push(`${event} ${relative}`) - if (relative !== 'generated.ts') { - const { readdir } = await import('node:fs/promises') - const names = (await readdir(server.root)) - .filter((name) => name.endsWith('.ts')) - .filter((name) => name !== 'generated.ts') - .sort() - await server.write({ - 'generated.ts': `export const names = ${JSON.stringify(names)}\n`, - }) + if (relative === 'generated.ts') { + return + } + const names = (await readdir(server.root)) + .filter((name) => name.endsWith('.ts')) + .filter((name) => name !== 'generated.ts') + .sort() + const code = `export const names = ${JSON.stringify(names)}\n` + const generated = server.file('generated.ts') + if ( + !existsSync(generated) || + (await readFile(generated, 'utf8')) !== code + ) { + await writeFile(generated, code) } }, } @@ -128,28 +145,37 @@ describe('dev server harness', () => { plugins: [generator], }) - await server.write({ 'b.ts': `export const b = 1\n` }) - await server.settle() + await server.edit({ 'b.ts': `export const b = 1\n` }) + expect(seen).toEqual(['create b.ts', 'create generated.ts']) + expect((await server.import('/generated.ts')).names).toEqual([ + 'a.ts', + 'b.ts', + ]) + + seen.length = 0 await server.rename('a.ts', 'c.ts') await server.settle() - await server.edit({ 'b.ts': null }) - - expect(seen).toEqual([ - 'create b.ts', - 'create generated.ts', - 'delete a.ts', + // A watcher reports the two events of a rename in either order + expect(seen.sort()).toEqual([ 'create c.ts', + 'delete a.ts', 'update generated.ts', - 'delete b.ts', - 'update generated.ts', ]) - expect(await server.transform('/generated.ts')).toContain('["c.ts"]') + expect((await server.import('/generated.ts')).names).toEqual([ + 'b.ts', + 'c.ts', + ]) + + seen.length = 0 + await server.edit({ 'b.ts': null }) + expect(seen).toEqual(['delete b.ts', 'update generated.ts']) + expect((await server.import('/generated.ts')).names).toEqual(['c.ts']) }, timeout, ) - // A watcher reports a write as a change even when the contents stay the - // same (chokidar compares modification times). + // The watcher reports a write as a change even when the contents stay the + // same; settle() waits for that event, whatever the clock says. test( 'reports a write that leaves the contents as they were as a change', async () => { @@ -162,92 +188,59 @@ describe('dev server harness', () => { // So a plugin that rewrites a file on every file event, even with the same // contents, makes the server handle file events forever (a reload loop). - // The plugin waits before writing: the kernel updates modification times - // once per clock tick (a few ms), so two writes in one tick look like one. + // The plugin takes a moment before writing, longer than the moment within + // which the watcher drops a second change of a file. test( 'never settles while a plugin rewrites a file with the same contents on each event', async () => { const rewriter: Plugin = { name: 'rewriter', - async watchChange() { - await new Promise((resolve) => setTimeout(resolve, 25)) - await server.write({ 'generated.ts': `export const same = 1\n` }) + async watchChange(id) { + // e.g. the harness's barrier files + if (!id.endsWith('.ts')) { + return + } + await sleep(60) + await writeFile( + server.file('generated.ts'), + `export const same = 1\n`, + ) }, } const server = await createDevServer({ files, plugins: [rewriter] }) await expect( server.edit({ 'leaf.ts': `export const leaf = 'v2'\n` }), - ).rejects.toThrow(/keep changing after 20 rounds[\s\S]*generated\.ts/) - }, - timeout, - ) - - test.each([ - { - order: undefined, - events: ['unlink a.ts', 'add c.ts'], - seen: ['delete a.ts', 'create c.ts'], - }, - { - order: 'creations-first' as const, - events: ['add c.ts', 'unlink a.ts'], - seen: ['create c.ts', 'delete a.ts'], - }, - ])( - 'reports a rename in the order of the option ($order)', - async ({ order, events, seen }) => { - const watched: Array = [] - const server = await createDevServer({ - files: { 'a.ts': `export const a = 1\n` }, - plugins: [ - { - name: 'watched', - watchChange(id, { event }) { - watched.push(`${event} ${path.relative(server.root, id)}`) - }, - }, - ], - fileEvents: { order }, - }) - await server.rename('a.ts', 'c.ts') - await server.settle() - expect( - server.events.map(({ event, file }) => `${event} ${file}`), - ).toEqual(events) - expect(watched).toEqual(seen) - expect(await server.transform('/c.ts')).toContain('a = 1') + ).rejects.toThrow(/keep changing after 10 rounds[\s\S]*generated\.ts/) }, timeout, ) test( - 'batch: emits every event of a round before the server has handled the first one', + 'settle() waits for the handling of each file of an edit, however long a plugin takes', async () => { const handling: Array = [] const slow: Plugin = { name: 'slow', async watchChange(id) { const relative = path.relative(server.root, id) + if (!relative.endsWith('.ts')) { + return + } handling.push(`start ${relative}`) - await new Promise((resolve) => setTimeout(resolve, 50)) + await sleep(200) handling.push(`end ${relative}`) }, } const server = await createDevServer({ files, plugins: [slow] }) await server.load('/main.ts') await server.load('/orphan.ts') - const messages = await server.edit( - { - 'leaf.ts': `export const leaf = 'v2'\n`, - 'orphan.ts': `export const orphan = 'v2'\n`, - }, - { batch: true }, - ) - expect(handling.slice(0, 2).sort()).toEqual([ - 'start leaf.ts', - 'start orphan.ts', - ]) - expect(handling).toHaveLength(4) + const messages = await server.edit({ + 'leaf.ts': `export const leaf = 'v2'\n`, + 'orphan.ts': `export const orphan = 'v2'\n`, + }) + expect( + handling.filter((entry) => entry.startsWith('end')).sort(), + ).toEqual(['end leaf.ts', 'end orphan.ts']) expect(describeHmr(messages).sort()).toEqual([ 'client: full-reload *', 'client: update /main.ts', @@ -256,7 +249,6 @@ describe('dev server harness', () => { }, timeout, ) - test( 'serves a request over HTTP like the client environment', async () => { @@ -458,7 +450,7 @@ describe('bundledDev', () => { Object.keys(bundle).filter((fileName) => bundle[fileName]!.includes(marker)) test( - 'clientBundle() bundles the client from the entry, again after settle(); the other environments are served as usual', + 'clientBundle() bundles the client from the entry, again after an edit the dev engine watched; the other environments are served as usual', async () => { const server = await createDevServer({ bundledDev: { entry: 'main.ts' }, @@ -471,15 +463,16 @@ describe('bundledDev', () => { const before = await server.clientBundle() expect(containing(before, 'MARK_V1')).toHaveLength(1) expect(containing(before, 'MARK_OTHER')).toEqual([]) + expect((await server.import('/dep.ts')).dep).toBe('MARK_V1') await server.edit({ 'dep.ts': `export const dep = 'MARK_V2'\n` }) + expect(server.events).toEqual([{ event: 'change', file: 'dep.ts' }]) const after = await server.clientBundle() expect({ v1: containing(after, 'MARK_V1'), v2: containing(after, 'MARK_V2').length, }).toEqual({ v1: [], v2: 1 }) - expect((await server.import('/dep.ts')).dep).toBe('MARK_V2') }, timeout, ) diff --git a/packages/compiler-test-utils/tests/integration/browser.test.ts b/packages/compiler-test-utils/tests/integration/browser.test.ts index d96fc3e971e..124859f70c4 100644 --- a/packages/compiler-test-utils/tests/integration/browser.test.ts +++ b/packages/compiler-test-utils/tests/integration/browser.test.ts @@ -47,7 +47,6 @@ test( ...app('import.meta.hot'), }, }) - await expect(server.settle()).rejects.toThrow(/watcher/) const page = await openPage(server.url!, { registerCleanup: onTestFinished, }) diff --git a/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts b/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts index 53274046c96..1cf04f4606e 100644 --- a/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/start-compiler-dev-server.test.ts @@ -460,32 +460,65 @@ export const label = () => 'MARK_CLIENT_LABEL' timeout, ) - // Source: issue #8062 (a hook of the Start compiler plugin threw on an - // edit in bundledDev; fix #8061 had no test), oracle-gaps H4 / DV29. - test( - 'a module whose factory becomes a server fn builder: the client bundle drops the handler, without errors', - async () => { - const server = await startDevServer({ - bundledDev: { entry: 'src/a.tsx' }, - files: { - 'src/b.ts': plain, - 'src/db.server.ts': `export const db = { x: () => 'MARK_DB_SECRET' }\n`, - 'src/a.tsx': `import { base } from './b' + /** + * Serves `src/a.tsx`, which calls `.handler()` on `base` from `src/b.ts` + * (a plain object at first) with a handler reading a server-only module, + * and edits `src/b.ts` so that `base` becomes a server fn builder. + */ + async function editFactoryIntoBuilder() { + const server = await startDevServer({ + bundledDev: { entry: 'src/a.tsx' }, + files: { + 'src/b.ts': plain, + 'src/db.server.ts': `export const db = { x: () => 'MARK_DB_SECRET' }\n`, + 'src/a.tsx': `import { base } from './b' import { db } from './db.server' export const fn = base.handler(async () => db.x()) `, - }, - }) - expect( - containing(await server.clientBundle(), 'MARK_DB_SECRET'), - ).toHaveLength(1) + }, + }) + expect( + containing(await server.clientBundle(), 'MARK_DB_SECRET'), + ).toHaveLength(1) + await server.edit({ 'src/b.ts': factory }) + return server + } - await server.edit({ 'src/b.ts': factory }) + // Source: issue #8062 (a hook of the Start compiler plugin threw on an + // edit in bundledDev; fix #8061 had no test), oracle-gaps H4 / DV29. + test( + 'a module whose factory becomes a server fn builder bundles again without errors', + async () => { + const server = await editFactoryIntoBuilder() + await server.clientBundle() + expect(server.logs.filter((log) => log.startsWith('error:'))).toEqual([]) + }, + timeout, + ) + // Control: "Vite dev: editing a server fn factory" above (unbundled dev, + // where the compiler invalidates the importers of an edited module). + + // Bug: in full-bundle dev, an edit of a module that turns what another + // module imports into a server fn builder does not recompile the + // importer: the dev engine re-bundles the edited module only, and the + // Start compiler's bundledDev `watchChange` forgets the edited module + // alone, so the importer keeps the output compiled against the old + // `base`, handler and server-only imports included. + // Source: packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts:412-424 + // (client: `invalidateModule(id)` of the edited module only; the + // unbundled path invalidates the importers, :318-358). + // Impact: with `experimental.bundledDev`, the page keeps loading the + // handler and its server-only modules (here a `.server` module) in the + // client until the dev server restarts. Found with the real watchers + // (the replaying harness forced a full rebuild, which hid it). + test.fails( + 'a module whose factory becomes a server fn builder: the client bundle drops the handler', + async () => { + const server = await editFactoryIntoBuilder() expect(containing(await server.clientBundle(), 'MARK_DB_SECRET')).toEqual( [], ) - expect(server.logs.filter((log) => log.startsWith('error:'))).toEqual([]) }, timeout, ) From 561c2d9e8fcbf641ad2a3336160370f8e03f7e53 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 18 Oct 2026 21:52:17 +0200 Subject: [PATCH 0446/1425] test(start-plugin-core): format and type the import-protection test changes Co-Authored-By: Claude Fable 5.1 --- .../importProtection/analysis.usage.test.ts | 5 +- .../importProtection/mock-helpers.test.ts | 6 +- .../importProtection/violation-report.test.ts | 14 +- .../importProtection/violation-report.ts | 6 +- .../importProtection/virtualModules.test.ts | 4 +- .../importProtection/build.test.ts | 21 ++- .../integration/importProtection/dev.test.ts | 26 +-- .../importProtection/known-bugs.test.ts | 76 ++++---- .../integration/known-bugs-dev-server.test.ts | 4 +- .../rsbuild/import-protection.test.ts | 170 ++++++++++-------- 10 files changed, 189 insertions(+), 143 deletions(-) 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 1df794dd6c5..c09ec4abef8 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts @@ -301,10 +301,7 @@ import { denied } from 'denied' ` /** Whether the output of `output` for `code` still imports `denied`. */ - async function survives( - code: string, - output: 'client' | 'ssr' | 'provider', - ) { + async function survives(code: string, output: 'client' | 'ssr' | 'provider') { const compiled = await compileFor(output, code, { id: '/test/src/module.tsx', }) diff --git a/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts b/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts index 00291bfccd9..8f9ba589239 100644 --- a/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts +++ b/packages/start-plugin-core/tests/importProtection/mock-helpers.test.ts @@ -26,7 +26,9 @@ describe('the safe-mock probe', () => { const node = (): unknown => new Proxy(function () {}, { get: (_target, prop) => - prop === Symbol.toPrimitive || prop === 'toString' || prop === 'toJSON' + prop === Symbol.toPrimitive || + prop === 'toString' || + prop === 'toJSON' ? () => label : node(), apply: () => node(), @@ -63,7 +65,7 @@ describe('the safe-mock probe', () => { }) test('rejects a mock that throws on construction', () => { - const value = new Proxy(function () {}, { + const value: any = new Proxy(function () {}, { get: () => value, apply: () => value, construct: () => { diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.test.ts b/packages/start-plugin-core/tests/importProtection/violation-report.test.ts index e1548d6830f..b708db98a6c 100644 --- a/packages/start-plugin-core/tests/importProtection/violation-report.test.ts +++ b/packages/start-plugin-core/tests/importProtection/violation-report.test.ts @@ -28,7 +28,12 @@ const violation: ViolationInfo = { trace: [ { file: '/project/src/entry.ts', specifier: './a', line: 2, column: 1 }, { file: '/project/src/a.ts', specifier: './b', line: 3, column: 1 }, - { file: '/project/src/b.ts', specifier: './secret.server', line: 4, column: 27 }, + { + file: '/project/src/b.ts', + specifier: './secret.server', + line: 4, + column: 27, + }, ], snippet: { lines: [ @@ -78,7 +83,12 @@ describe('the report adapter', () => { test('reads each report of a log, in order, with the entries around them', () => { const server = formatViolation( - { ...violation, env: 'ssr', envType: 'server', importer: '/project/src/s.ts' }, + { + ...violation, + env: 'ssr', + envType: 'server', + importer: '/project/src/s.ts', + }, '/project', ) expect( diff --git a/packages/start-plugin-core/tests/importProtection/violation-report.ts b/packages/start-plugin-core/tests/importProtection/violation-report.ts index c0b9eecf7fd..afb6c638fdc 100644 --- a/packages/start-plugin-core/tests/importProtection/violation-report.ts +++ b/packages/start-plugin-core/tests/importProtection/violation-report.ts @@ -185,7 +185,11 @@ function parseReport(block: Array): Array { ) } const environments = [ - ...new Set(headerOf(block).join(' ').match(/\b(client|server)\b/g) ?? []), + ...new Set( + headerOf(block) + .join(' ') + .match(/\b(client|server)\b/g) ?? [], + ), ] if (environments.length !== 1) { throw new Error( diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts index 43efff4244f..7d883aa2f54 100644 --- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts +++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts @@ -105,7 +105,7 @@ describe('the dev mock of a denied file', () => { async function useDevMock( mode: 'error' | 'warn' | 'off', info: ViolationInfo = violation, - use: (mod: { getSecret: any }) => unknown = (mod) => mod.getSecret(), + use: (mod: Record) => unknown = (mod) => mod.getSecret(), ) { const output = captureConsole() const runtimeId = mockRuntimeModuleIdFromViolation(info, mode, '/project') @@ -146,7 +146,7 @@ describe('the dev mock of a denied file', () => { }, ) - test.each<{ name: string; use: (mod: { getSecret: any }) => unknown }>([ + test.each<{ name: string; use: (mod: Record) => 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() }, diff --git a/packages/start-plugin-core/tests/integration/importProtection/build.test.ts b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts index 4247844320b..06d5bdc9537 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/build.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/build.test.ts @@ -702,7 +702,10 @@ describe.each(startBundlers)('%s build, server-only marker', (bundler) => { */ const blamedImporter = (info: ViolationInfo) => { const fromRoot = (file: string | undefined) => - file?.split(path.sep).join('/').replace(/^.*?\/(?=src\/)/, '') + file + ?.split(path.sep) + .join('/') + .replace(/^.*?\/(?=src\/)/, '') const importer = fromRoot(info.importer) return importer === 'src/marked.ts' ? fromRoot(info.trace.at(-2)?.file) @@ -835,13 +838,15 @@ describe.each(startBundlers)('%s build, server-only marker', (bundler) => { timeout, ) - // Bug: same two bugs, on one app: in a mock-mode build, the exempt - // importer of a server-only-marked module gets a mock (Rsbuild: the marked - // module is mocked for every importer), or the real module with a - // violation reported for it (Vite: markers are not mocked at all, and - // `ignoreImporters` does not apply to them). - // Impact: as above; the non-exempt importer is fine on Rsbuild (a safe - // mock, reported), and runs the real server-only code on Vite. + // Bug: the same bugs, on one app, in a mock-mode build. Rsbuild: the + // marked module is mocked for every importer, the exempt one included, + // and the one report is at the marked module (`src/marked.ts`), not at + // the other importer. Vite: the exempt importer gets the real module and + // the other a mock, but the marker import of the marked module itself is + // reported too (`ignoreImporters` does not apply to it, the pin above), + // so the reports name `src/marked.ts` besides `src/other.ts`. + // Impact: as above; on Rsbuild, code a project exempts gets a mock; on + // both, the report does not single out the importer at fault. test.fails( 'in mock mode, an ignored importer gets the real module, another importer a safe mock, and only the other importer is reported', async () => { diff --git a/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts b/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts index 5d7f8c37aad..59e8f7dfba8 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/dev.test.ts @@ -8,7 +8,7 @@ */ import path from 'node:path' import mdx from '@mdx-js/rollup' -import * as jsxRuntime from 'react/jsx-runtime' +import { createRequire } from 'node:module' import { evaluateServed } from '@tanstack/compiler-test-utils/dev-server' import { afterEach, describe, expect, test, vi } from 'vitest' import { startDevServer } from '../../dev-server' @@ -30,10 +30,15 @@ import { import type { ViolationReport } from '../../importProtection/violation-report' import type { ImportProtectionOptions } from '../../../src/schema' import type { ViolationInfo } from '../../../src/import-protection/trace' -import type { Plugin } from 'vite' +import type { DevEnvironment, Plugin } from 'vite' const timeout = 30_000 +/** The real React JSX runtime (a dependency of this package, no types). */ +const jsxRuntime = createRequire(import.meta.url)( + 'react/jsx-runtime', +) as Record + afterEach(() => { vi.restoreAllMocks() }) @@ -144,9 +149,7 @@ describe('a client import of a denied file', () => { async () => { const { server } = await addDeniedImportByAnEdit() const reports = violationReports(server.logs) - expect(distinct(reports.map((v) => v.importer))).toEqual([ - deniedFileUse, - ]) + expect(distinct(reports.map((v) => v.importer))).toEqual([deniedFileUse]) for (const report of reports) { expect(traceModules(report)).toEqual(['src/entry.ts', 'src/a.ts']) } @@ -655,9 +658,11 @@ export const read = () => [getRequest()] }) captureMockAccess() const described = async () => - ((await evaluateServed(server, '/src/entry.ts')).read() as Array).map( - (value) => describeValue(value, 'MARK_'), - ) + ( + ( + await evaluateServed(server, '/src/entry.ts') + ).read() as Array + ).map((value) => describeValue(value, 'MARK_')) expect(await described()).toEqual(['a safe mock']) await server.edit({ 'src/a.ts': `import { getRequest, getResponse } from '@tanstack/react-start/server' @@ -1286,8 +1291,9 @@ export const createSecretFn = createServerFn().middleware([mw]) const helper = path.join(root, 'src/helper.ts') const deadline = Date.now() + 5_000 while ( - !this.environment.moduleGraph.getModuleById(helper) - ?.transformResult && + !(this.environment as DevEnvironment).moduleGraph.getModuleById( + helper, + )?.transformResult && Date.now() < deadline ) { await new Promise((resolve) => setTimeout(resolve, 5)) diff --git a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts index a97ecff1efc..b4b39c51dc1 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts @@ -280,22 +280,26 @@ describe('Rsbuild dev', () => { // Control for the pins below (same config and import protection): a // module of that type is compiled, and its code runs in the client. - test('a module of another type without a denied import runs in the client', async () => { - const server = await startRsbuildDevServer({ - compileTimeout, - importProtection: { - behavior: 'error', - client: { specifiers: ['db-lib'] }, - }, - config: pageModules, - files: { - 'src/home.page': `export const page = () => 'MARK_PAGE'\n`, - 'src/entry.ts': `import { page } from './home.page'\nconsole.log(page())\n`, - }, - }) - expect(server.errors).toEqual([]) - expect(await runRsbuildClient(server)).toEqual(['MARK_PAGE']) - }, timeout) + test( + 'a module of another type without a denied import runs in the client', + async () => { + const server = await startRsbuildDevServer({ + compileTimeout, + importProtection: { + behavior: 'error', + client: { specifiers: ['db-lib'] }, + }, + config: pageModules, + files: { + 'src/home.page': `export const page = () => 'MARK_PAGE'\n`, + 'src/entry.ts': `import { page } from './home.page'\nconsole.log(page())\n`, + }, + }) + expect(server.errors).toEqual([]) + expect(await runRsbuildClient(server)).toEqual(['MARK_PAGE']) + }, + timeout, + ) // Bug: Rsbuild import protection checks importers by file extension // (`isImportProtectionSourceFile`, rsbuild/import-protection.ts, only @@ -363,25 +367,29 @@ describe('Rsbuild dev', () => { // ("never reported") while the dev harness only waited for `done`; the // harness now fails the compile wait with the error Rspack reports // (mutation run R3c). - test('an onViolation that throws ends the compile with its error', async () => { - const outcome = await startRsbuildDevServer({ - importProtection: { - onViolation: () => { - throw new Error('MARK_THROWN') + test( + 'an onViolation that throws ends the compile with its error', + async () => { + const outcome = await startRsbuildDevServer({ + importProtection: { + onViolation: () => { + throw new Error('MARK_THROWN') + }, }, - }, - // Only a guard: the failed compile ends the wait. - compileTimeout, - 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') - }, timeout) + // Only a guard: the failed compile ends the wait. + compileTimeout, + 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') + }, + timeout, + ) }) describe.each(startBundlers)('%s build', (bundler) => { diff --git a/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts index 324aa0422ec..367baf3067c 100644 --- a/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts +++ b/packages/start-plugin-core/tests/integration/known-bugs-dev-server.test.ts @@ -418,7 +418,9 @@ export const values = [getRequest, getHeader, server.setCookie] async ({ client }) => { const { module } = await evaluateClient({ 'src/c.ts': client }) const { values } = module as { values: Array } - expect(values.map((value: unknown) => isSafeMock(value))).toEqual(values.map(() => true)) + expect(values.map((value: unknown) => isSafeMock(value))).toEqual( + values.map(() => true), + ) }, timeout, ) diff --git a/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts index d0bb3b2f154..1e613f5b6fc 100644 --- a/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/integration/rsbuild/import-protection.test.ts @@ -110,30 +110,34 @@ exports.page = () => secret // environment too: Start's default rules deny `*.client.*` files to it. // Source: audit F-H4 (the other Rsbuild dev tests run import protection on // the client environment only). -test('Rsbuild dev with Start: a server import of a .client file is mocked on the server and reported', async () => { - const server = await startRsbuildStartDevServer({ - compileTimeout, - files: { - 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, - 'src/entry.ts': `import { widget } from './widget.client' +test( + 'Rsbuild dev with Start: a server import of a .client file is mocked on the server and reported', + async () => { + const server = await startRsbuildStartDevServer({ + compileTimeout, + files: { + 'src/widget.client.ts': `export const widget = () => 'MARK_CLIENT'\n`, + 'src/entry.ts': `import { widget } from './widget.client' export const read = () => widget() console.log(read()) `, - }, - }) - expect(server.errors).toEqual([]) - expect( - distinct( - reportedViolations(server.warnings).map( - ({ environment, importer }) => `${environment} ${importer}`, + }, + }) + expect(server.errors).toEqual([]) + expect( + distinct( + reportedViolations(server.warnings).map( + ({ environment, importer }) => `${environment} ${importer}`, + ), ), - ), - ).toEqual(['server src/entry.ts:2:27']) - const code = async (environment: string) => - Object.values(await server.outputs(environment)).join('\n') - expect(await code('ssr')).not.toContain('MARK_CLIENT') - expect(await code('client')).toContain('MARK_CLIENT') -}, timeout) + ).toEqual(['server src/entry.ts:2:27']) + const code = async (environment: string) => + Object.values(await server.outputs(environment)).join('\n') + expect(await code('ssr')).not.toContain('MARK_CLIENT') + expect(await code('client')).toContain('MARK_CLIENT') + }, + timeout, +) // Source: round 4 mutation testing (rsbuild host, `behavior` in dev). describe('Rsbuild dev: behavior', () => { @@ -148,7 +152,7 @@ describe('Rsbuild dev: behavior', () => { '$behavior: a violation is reported in the compile $reportedAs', async ({ behavior, reportedAs }) => { const server = await startRsbuildDevServer({ - compileTimeout, + compileTimeout, importProtection: { behavior }, files: { 'src/secret.server.ts': `export const secret = () => 'MARK_SECRET'\n`, @@ -190,7 +194,7 @@ console.log(secret(), other) "log '$log': a rebuild for an unrelated edit reports $afterRebuild", async ({ log, afterRebuild }) => { const server = await startRsbuildDevServer({ - compileTimeout, + compileTimeout, importProtection: { log }, files: app, }) @@ -215,7 +219,7 @@ console.log(secret(), other) 'a compile in which $module is missing reports only that', async ({ file }) => { const server = await startRsbuildDevServer({ - compileTimeout, + compileTimeout, importProtection: {}, files: app, }) @@ -231,32 +235,36 @@ console.log(secret(), other) // 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({ - compileTimeout, - importProtection: { - client: { specifiers: [/^\.\/lib$/] }, - onViolation: (info) => { - // relative to the app root (src/...) - resolved.push( - (info as ViolationInfo).resolved?.replace(/^.*\/(?=src\/)/, ''), - ) + test( + 'a denied specifier that an edit makes resolve elsewhere is reported with its new target', + async () => { + const resolved: Array = [] + const server = await startRsbuildDevServer({ + compileTimeout, + 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']) - }, timeout) + 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']) + }, + timeout, + ) }) // Rsbuild's `source.transformImport` rewrites a library's named imports @@ -384,41 +392,45 @@ export const read = () => secret() + other }, 60_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({ +test( + 'Rsbuild dev: the client mock of each denied import reports its own use in the browser console', + async () => { + const server = await startRsbuildDevServer({ compileTimeout, - 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' + 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']) -}, timeout) + }, + }) + 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']) + }, + timeout, +) // Source: round 4 mutation testing (rsbuild host, `mockAccess`). describe('Rsbuild dev: mockAccess', () => { @@ -434,7 +446,7 @@ describe('Rsbuild dev: mockAccess', () => { '$mockAccess: the use of a mocked import is reported with console $reports', async ({ mockAccess, reports }) => { const server = await startRsbuildDevServer({ - compileTimeout, + compileTimeout, importProtection: { mockAccess, client: { specifiers: ['db-lib'] }, From 7711087ca31f9991d0865bb9813745fec1cb50a7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 19 Oct 2026 00:03:26 +0200 Subject: [PATCH 0447/1425] test(start-plugin-core): name the Rsbuild custom module type for what it is Co-Authored-By: Claude Fable 5.1 --- .../tests/integration/importProtection/known-bugs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts index b4b39c51dc1..4520208fac4 100644 --- a/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts +++ b/packages/start-plugin-core/tests/integration/importProtection/known-bugs.test.ts @@ -269,7 +269,7 @@ describe('Rsbuild dev', () => { const timeout = 60_000 const compileTimeout = timeout - /** A module type another loader compiles to JS (a stand-in for `.mdx`). */ + /** A module type of the app's own: an Rspack rule compiles `.page` files as JavaScript (like `.mdx` through its loader). */ const pageModules: RsbuildConfig = { tools: { rspack: { From 1ba702ea33db2f5431b861b05726f06a04214d57 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 19 Oct 2026 00:05:30 +0200 Subject: [PATCH 0448/1425] wip(react-start-rsc): integration tier scaffold for real RSC tests (W2-j, in progress) Co-Authored-By: Claude Opus 5.5 --- packages/react-start-rsc/package.json | 24 +++- .../tests/integration/rsc-app.ts | 126 ++++++++++++++++++ .../tests/integration/spike.test.ts | 37 +++++ packages/react-start-rsc/vite.config.ts | 43 +++++- 4 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 packages/react-start-rsc/tests/integration/rsc-app.ts create mode 100644 packages/react-start-rsc/tests/integration/spike.test.ts diff --git a/packages/react-start-rsc/package.json b/packages/react-start-rsc/package.json index 0434052a0e5..985804e6334 100644 --- a/packages/react-start-rsc/package.json +++ b/packages/react-start-rsc/package.json @@ -24,10 +24,11 @@ ], "scripts": { "clean": "rimraf ./dist && rimraf ./coverage", - "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit", - "test:unit": "vitest", - "test:unit:dev": "vitest --watch", - "test:eslint": "eslint ./src", + "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit && pnpm test:integration", + "test:unit": "vitest --project unit", + "test:unit:dev": "vitest --project unit --watch", + "test:integration": "vitest --project integration", + "test:eslint": "eslint ./src ./tests", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", @@ -38,6 +39,21 @@ "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", "build": "vite build" }, + "nx": { + "targets": { + "test:integration": { + "dependsOn": [ + "^build", + { + "projects": [ + "@tanstack/react-start" + ], + "target": "build" + } + ] + } + } + }, "type": "module", "types": "dist/esm/src/index.d.ts", "exports": { diff --git a/packages/react-start-rsc/tests/integration/rsc-app.ts b/packages/react-start-rsc/tests/integration/rsc-app.ts new file mode 100644 index 00000000000..7491fd5e5e7 --- /dev/null +++ b/packages/react-start-rsc/tests/integration/rsc-app.ts @@ -0,0 +1,126 @@ +/** + * Real React Start apps with RSC enabled, for tests of what the RSC CSS + * transform and Start's RSC wiring do end to end: a Vite dev server + * (`startRscDevServer`) runs the app the way `vite dev` does, with the built + * `@tanstack/react-start` (`tanstackStart({ rsc: { enabled: true } })` of its + * `plugin/vite` entry, linked into the app's `node_modules`), Vite's own RSC + * plugin (`@vitejs/plugin-rsc`, the three environments `client`, `ssr` and + * `rsc`) and `@vitejs/plugin-react`. With `browser`, a real page + * (`@tanstack/compiler-test-utils/browser`) loads the app over HTTP through + * Start's own dev server middleware (SSR), Vite's HMR client and React + * Refresh; Vite's own watcher reports the test's file edits (`write()`). + * + * The temporary apps live in this directory: their bare imports (`react`, + * `@tanstack/react-router`, `@vitejs/plugin-rsc`, ...) resolve to this + * package's dependencies, as in an installed app. `@tanstack/react-start` + * is not one of them (it depends on this package), so the built workspace + * package is linked (`workspacePackageLinks`); the `test:integration` Nx + * target depends on its `build`. + */ +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { createDevServer } from '@tanstack/compiler-test-utils/dev-server' +import { + workspacePackage, + workspacePackageLinks, +} from '@tanstack/compiler-test-utils/workspace-packages' +import { onTestFinished } from 'vitest' +import type { + DevServer, + DevServerOptions, +} from '@tanstack/compiler-test-utils/dev-server' + +const harnessDir = path.dirname(fileURLToPath(import.meta.url)) + +/** The built workspace package `@tanstack/react-start`. */ +export const reactStartPackage = workspacePackage('@tanstack/react-start') + +/** + * `tanstackStart` of the built `@tanstack/react-start/plugin/vite`, what an + * app's `vite.config.ts` imports. + */ +async function tanstackStartVitePlugin(): Promise< + (options?: Record) => unknown +> { + const entry = pathToFileURL( + path.join(reactStartPackage, 'dist/esm/plugin/vite.js'), + ).href + const { tanstackStart } = (await import(/* @vite-ignore */ entry)) as { + tanstackStart: (options?: Record) => unknown + } + return tanstackStart +} + +/** + * The files every app has unless it brings its own: Start's router entry + * (the real router over the generated route tree) and a root route that + * renders the document with Start's `HeadContent` and `Scripts`, so pages + * hydrate and load the client. + */ +export const rscAppFiles = { + 'src/router.tsx': `import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +export function getRouter() { + return createRouter({ routeTree }) +} +`, + 'src/routes/__root.tsx': `import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({ component: Root }) +function Root() { + return ( + + + + + +
    + + + + ) +} +`, +} + +export interface RscDevServerOptions extends Omit< + DevServerOptions, + 'dir' | 'plugins' +> { + /** Start's plugin options (`tanstackStart(options)`), `rsc` enabled. */ + start?: Record +} + +/** + * Starts a Vite dev server on a React Start app with RSC enabled, of + * `files` over `rscAppFiles`. Without `browser`, the server runs in + * middleware mode and `settle()`/`edit()` replay file events; the `rsc` + * environment is runnable (`server.import(url, 'rsc')` runs a module in the + * provider). + */ +export async function startRscDevServer( + options: RscDevServerOptions, +): Promise { + const { files, start, config, ...devServerOptions } = options + const tanstackStart = await tanstackStartVitePlugin() + return createDevServer({ + dir: harnessDir, + prefix: '.rsc-dev-server-', + registerCleanup: onTestFinished, + ...devServerOptions, + files: { ...rscAppFiles, ...files }, + symlinks: { + ...workspacePackageLinks(['@tanstack/react-start']), + ...devServerOptions.symlinks, + }, + // Start serves the pages (SSR through its dev middleware), not Vite's + // SPA fallback. + config: { appType: 'custom', ...config }, + plugins: [ + tanstackStart({ ...start, rsc: { enabled: true } }) as never, + rsc(), + react(), + ], + }) +} diff --git a/packages/react-start-rsc/tests/integration/spike.test.ts b/packages/react-start-rsc/tests/integration/spike.test.ts new file mode 100644 index 00000000000..91255bccbb3 --- /dev/null +++ b/packages/react-start-rsc/tests/integration/spike.test.ts @@ -0,0 +1,37 @@ +import { expect, onTestFinished, test } from 'vitest' +import { openPage } from '@tanstack/compiler-test-utils/browser' +import { startRscDevServer } from './rsc-app' + +test('spike: a server component with a CSS module styles the page', async () => { + const server = await startRscDevServer({ + browser: true, + files: { + 'src/card.module.css': `.card { background: #fef3c7; border-radius: 18px; }\n`, + 'src/card.tsx': `import styles from './card.module.css' +export function Card() { + return
    MARK_CARD
    +} +`, + 'src/routes/index.tsx': `import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { renderServerComponent } from '@tanstack/react-start/rsc' +import { Card } from '../card' +const getCard = createServerFn().handler(() => renderServerComponent()) +export const Route = createFileRoute('/')({ + loader: () => getCard(), + component: () =>
    {Route.useLoaderData()}
    , +}) +`, + }, + }) + console.log(server.url, server.logs) + const page = await openPage(`${server.url}/`, { + registerCleanup: onTestFinished, + }) + await page.waitForText('MARK_CARD') + const style = await page.page + .locator('[data-testid=card]') + .evaluate((el) => getComputedStyle(el).backgroundColor) + console.log(page.console, page.errors, server.logs) + expect(style).toBe('rgb(254, 243, 199)') +}) diff --git a/packages/react-start-rsc/vite.config.ts b/packages/react-start-rsc/vite.config.ts index 6db91feb775..26a43516471 100644 --- a/packages/react-start-rsc/vite.config.ts +++ b/packages/react-start-rsc/vite.config.ts @@ -1,9 +1,20 @@ -import { defineConfig, mergeConfig } from 'vitest/config' +import path from 'node:path' +import os from 'node:os' +import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import react from '@vitejs/plugin-react' import { copyFilesPlugin } from '@tanstack/router-utils' import packageJson from './package.json' +// Under Vitest: Node's compile cache for the test workers. No outcome +// change; less time compiling the bundlers and the built apps again. +if (process.env.VITEST) { + process.env.NODE_COMPILE_CACHE ??= path.join( + os.tmpdir(), + 'node-compile-cache', + ) +} + const config = defineConfig({ resolve: { alias: { @@ -19,12 +30,36 @@ const config = defineConfig({ }), ], test: { - include: ['**/*.{test-d,test,spec}.?(c|m)[jt]s?(x)'], name: packageJson.name, watch: false, reporters: ['default', '@tanstack/compiler-test-utils/budget-reporter'], - environment: 'jsdom', - setupFiles: ['./tests/setup.ts'], + projects: [ + { + // Fast tier (`test:unit`): the runtime's unit tests (jsdom) and the + // in-process compiler tests. + extends: true, + test: { + name: 'unit', + include: ['**/*.{test-d,test,spec}.?(c|m)[jt]s?(x)'], + exclude: [...configDefaults.exclude, 'tests/integration/**'], + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + }, + }, + { + // Slow tier (`test:integration`): real Start apps with RSC on Vite + // and Rsbuild dev servers and builds, and real browser pages. Its + // Nx target builds `@tanstack/react-start` first. + extends: true, + test: { + name: 'integration', + include: ['tests/integration/**/*.test.ts?(x)'], + environment: 'node', + testTimeout: 60_000, + hookTimeout: 60_000, + }, + }, + ], }, }) From f3206be121c555ca38cf3a5db8a9514c128a8905 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 19 Oct 2026 00:17:25 +0200 Subject: [PATCH 0449/1425] test(start-plugin-core): hydrate-build runs the real Start, React and router, and hydrates in Chromium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX-PLAN A3b-2 (HYD-02, HYD-04, HYD-05, HYD-15, HYD-24). - Delete `hydrateRuntimeFiles` (stand-in `@tanstack/react-start`, `@tanstack/react-router` and React JSX runtime), its names-only contract test, and `renderThroughChunks` (which preloaded lazy components itself). Apps link the built `@tanstack/react-start` (startRuntimeLinks) and resolve the real router and React. - Hydration oracle: the SSR build renders each page with `renderToReadableStream` (allReady); a Chromium page of the client build hydrates that HTML with `hydrateRoot`, records `onRecoverableError` and `console.error`, and clicks every child until it shows it was clicked. Identity rows use `interaction()` boundaries, whose runtime finds the gate by the server-rendered id (probe: an altered id leaves the child unhydrated). Boundary-id equality and prefix presence are no longer asserted (J5.4, §5). - One shared production build per bundler for all pages (plus the unminified source-map build and the dangling-name pin build). - Split route options: per-route `codeSplitGroupings` (documented, docs/router/guide/automatic-code-splitting.md:166), explicit separate groups, one group, and the default (component split, pendingComponent eager) as a named mixed row (HYD-15); the three routes hydrate in one page. - Exported widget initialized by an impure call: the goal "lazy or reported" becomes the real outcome (initializer runs once, renders, boundary children lazy, no warning required); red on both bundlers (count 2), pinned with the same root cause as the known-bugs-hydrate compiler pin (HYD-24). - A package without side-effect hints: the chart placement is the bundler's call (eager with the real React on both bundlers; it was lazy only with the stand-in React). - Vite dev: a real page (index.html, @vitejs/plugin-react, Vite's HMR client) requests the chunk before its parent module, renders it, and shows the edit. - Rsbuild dev: the real packages instead of the stand-ins. - Delete the Solid dev row: it rendered stand-in Solid packages through a string JSX runtime; real Solid needs solid-js, vite-plugin-solid and a linked @tanstack/solid-start, none of which resolve from start-plugin-core (no approved dependency). Solid compile coverage stays in hydrate-boundary-forms.test.ts (framework: 'solid'). Pins re-checked red without .fails: the head-reads-Page pin (DanglingNameError `_H2`, both bundlers), the unused exported boundary (`lazy`, both), the impure exported initializer (count 2, both). Co-Authored-By: Claude Opus 5.5 --- .../tests/integration/hydrate-build.test.ts | 1471 ++++++++--------- 1 file changed, 693 insertions(+), 778 deletions(-) diff --git a/packages/start-plugin-core/tests/integration/hydrate-build.test.ts b/packages/start-plugin-core/tests/integration/hydrate-build.test.ts index 532b7469576..5ca7c9194ef 100644 --- a/packages/start-plugin-core/tests/integration/hydrate-build.test.ts +++ b/packages/start-plugin-core/tests/integration/hydrate-build.test.ts @@ -1,500 +1,682 @@ /** * `` in real builds (Start's Vite and Rsbuild plugins) and on Vite - * and Rsbuild dev servers: where the children's code ends up is the - * bundler's decision (with the side-effect hints of the app), so it is - * asserted on build output. + * and Rsbuild dev servers, with the real `@tanstack/react-start`, React and + * router. Where the children's code ends up is the bundler's decision (with + * the side-effect hints of the app), so it is asserted on build output. + * That the children hydrate is asserted like a browser sees it: the server + * build renders a page to HTML with `react-dom/server`, a page of headless + * Chromium loads the client build, hydrates that HTML with `hydrateRoot` + * (recoverable errors and `console.error` recorded) and clicks each child, + * whose button then shows that it was clicked: the child hydrated, with its + * own code, in the boundary the server rendered it in. */ import { afterAll, describe, expect, onTestFinished, test } from 'vitest' -import path from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import react from '@vitejs/plugin-react' +import { openPage } from '@tanstack/compiler-test-utils/browser' import { originalFrameOf } from '@tanstack/compiler-test-utils/build-harness' -import { - createDevServer, - evaluateServed, -} from '@tanstack/compiler-test-utils/dev-server' -import { buildStart, reactStartPackage, startBundlers } from '../build-harness' -import { startCompilerPlugin } from '../../src/vite/start-compiler-plugin/plugin' +import { buildStart, startBundlers } from '../build-harness' import { startDevServer } from '../dev-server' import { startRsbuildDevServer } from '../rsbuild-dev-server' -import { getChunkIds } from '../regression-helpers' -import type { BuildResult } from '@tanstack/compiler-test-utils/build-harness' -import type { StartBuild } from '../build-harness' +import { moduleRequests } from '../imports' +import type { StartBuild, StartBundler } from '../build-harness' const timeout = 60_000 -/** - * Stand-ins of the packages a `` module imports: React's JSX - * runtimes render intrinsic elements as tags, `Hydrate` records its boundary - * id (`globalThis.__boundaryIds`) and renders its children between brackets, - * and `lazyRouteComponent(load, name)` renders the export `name` - * (`exportName`) of the module it loaded (after `preload()`); every lazy - * component is listed in `globalThis.__lazyComponents` - * (`renderThroughChunks`). `@tanstack/react-start` is a stand-in here too - * (`Hydrate` and `/hydration`, what these modules import): the real - * `Hydrate` renders with React and the real router, not with these JSX - * runtimes. - */ -const hydrateRuntimeFiles: Record = { - 'node_modules/@tanstack/react-start/package.json': JSON.stringify({ - name: '@tanstack/react-start', - type: 'module', - sideEffects: false, - exports: { - '.': { import: { default: './index.js' } }, - './hydration': { import: { default: './hydration.js' } }, - }, - }), - 'node_modules/@tanstack/react-start/index.js': `export const Hydrate = (props) => { - ;(globalThis.__boundaryIds ??= []).push(props.h) - return '[' + (props.children ?? '') + ']' +/** A clickable child: ` } +` + +/** The entries of an app whose pages `src/pages.ts` exports (`pages`). */ +const pageEntries = { + 'entry-server.ts': `export * as pages from './src/pages' +export { createElement } from 'react' +export { renderToReadableStream } from 'react-dom/server' `, - 'node_modules/@tanstack/react-start/hydration.js': `export const idle = () => 'idle' -export const load = () => 'load' -export const visible = () => 'visible' + 'entry-client.ts': `export * as pages from './src/pages' +export { createElement } from 'react' +export { createRoot, hydrateRoot } from 'react-dom/client' `, - 'node_modules/@tanstack/react-router/package.json': JSON.stringify({ - name: '@tanstack/react-router', - type: 'module', - sideEffects: false, - exports: { '.': './index.js' }, - }), - // The route creation of this stand-in router (routes are their options, - // the generated route tree links children) goes with its JSX runtime - // above: the real router renders with React. The build harness itself - // links the real `@tanstack/react-router`. - 'node_modules/@tanstack/react-router/index.js': `function createRouteObject(options) { - const route = { - options, - children: undefined, - update(extra) { - Object.assign(route.options, extra) - return route - }, - _addFileChildren(children) { - route.children = children - return route - }, - _addFileTypes: () => route, - } - return route } -export const createRootRoute = (options = {}) => createRouteObject(options) -export const createFileRoute = () => (options = {}) => createRouteObject(options) -export const lazyFn = (load, name) => async (...args) => (await load())[name](...args) -export function lazyRouteComponent(load, name = 'default') { - let loaded - const Lazy = (props) => { - if (!loaded) throw new Error('lazy component rendered before it loaded') - Lazy.rendered = true - return loaded(props) - } - Lazy.exportName = name - Lazy.preload = async () => { - loaded = (await load())[name] - Lazy.loaded = true +const entry = { client: 'entry-client.ts', ssr: 'entry-server.ts' } + +/** + * Server-renders every export of `pages` (a component) to HTML, like Start's + * SSR: `renderToReadableStream`, all Suspense boundaries ready. A page whose + * render fails gives its error. + */ +async function renderPages(build: StartBuild) { + const { result } = await build.ssr.run(`const html = {} +for (const [name, Page] of Object.entries(entry.pages)) { + try { + const stream = await entry.renderToReadableStream(entry.createElement(Page)) + await stream.allReady + html[name] = await new Response(stream).text() + } catch (error) { + html[name] = { error: String(error) } } - ;(globalThis.__lazyComponents ??= []).push(Lazy) - return Lazy } -`, - 'node_modules/react/package.json': JSON.stringify({ - name: 'react', - type: 'module', - sideEffects: false, - exports: { - './jsx-runtime': './jsx-runtime.js', - './jsx-dev-runtime': './jsx-runtime.js', - }, - }), - 'node_modules/react/jsx-runtime.js': `export const Fragment = Symbol('Fragment') -const text = (children) => - [children].flat(Infinity).filter((c) => c != null && c !== false && c !== true).join('') -export function jsx(type, props) { - if (type === Fragment) return text(props.children) - if (typeof type === 'function') { - return type(props.children === undefined ? props : { ...props, children: text(props.children) }) - } - return '<' + type + '>' + text(props.children) + '' +return html`) + return result as Record } -export const jsxs = jsx -export const jsxDEV = jsx -`, + +/** What hydrating a page showed (`hydratePage`). */ +interface Hydrated { + /** The value of the `read` expression after the clicks, if any. */ + read?: unknown + /** The page's text after the clicks. */ + text: string + /** Errors given to `onRecoverableError` (e.g. a hydration mismatch). */ + recoverableErrors: Array + /** What the page logged with `console.error`. */ + consoleErrors: Array } /** - * Script of a build's `run()`: renders `render` (an expression with the - * entry's exports in scope as `entry`) like the browser hydrates the page: - * a lazy component that has not loaded throws, so every lazy component - * created so far loads and the page renders again. Returns the HTML, the - * boundary ids `` rendered with and how many lazy components - * rendered and were created. + * Hydrates the server HTML of the page `name` (`html`) in a page of the + * client build, then clicks each button whose text contains a label of + * `click` (again every 20 ms, while it hydrates) until it shows `

    + home + +