diff --git a/.changeset/fresh-routes-refresh.md b/.changeset/fresh-routes-refresh.md new file mode 100644 index 0000000000..3c23631cd4 --- /dev/null +++ b/.changeset/fresh-routes-refresh.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-plugin': patch +--- + +Preserve React component state during HMR when route components are declared with lowercase function names. The development-only React transforms cover split route component groups and unsplit root route component options, including shell, pending, and error components, without changing production output. diff --git a/e2e/react-start/hmr/src/routeTree.gen.ts b/e2e/react-start/hmr/src/routeTree.gen.ts index 8de7587b66..73d8bd14f5 100644 --- a/e2e/react-start/hmr/src/routeTree.gen.ts +++ b/e2e/react-start/hmr/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as ComponentHmrRouteImport } from './routes/component-hmr' import { Route as ComponentHmrInlineErrorSplitRouteImport } from './routes/component-hmr-inline-error-split' import { Route as ComponentHmrInlineNosplitRouteImport } from './routes/component-hmr-inline-nosplit' import { Route as ComponentHmrInlineSplitRouteImport } from './routes/component-hmr-inline-split' +import { Route as ComponentHmrLowercaseNamedSplitRouteImport } from './routes/component-hmr-lowercase-named-split' import { Route as ComponentHmrNamedErrorSplitRouteImport } from './routes/component-hmr-named-error-split' import { Route as ComponentHmrNamedNosplitRouteImport } from './routes/component-hmr-named-nosplit' import { Route as ComponentHmrNamedSplitRouteImport } from './routes/component-hmr-named-split' @@ -55,6 +56,12 @@ const ComponentHmrInlineSplitRoute = ComponentHmrInlineSplitRouteImport.update({ path: '/component-hmr-inline-split', getParentRoute: () => rootRouteImport, } as any) +const ComponentHmrLowercaseNamedSplitRoute = + ComponentHmrLowercaseNamedSplitRouteImport.update({ + id: '/component-hmr-lowercase-named-split', + path: '/component-hmr-lowercase-named-split', + getParentRoute: () => rootRouteImport, + } as any) const ComponentHmrNamedErrorSplitRoute = ComponentHmrNamedErrorSplitRouteImport.update({ id: '/component-hmr-named-error-split', @@ -101,6 +108,7 @@ export interface FileRoutesByFullPath { '/component-hmr-inline-error-split': typeof ComponentHmrInlineErrorSplitRoute '/component-hmr-inline-nosplit': typeof ComponentHmrInlineNosplitRoute '/component-hmr-inline-split': typeof ComponentHmrInlineSplitRoute + '/component-hmr-lowercase-named-split': typeof ComponentHmrLowercaseNamedSplitRoute '/component-hmr-named-error-split': typeof ComponentHmrNamedErrorSplitRoute '/component-hmr-named-nosplit': typeof ComponentHmrNamedNosplitRoute '/component-hmr-named-split': typeof ComponentHmrNamedSplitRoute @@ -116,6 +124,7 @@ export interface FileRoutesByTo { '/component-hmr-inline-error-split': typeof ComponentHmrInlineErrorSplitRoute '/component-hmr-inline-nosplit': typeof ComponentHmrInlineNosplitRoute '/component-hmr-inline-split': typeof ComponentHmrInlineSplitRoute + '/component-hmr-lowercase-named-split': typeof ComponentHmrLowercaseNamedSplitRoute '/component-hmr-named-error-split': typeof ComponentHmrNamedErrorSplitRoute '/component-hmr-named-nosplit': typeof ComponentHmrNamedNosplitRoute '/component-hmr-named-split': typeof ComponentHmrNamedSplitRoute @@ -132,6 +141,7 @@ export interface FileRoutesById { '/component-hmr-inline-error-split': typeof ComponentHmrInlineErrorSplitRoute '/component-hmr-inline-nosplit': typeof ComponentHmrInlineNosplitRoute '/component-hmr-inline-split': typeof ComponentHmrInlineSplitRoute + '/component-hmr-lowercase-named-split': typeof ComponentHmrLowercaseNamedSplitRoute '/component-hmr-named-error-split': typeof ComponentHmrNamedErrorSplitRoute '/component-hmr-named-nosplit': typeof ComponentHmrNamedNosplitRoute '/component-hmr-named-split': typeof ComponentHmrNamedSplitRoute @@ -149,6 +159,7 @@ export interface FileRouteTypes { | '/component-hmr-inline-error-split' | '/component-hmr-inline-nosplit' | '/component-hmr-inline-split' + | '/component-hmr-lowercase-named-split' | '/component-hmr-named-error-split' | '/component-hmr-named-nosplit' | '/component-hmr-named-split' @@ -164,6 +175,7 @@ export interface FileRouteTypes { | '/component-hmr-inline-error-split' | '/component-hmr-inline-nosplit' | '/component-hmr-inline-split' + | '/component-hmr-lowercase-named-split' | '/component-hmr-named-error-split' | '/component-hmr-named-nosplit' | '/component-hmr-named-split' @@ -179,6 +191,7 @@ export interface FileRouteTypes { | '/component-hmr-inline-error-split' | '/component-hmr-inline-nosplit' | '/component-hmr-inline-split' + | '/component-hmr-lowercase-named-split' | '/component-hmr-named-error-split' | '/component-hmr-named-nosplit' | '/component-hmr-named-split' @@ -195,6 +208,7 @@ export interface RootRouteChildren { ComponentHmrInlineErrorSplitRoute: typeof ComponentHmrInlineErrorSplitRoute ComponentHmrInlineNosplitRoute: typeof ComponentHmrInlineNosplitRoute ComponentHmrInlineSplitRoute: typeof ComponentHmrInlineSplitRoute + ComponentHmrLowercaseNamedSplitRoute: typeof ComponentHmrLowercaseNamedSplitRoute ComponentHmrNamedErrorSplitRoute: typeof ComponentHmrNamedErrorSplitRoute ComponentHmrNamedNosplitRoute: typeof ComponentHmrNamedNosplitRoute ComponentHmrNamedSplitRoute: typeof ComponentHmrNamedSplitRoute @@ -247,6 +261,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentHmrInlineSplitRouteImport parentRoute: typeof rootRouteImport } + '/component-hmr-lowercase-named-split': { + id: '/component-hmr-lowercase-named-split' + path: '/component-hmr-lowercase-named-split' + fullPath: '/component-hmr-lowercase-named-split' + preLoaderRoute: typeof ComponentHmrLowercaseNamedSplitRouteImport + parentRoute: typeof rootRouteImport + } '/component-hmr-named-error-split': { id: '/component-hmr-named-error-split' path: '/component-hmr-named-error-split' @@ -319,6 +340,7 @@ const rootRouteChildren: RootRouteChildren = { ComponentHmrInlineErrorSplitRoute: ComponentHmrInlineErrorSplitRoute, ComponentHmrInlineNosplitRoute: ComponentHmrInlineNosplitRoute, ComponentHmrInlineSplitRoute: ComponentHmrInlineSplitRoute, + ComponentHmrLowercaseNamedSplitRoute: ComponentHmrLowercaseNamedSplitRoute, ComponentHmrNamedErrorSplitRoute: ComponentHmrNamedErrorSplitRoute, ComponentHmrNamedNosplitRoute: ComponentHmrNamedNosplitRoute, ComponentHmrNamedSplitRoute: ComponentHmrNamedSplitRoute, diff --git a/e2e/react-start/hmr/src/routes/__root.tsx b/e2e/react-start/hmr/src/routes/__root.tsx index b2ec48fb70..22dddf1b3c 100644 --- a/e2e/react-start/hmr/src/routes/__root.tsx +++ b/e2e/react-start/hmr/src/routes/__root.tsx @@ -164,6 +164,12 @@ function RootContent() { label: 'Named Split', detail: 'Named component with default splitting', }, + { + testId: 'component-hmr-lowercase-named-split-link', + to: '/component-hmr-lowercase-named-split', + label: 'Lowercase Named Split', + detail: 'Lowercase named component with default splitting', + }, { testId: 'component-hmr-named-nosplit-link', to: '/component-hmr-named-nosplit', diff --git a/e2e/react-start/hmr/src/routes/component-hmr-lowercase-named-split.tsx b/e2e/react-start/hmr/src/routes/component-hmr-lowercase-named-split.tsx new file mode 100644 index 0000000000..957e14d7a4 --- /dev/null +++ b/e2e/react-start/hmr/src/routes/component-hmr-lowercase-named-split.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' + +export const Route = createFileRoute('/component-hmr-lowercase-named-split')({ + component, +}) + +function component() { + const [count, setCount] = useState(0) + + return ( +
+

+ component-hmr-lowercase-named-split-baseline +

+

Count: {count}

+ + +
+ ) +} diff --git a/e2e/react-start/hmr/tests/app.spec.ts b/e2e/react-start/hmr/tests/app.spec.ts index 10499e4b88..6c70843c04 100644 --- a/e2e/react-start/hmr/tests/app.spec.ts +++ b/e2e/react-start/hmr/tests/app.spec.ts @@ -11,6 +11,7 @@ import type { Page } from '@playwright/test' const whitelistErrors = [ 'Failed to load resource: net::ERR_NAME_NOT_RESOLVED', 'Failed to load resource: the server responded with a status of 504', + 'root loader error', ] const hmrExpect = expect.configure({ timeout: 20_000 }) @@ -24,6 +25,8 @@ const routeFilePaths = { componentHmrInlineSplit: 'routes/component-hmr-inline-split.tsx', componentHmrInlineNosplit: 'routes/component-hmr-inline-nosplit.tsx', componentHmrNamedSplit: 'routes/component-hmr-named-split.tsx', + componentHmrLowercaseNamedSplit: + 'routes/component-hmr-lowercase-named-split.tsx', componentHmrNamedNosplit: 'routes/component-hmr-named-nosplit.tsx', componentHmrInlineErrorSplit: 'routes/component-hmr-inline-error-split.tsx', componentHmrNamedErrorSplit: 'routes/component-hmr-named-error-split.tsx', @@ -99,6 +102,11 @@ const routeFileRestoreChecks: Partial< testId: 'component-hmr-marker', text: 'component-hmr-named-split-baseline', }, + componentHmrLowercaseNamedSplit: { + url: '/component-hmr-lowercase-named-split', + testId: 'component-hmr-marker', + text: 'component-hmr-lowercase-named-split-baseline', + }, componentHmrNamedNosplit: { url: '/component-hmr-named-nosplit', testId: 'component-hmr-marker', @@ -158,6 +166,16 @@ function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) { ' component: RootComponent,', ) + next = next.replace( + ' shellComponent: shellComponent,\n component: RootContent,', + ' component: RootComponent,', + ) + + next = next.replace( + " loader: () => {\n throw new Error('root loader error')\n },\n errorComponent: errorComponent,\n component: RootComponent,", + " loader: () => ({\n crumb: 'Home',\n }),\n component: RootComponent,", + ) + for (const marker of ['root-shell-baseline', 'root-shell-updated']) { next = next.replace( `function RootShell({ children }: { children: ReactNode }) {\n return {children}\n}\n\nfunction Breadcrumbs() {`, @@ -165,6 +183,26 @@ function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) { ) } + for (const marker of [ + 'root-shell-lowercase-baseline', + 'root-shell-lowercase-updated', + ]) { + next = next.replace( + `function shellComponent({ children }: { children: ReactNode }) {\n return {children}\n}\n\nfunction Breadcrumbs() {`, + 'function Breadcrumbs() {', + ) + } + + for (const marker of [ + 'root-error-lowercase-baseline', + 'root-error-lowercase-updated', + ]) { + next = next.replace( + `function errorComponent() {\n return (\n \n

${marker}

\n
\n )\n}\n\nfunction Breadcrumbs() {`, + 'function Breadcrumbs() {', + ) + } + next = replaceAll(next, "crumb: 'Home Updated'", "crumb: 'Home'") next = replaceAll(next, 'root-component-updated', 'root-component-baseline') next = replaceAll(next, 'root-shell-updated', 'root-shell-baseline') @@ -214,6 +252,10 @@ function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) { 'component-hmr-named-split-updated', 'component-hmr-named-split-baseline', ], + componentHmrLowercaseNamedSplit: [ + 'component-hmr-lowercase-named-split-updated', + 'component-hmr-lowercase-named-split-baseline', + ], componentHmrNamedNosplit: [ 'component-hmr-named-nosplit-updated', 'component-hmr-named-nosplit-baseline', @@ -1032,6 +1074,89 @@ test.describe('react-start hmr', () => { ) }) + test('updates root route shellComponent during HMR when its function name is lowercase', async ({ + page, + }) => { + await rewriteRouteFile( + page, + 'root', + (source) => + source + .replace( + ' component: RootComponent,', + ' shellComponent: shellComponent,\n component: RootContent,', + ) + .replace( + 'function Breadcrumbs() {', + 'function shellComponent({ children }: { children: ReactNode }) {\n return {children}\n}\n\nfunction Breadcrumbs() {', + ), + async () => {}, + ) + await page.waitForTimeout(300) + + await reloadPageAndWaitForText( + page, + '/', + 'root-shell-marker', + 'root-shell-lowercase-baseline', + ) + await page.getByTestId('increment').click() + await page.getByTestId('message').fill('index preserved') + await page.getByTestId('root-message').fill('root preserved') + + await replaceRouteTextAndWait( + page, + 'root', + 'root-shell-lowercase-baseline', + 'root-shell-lowercase-updated', + async () => { + await hmrExpect(page.getByTestId('root-shell-marker')).toHaveText( + 'root-shell-lowercase-updated', + ) + }, + ) + + await expectHomeStatePreserved(page) + }) + + test('updates root route errorComponent during HMR when its function name is lowercase', async ({ + page, + }) => { + await seedHomeState(page) + + await rewriteRouteFile( + page, + 'root', + (source) => + source + .replace( + " loader: () => ({\n crumb: 'Home',\n }),\n component: RootComponent,", + " loader: () => {\n throw new Error('root loader error')\n },\n errorComponent: errorComponent,\n component: RootComponent,", + ) + .replace( + 'function Breadcrumbs() {', + 'function errorComponent() {\n return (\n \n

root-error-lowercase-baseline

\n
\n )\n}\n\nfunction Breadcrumbs() {', + ), + async () => { + await hmrExpect(page.getByTestId('root-error-marker')).toHaveText( + 'root-error-lowercase-baseline', + ) + }, + ) + + await replaceRouteTextAndWait( + page, + 'root', + 'root-error-lowercase-baseline', + 'root-error-lowercase-updated', + async () => { + await hmrExpect(page.getByTestId('root-error-marker')).toHaveText( + 'root-error-lowercase-updated', + ) + }, + ) + }) + test('updates non-root route component during HMR when component is defined inline and codeSplitGroupings is undefined', async ({ page, }) => { @@ -1092,6 +1217,26 @@ test.describe('react-start hmr', () => { await expectComponentHmrStatePreserved(page) }) + test('updates a code-split route component whose function name is lowercase', async ({ + page, + }) => { + await seedComponentHmrState(page, '/component-hmr-lowercase-named-split') + + await replaceRouteTextAndWait( + page, + 'componentHmrLowercaseNamedSplit', + 'component-hmr-lowercase-named-split-baseline', + 'component-hmr-lowercase-named-split-updated', + async () => { + await hmrExpect(page.getByTestId('component-hmr-marker')).toHaveText( + 'component-hmr-lowercase-named-split-updated', + ) + }, + ) + + await expectComponentHmrStatePreserved(page) + }) + test('updates non-root route component during HMR when component is not defined inline and codeSplitGroupings disables code splitting', async ({ page, }) => { diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts index 9411165630..89cdae41ad 100644 --- a/packages/router-plugin/src/core/code-splitter/compilers.ts +++ b/packages/router-plugin/src/core/code-splitter/compilers.ts @@ -25,8 +25,8 @@ import { createRouteHmrStatement } from '../hmr' import { getObjectPropertyKeyName } from '../utils' import { getFrameworkOptions } from './framework-options' import type { + CodeSplitCompilerPlugin, CompileCodeSplitReferenceRouteOptions, - ReferenceRouteCompilerPlugin, } from './plugins' import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' @@ -364,7 +364,7 @@ function removeSharedDeclarations(ast: t.File, sharedBindings: Set) { export function compileCodeSplitReferenceRoute( opts: ParseAstOptions & CompileCodeSplitReferenceRouteOptions & { - compilerPlugins?: Array + compilerPlugins?: Array }, ): GeneratorResult | null { const ast = parseAst(opts) @@ -879,6 +879,7 @@ export function compileCodeSplitVirtualRoute( splitTargets: Array filename: string sharedBindings?: Set + compilerPlugins?: Array }, ): GeneratorResult { const ast = parseAst(opts) @@ -1015,6 +1016,14 @@ export function compileCodeSplitVirtualRoute( // Add the node to the program if (splitNode) { + for (const plugin of opts.compilerPlugins ?? []) { + plugin.onVirtualRouteSplitNode?.({ + programPath, + splitNode, + splitNodeMeta: splitMeta, + }) + } + if (t.isFunctionDeclaration(splitNode)) { // an anonymous function declaration should only happen for `export default function() {...}` // so we should never get here @@ -1023,6 +1032,7 @@ export function compileCodeSplitVirtualRoute( `Function declaration for "${SPLIT_TYPE}" must have an identifier.`, ) } + splitMeta.shouldRemoveNode = false splitMeta.localExporterIdent = splitNode.id.name } else if ( diff --git a/packages/router-plugin/src/core/code-splitter/plugins.ts b/packages/router-plugin/src/core/code-splitter/plugins.ts index ebf1f4aa30..1e8ada32b5 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins.ts @@ -40,7 +40,13 @@ export type ReferenceRouteCompilerPluginResult = { modified?: boolean } -export type ReferenceRouteCompilerPlugin = { +export type VirtualRouteSplitNodeCompilerPluginContext = { + programPath: babel.NodePath + splitNode: t.Node + splitNodeMeta: SplitNodeMeta +} + +export type CodeSplitCompilerPlugin = { name: string getStableRouteOptionKeys?: () => Array onRouteOptions?: ( @@ -55,4 +61,9 @@ export type ReferenceRouteCompilerPlugin = { onSplitRouteProperty?: ( ctx: ReferenceRouteSplitPropertyCompilerPluginContext, ) => void | t.Expression + onVirtualRouteSplitNode?: ( + ctx: VirtualRouteSplitNodeCompilerPluginContext, + ) => void } + +export type ReferenceRouteCompilerPlugin = CodeSplitCompilerPlugin diff --git a/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts b/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts index de8195853a..a0351ad210 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts @@ -1,27 +1,23 @@ import { createReactRefreshIgnoredRouteExportsPlugin } from './react-refresh-ignored-route-exports' import { createReactRefreshRouteComponentsPlugin } from './react-refresh-route-components' import { createReactStableHmrSplitRouteComponentsPlugin } from './react-stable-hmr-split-route-components' -import type { ReferenceRouteCompilerPlugin } from '../plugins' +import type { CodeSplitCompilerPlugin } from '../plugins' import type { Config, HmrStyle } from '../../config' -export function getReferenceRouteCompilerPlugins(opts: { +export function getFrameworkHmrCompilerPlugins(opts: { targetFramework: Config['target'] - addHmr?: boolean hmrStyle?: HmrStyle -}): Array | undefined { +}): Array | undefined { switch (opts.targetFramework) { case 'react': { - if (opts.addHmr) { - const hmrStyle = opts.hmrStyle ?? 'vite' - return [ - ...(hmrStyle === 'vite' - ? [createReactRefreshIgnoredRouteExportsPlugin()] - : []), - createReactRefreshRouteComponentsPlugin(), - createReactStableHmrSplitRouteComponentsPlugin({ hmrStyle }), - ] - } - return undefined + const hmrStyle = opts.hmrStyle ?? 'vite' + return [ + ...(hmrStyle === 'vite' + ? [createReactRefreshIgnoredRouteExportsPlugin()] + : []), + createReactRefreshRouteComponentsPlugin(), + createReactStableHmrSplitRouteComponentsPlugin({ hmrStyle }), + ] } default: return undefined diff --git a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts index f7c4bca338..44041671e0 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts @@ -13,29 +13,74 @@ const REACT_REFRESH_ROUTE_COMPONENT_IDENTS = new Set([ 'notFoundComponent', ]) -function hoistInlineRouteComponents(ctx: { +type RouteComponentContext = { programPath: Parameters[0] insertionPath: { insertBefore: (nodes: Array) => void } routeOptions: t.ObjectExpression -}) { +} + +function isReactComponentName(name: string) { + const firstCharacter = name[0] + + return ( + firstCharacter !== undefined && + firstCharacter >= 'A' && + firstCharacter <= 'Z' + ) +} + +function getRouteComponentKey(prop: t.ObjectProperty) { + const key = getObjectPropertyKeyName(prop) + + return key && REACT_REFRESH_ROUTE_COMPONENT_IDENTS.has(key) ? key : undefined +} + +function prepareRouteComponentsForReactRefresh(ctx: RouteComponentContext) { const hoistedDeclarations: Array = [] + let modified = false - ctx.routeOptions.properties.forEach((prop) => { + for (const prop of ctx.routeOptions.properties) { if (!t.isObjectProperty(prop)) { - return + continue } - const key = getObjectPropertyKeyName(prop) + const key = getRouteComponentKey(prop) - if (!key || !REACT_REFRESH_ROUTE_COMPONENT_IDENTS.has(key)) { - return + if (!key) { + continue + } + + if (t.isIdentifier(prop.value)) { + if (isReactComponentName(prop.value.name)) { + continue + } + + const bindingNode = ctx.programPath.scope.getBinding(prop.value.name) + ?.path.node + const isLocalComponentBinding = + t.isFunctionDeclaration(bindingNode) || + t.isClassDeclaration(bindingNode) || + t.isVariableDeclarator(bindingNode) + + if (!isLocalComponentBinding) { + continue + } + + const componentIdentifier = getUniqueProgramIdentifier( + ctx.programPath, + `TSR${key[0]!.toUpperCase()}${key.slice(1)}`, + ) + + ctx.programPath.scope.rename(prop.value.name, componentIdentifier.name) + modified = true + continue } if ( !t.isArrowFunctionExpression(prop.value) && !t.isFunctionExpression(prop.value) ) { - return + continue } const hoistedIdentifier = getUniqueProgramIdentifier( @@ -50,14 +95,14 @@ function hoistInlineRouteComponents(ctx: { ) prop.value = t.cloneNode(hoistedIdentifier) - }) + modified = true + } - if (hoistedDeclarations.length === 0) { - return false + if (hoistedDeclarations.length > 0) { + ctx.insertionPath.insertBefore(hoistedDeclarations) } - ctx.insertionPath.insertBefore(hoistedDeclarations) - return true + return modified } export function createReactRefreshRouteComponentsPlugin(): ReferenceRouteCompilerPlugin { @@ -66,27 +111,32 @@ export function createReactRefreshRouteComponentsPlugin(): ReferenceRouteCompile getStableRouteOptionKeys() { return [...REACT_REFRESH_ROUTE_COMPONENT_IDENTS] }, - onUnsplittableRoute(ctx) { - if (!ctx.opts.addHmr) { - return - } - - if (hoistInlineRouteComponents(ctx)) { + onAddHmr(ctx) { + if (prepareRouteComponentsForReactRefresh(ctx)) { return { modified: true } } return }, - onAddHmr(ctx) { - if (!ctx.opts.addHmr) { + onVirtualRouteSplitNode(ctx) { + if ( + ctx.splitNodeMeta.splitStrategy !== 'lazyRouteComponent' || + !t.isFunctionDeclaration(ctx.splitNode) || + !ctx.splitNode.id || + isReactComponentName(ctx.splitNode.id.name) + ) { return } - if (hoistInlineRouteComponents(ctx)) { - return { modified: true } - } + const componentIdentifier = getUniqueProgramIdentifier( + ctx.programPath, + ctx.splitNodeMeta.localExporterIdent, + ) - return + ctx.programPath.scope.rename( + ctx.splitNode.id.name, + componentIdentifier.name, + ) }, } } diff --git a/packages/router-plugin/src/core/config.ts b/packages/router-plugin/src/core/config.ts index c0b47b9ef5..17c0dd08da 100644 --- a/packages/router-plugin/src/core/config.ts +++ b/packages/router-plugin/src/core/config.ts @@ -9,7 +9,7 @@ import type { RouteIds, } from '@tanstack/router-core' import type { CodeSplitGroupings } from './constants' -import type { ReferenceRouteCompilerPlugin } from './code-splitter/plugins' +import type { CodeSplitCompilerPlugin } from './code-splitter/plugins' export const splitGroupingsSchema = z .array( @@ -76,7 +76,7 @@ export type CodeSplittingOptions = { * Internal compiler plugins used by framework integrations. * @internal */ - compilerPlugins?: Array + compilerPlugins?: Array } export type HmrStyle = 'vite' | 'webpack' diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts index 4b6af091cc..f1f75d4f3c 100644 --- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts +++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts @@ -13,7 +13,7 @@ import { computeSharedBindings, detectCodeSplitGroupingsFromRoute, } from './code-splitter/compilers' -import { getReferenceRouteCompilerPlugins } from './code-splitter/plugins/framework-plugins' +import { getFrameworkHmrCompilerPlugins } from './code-splitter/plugins/framework-plugins' import { defaultCodeSplitGroupings, splitRouteIdentNodes, @@ -24,7 +24,8 @@ import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils' import { createRouterPluginContext } from './router-plugin-context' import type { CodeSplitGroupings, SplitRouteIdentNodes } from './constants' import type { GetRoutesByFileMapResultValue } from '@tanstack/router-generator' -import type { Config } from './config' +import type { CodeSplitCompilerPlugin } from './code-splitter/plugins' +import type { Config, HmrStyle } from './config' import type { RouterPluginContext } from './router-plugin-context' import type { UnpluginFactory, @@ -83,6 +84,12 @@ export function createRouterCodeSplitterPlugin( ): ReturnType Config)> | undefined>> { let ROOT: string = process.cwd() let userConfig: Config + let addHmr: boolean + let hmrStyle: HmrStyle + let compilerPlugins: Array + let virtualRouteCompilerPlugins: Array + + let isProduction = process.env.NODE_ENV === 'production' function initUserConfig() { if (typeof options === 'function') { @@ -90,8 +97,22 @@ export function createRouterCodeSplitterPlugin( } else { userConfig = getConfig(options, ROOT) } + + addHmr = (userConfig.codeSplittingOptions?.addHmr ?? true) && !isProduction + hmrStyle = userConfig.plugin?.hmr?.style ?? 'vite' + compilerPlugins = [ + ...(addHmr + ? (getFrameworkHmrCompilerPlugins({ + targetFramework: userConfig.target, + hmrStyle, + }) ?? []) + : []), + ...(userConfig.codeSplittingOptions?.compilerPlugins ?? []), + ] + virtualRouteCompilerPlugins = compilerPlugins.filter( + (plugin) => plugin.onVirtualRouteSplitNode, + ) } - const isProduction = process.env.NODE_ENV === 'production' // Map from normalized route file path → set of shared binding names. // Populated by the reference compiler, consumed by virtual and shared compilers. const sharedBindingsMap = new Map>() @@ -159,10 +180,6 @@ export function createRouterCodeSplitterPlugin( sharedBindingsMap.delete(id) } - const addHmr = - (userConfig.codeSplittingOptions?.addHmr ?? true) && !isProduction - const hmrStyle = userConfig.plugin?.hmr?.style ?? 'vite' - const compiledReferenceRoute = compileCodeSplitReferenceRoute({ code, codeSplitGroupings: splitGroupings, @@ -176,14 +193,7 @@ export function createRouterCodeSplitterPlugin( hmrStyle, hmrRouteId: generatorNodeInfo.routeId, sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, - compilerPlugins: [ - ...(getReferenceRouteCompilerPlugins({ - targetFramework: userConfig.target, - addHmr, - hmrStyle, - }) ?? []), - ...(userConfig.codeSplittingOptions?.compilerPlugins ?? []), - ], + compilerPlugins, }) if (compiledReferenceRoute === null) { @@ -232,6 +242,7 @@ export function createRouterCodeSplitterPlugin( filename: id, splitTargets: grouping, sharedBindings: resolvedSharedBindings, + compilerPlugins: virtualRouteCompilerPlugins, }) if (debug) { @@ -276,6 +287,7 @@ export function createRouterCodeSplitterPlugin( vite: { configResolved(config) { + isProduction = config.command === 'build' ROOT = config.root initUserConfig() @@ -319,12 +331,14 @@ export function createRouterCodeSplitterPlugin( }, }, - rspack() { + rspack(compiler) { + isProduction = compiler.options.mode === 'production' ROOT = process.cwd() initUserConfig() }, - webpack() { + webpack(compiler) { + isProduction = compiler.options.mode === 'production' ROOT = process.cwd() initUserConfig() }, diff --git a/packages/router-plugin/src/core/router-hmr-plugin.ts b/packages/router-plugin/src/core/router-hmr-plugin.ts index 75dfde11f8..e1dc01a43b 100644 --- a/packages/router-plugin/src/core/router-hmr-plugin.ts +++ b/packages/router-plugin/src/core/router-hmr-plugin.ts @@ -1,6 +1,6 @@ import { generateFromAst, logDiff, parseAst } from '@tanstack/router-utils' import { compileCodeSplitReferenceRoute } from './code-splitter/compilers' -import { getReferenceRouteCompilerPlugins } from './code-splitter/plugins/framework-plugins' +import { getFrameworkHmrCompilerPlugins } from './code-splitter/plugins/framework-plugins' import { createRouteHmrStatement } from './hmr' import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils' import { getConfig } from './config' @@ -50,9 +50,8 @@ export function createRouterHmrPlugin( const hmrStyle = userConfig.plugin?.hmr?.style ?? 'vite' if (userConfig.target === 'react') { - const compilerPlugins = getReferenceRouteCompilerPlugins({ + const compilerPlugins = getFrameworkHmrCompilerPlugins({ targetFramework: 'react', - addHmr: true, hmrStyle, }) const compiled = compileCodeSplitReferenceRoute({ diff --git a/packages/router-plugin/src/index.ts b/packages/router-plugin/src/index.ts index e390c046a2..6a6ad158bb 100644 --- a/packages/router-plugin/src/index.ts +++ b/packages/router-plugin/src/index.ts @@ -13,8 +13,10 @@ export type { export type { RouterPluginContext } from './core/router-plugin-context' export { getObjectPropertyKeyName } from './core/utils' export type { + CodeSplitCompilerPlugin, ReferenceRouteCompilerPlugin, ReferenceRouteCompilerPluginContext, + VirtualRouteSplitNodeCompilerPluginContext, } from './core/code-splitter/plugins' export { tsrSplit, diff --git a/packages/router-plugin/tests/add-hmr.test.ts b/packages/router-plugin/tests/add-hmr.test.ts index 4430cef74c..52c2645409 100644 --- a/packages/router-plugin/tests/add-hmr.test.ts +++ b/packages/router-plugin/tests/add-hmr.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { compileCodeSplitReferenceRoute } from '../src/core/code-splitter/compilers' import { defaultCodeSplitGroupings } from '../src/core/constants' -import { getReferenceRouteCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' +import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' import { createRouteHmrStatement } from '../src/core/hmr' import { frameworks } from './constants' @@ -32,9 +32,8 @@ describe('add-hmr works', () => { addHmr: true, codeSplitGroupings: defaultCodeSplitGroupings, targetFramework: framework, - compilerPlugins: getReferenceRouteCompilerPlugins({ + compilerPlugins: getFrameworkHmrCompilerPlugins({ targetFramework: framework, - addHmr: true, }), }) @@ -57,10 +56,6 @@ describe('add-hmr works', () => { addHmr: false, codeSplitGroupings: defaultCodeSplitGroupings, targetFramework: framework, - compilerPlugins: getReferenceRouteCompilerPlugins({ - targetFramework: framework, - addHmr: false, - }), }) await expect(compileResult?.code || code).toMatchFileSnapshot( @@ -86,9 +81,8 @@ describe('add-hmr works', () => { hmrStyle: 'webpack', codeSplitGroupings: defaultCodeSplitGroupings, targetFramework: framework, - compilerPlugins: getReferenceRouteCompilerPlugins({ + compilerPlugins: getFrameworkHmrCompilerPlugins({ targetFramework: framework, - addHmr: true, hmrStyle: 'webpack', }), }) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@false.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@false.tsx new file mode 100644 index 0000000000..a2983e31bd --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@false.tsx @@ -0,0 +1,12 @@ +const $$splitComponentImporter = () => import('createFileRoute-lowercase-components.tsx?tsr-split=component'); +const $$splitErrorComponentImporter = () => import('createFileRoute-lowercase-components.tsx?tsr-split=errorComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +export const Route = createFileRoute('/lowercase-components')({ + pendingComponent: pendingComponent, + errorComponent: lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent'), + component: lazyRouteComponent($$splitComponentImporter, 'component') +}); +function pendingComponent() { + return
Pending
; +} \ No newline at end of file diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx new file mode 100644 index 0000000000..b04afe83f4 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx @@ -0,0 +1,204 @@ +const $$splitComponentImporter = () => import('createFileRoute-lowercase-components.tsx?tsr-split=component'); +const $$splitErrorComponentImporter = () => import('createFileRoute-lowercase-components.tsx?tsr-split=errorComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +const TSRSplitErrorComponent = (() => { + const hot = import.meta.hot; + const hotData = hot ? hot.data ??= {} : undefined; + return hotData?.["tsr-split-component:errorComponent"] ?? lazyRouteComponent($$splitErrorComponentImporter, "errorComponent"); +})(); +if (import.meta.hot) { + (import.meta.hot.data ??= {})["tsr-split-component:errorComponent"] = TSRSplitErrorComponent; +} +const TSRSplitComponent = (() => { + const hot = import.meta.hot; + const hotData = hot ? hot.data ??= {} : undefined; + return hotData?.["tsr-split-component:component"] ?? lazyRouteComponent($$splitComponentImporter, "component"); +})(); +if (import.meta.hot) { + (import.meta.hot.data ??= {})["tsr-split-component:component"] = TSRSplitComponent; +} +export const Route = createFileRoute('/lowercase-components')({ + pendingComponent: TSRPendingComponent, + errorComponent: TSRSplitErrorComponent, + component: TSRSplitComponent +}); +function TSRPendingComponent() { + return
Pending
; +} +const hot = import.meta.hot; +if (hot && typeof window !== 'undefined') { + hot.data ??= {}; + const tsrReactRefresh = window.__TSR_REACT_REFRESH__ ??= (() => { + const ignoredExportsById = new Map(); + const previousGetIgnoredExports = window.__getReactRefreshIgnoredExports; + window.__getReactRefreshIgnoredExports = ctx => { + const ignoredExports = previousGetIgnoredExports?.(ctx) ?? []; + const moduleIgnored = ignoredExportsById.get(ctx.id) ?? []; + return [...ignoredExports, ...moduleIgnored]; + }; + return { + ignoredExportsById + }; + })(); + tsrReactRefresh.ignoredExportsById.set("createFileRoute-lowercase-components.tsx", ['Route']); +} +export function TSRFastRefreshAnchor() { + return null; +} +if (import.meta.hot) { + const hot = import.meta.hot; + const hotData = hot.data ??= {}; + const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { + const router = window.__TSR_ROUTER__; + const oldRoute = router.routesById[routeId]; + if (!oldRoute) { + return; + } + ; + const generatedRouteOptionKeys = new Set(["id", "path", "getParentRoute"]); + const generatedRouteOptions = {}; + generatedRouteOptionKeys.forEach(key => { + if (key in oldRoute.options) { + generatedRouteOptions[key] = oldRoute.options[key]; + } + }); + const removedKeys = new Set(); + Object.keys(oldRoute.options).forEach(key => { + if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { + removedKeys.add(key); + delete oldRoute.options[key]; + } + }); + const oldHasShellComponent = "shellComponent" in oldRoute.options; + const newHasShellComponent = "shellComponent" in newRoute.options; + const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; + const componentKeys = ["component", "shellComponent", "pendingComponent", "errorComponent", "notFoundComponent"]; + if (preserveComponentIdentity) { + componentKeys.forEach(key => { + if (key in oldRoute.options && key in newRoute.options) { + newRoute.options[key] = oldRoute.options[key]; + } + }); + } + ; + const nextOptions = { + ...newRoute.options, + ...generatedRouteOptions + }; + oldRoute.options = nextOptions; + oldRoute.update(nextOptions); + oldRoute._componentsPromise = undefined; + oldRoute._lazyPromise = undefined; + router.setRoutes(router.buildRouteTree()); + syncHotRouteExport(oldRoute); + router.resolvePathCache.clear(); + const filter = m => m.routeId === oldRoute.id; + const activeMatch = router.stores.matches.get().find(filter); + const pendingMatch = router.stores.pendingMatches.get().find(filter); + const cachedMatches = router.stores.cachedMatches.get().filter(filter); + if (activeMatch || pendingMatch || cachedMatches.length > 0) { + if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { + const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + router.batch(() => { + for (const matchId of matchIds) { + const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + if (store) { + store.set(prev => { + const next = { + ...prev + }; + if (removedKeys.has("loader")) { + next.loaderData = undefined; + } + ; + if (removedKeys.has("beforeLoad")) { + next.__beforeLoadContext = undefined; + next.context = rebuildMatchContextWithoutBeforeLoad(next); + } + ; + return next; + }); + } + } + }); + } + ; + router.invalidate({ + filter, + sync: true + }); + } + ; + function syncHotRouteExport(liveRoute) { + newRoute.options = liveRoute.options; + newRoute.parentRoute = liveRoute.parentRoute; + newRoute._path = liveRoute._path; + newRoute._id = liveRoute._id; + newRoute._fullPath = liveRoute._fullPath; + newRoute._to = liveRoute._to; + } + function getStoreMatch(matchId) { + return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); + } + function getMatchList(matchId) { + const pendingMatches = router.stores.pendingMatches.get(); + if (pendingMatches.some(match => match.id === matchId)) { + return pendingMatches; + } + ; + const activeMatches = router.stores.matches.get(); + if (activeMatches.some(match => match.id === matchId)) { + return activeMatches; + } + ; + const cachedMatches = router.stores.cachedMatches.get(); + if (cachedMatches.some(match => match.id === matchId)) { + return cachedMatches; + } + ; + return []; + } + function getParentMatch(match) { + const matchList = getMatchList(match.id); + const matchIndex = matchList.findIndex(item => item.id === match.id); + if (matchIndex <= 0) { + return undefined; + } + ; + const parentMatch = matchList[matchIndex - 1]; + return getStoreMatch(parentMatch.id) || parentMatch; + } + function rebuildMatchContextWithoutBeforeLoad(match) { + const parentMatch = getParentMatch(match); + const getParentContext = router.getParentContext; + const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + return { + ...(parentContext ?? {}), + ...(match.__routeContext ?? {}) + }; + } + }; + const initialRouteId = Route.id ?? hotData['tsr-route-id']; + if (initialRouteId) { + hotData['tsr-route-id'] = initialRouteId; + } + const existingRoute = typeof window !== 'undefined' && initialRouteId ? window.__TSR_ROUTER__?.routesById?.[initialRouteId] : undefined; + if (initialRouteId && existingRoute && existingRoute !== Route) { + handleRouteUpdate(initialRouteId, Route); + hotData['tsr-route-update-handled'] = Route; + } + hot.accept(newModule => { + if (Route && newModule && newModule.Route) { + const routeId = hotData['tsr-route-id'] ?? Route.id; + if (routeId) { + hotData['tsr-route-id'] = routeId; + } + if (hotData['tsr-route-update-handled'] === newModule.Route) { + delete hotData['tsr-route-update-handled']; + return; + } + handleRouteUpdate(routeId, newModule.Route); + } + }); +} \ No newline at end of file diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@false.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@false.tsx new file mode 100644 index 0000000000..20c9a07798 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@false.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + shellComponent: shellComponent, + pendingComponent: pendingComponent, + errorComponent: errorComponent, + component: component, +}) + +function shellComponent({ children }: { children: React.ReactNode }) { + return {children} +} + +function pendingComponent() { + return
Pending
+} + +function errorComponent() { + return
Error
+} + +function component() { + return
Hello
+} diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx new file mode 100644 index 0000000000..d3eae5cb36 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx @@ -0,0 +1,200 @@ +import * as React from 'react'; +import { createRootRoute } from '@tanstack/react-router'; +export const Route = createRootRoute({ + shellComponent: TSRShellComponent, + pendingComponent: TSRPendingComponent, + errorComponent: TSRErrorComponent, + component: TSRComponent +}); +function TSRShellComponent({ + children +}: { + children: React.ReactNode; +}) { + return {children}; +} +function TSRPendingComponent() { + return
Pending
; +} +function TSRErrorComponent() { + return
Error
; +} +function TSRComponent() { + return
Hello
; +} +const hot = import.meta.hot; +if (hot && typeof window !== 'undefined') { + hot.data ??= {}; + const tsrReactRefresh = window.__TSR_REACT_REFRESH__ ??= (() => { + const ignoredExportsById = new Map(); + const previousGetIgnoredExports = window.__getReactRefreshIgnoredExports; + window.__getReactRefreshIgnoredExports = ctx => { + const ignoredExports = previousGetIgnoredExports?.(ctx) ?? []; + const moduleIgnored = ignoredExportsById.get(ctx.id) ?? []; + return [...ignoredExports, ...moduleIgnored]; + }; + return { + ignoredExportsById + }; + })(); + tsrReactRefresh.ignoredExportsById.set("createRootRoute-lowercase-components.tsx", ['Route']); +} +export function TSRFastRefreshAnchor() { + return null; +} +if (import.meta.hot) { + const hot = import.meta.hot; + const hotData = hot.data ??= {}; + const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { + const router = window.__TSR_ROUTER__; + const oldRoute = router.routesById[routeId]; + if (!oldRoute) { + return; + } + ; + const generatedRouteOptionKeys = new Set(["id", "path", "getParentRoute"]); + const generatedRouteOptions = {}; + generatedRouteOptionKeys.forEach(key => { + if (key in oldRoute.options) { + generatedRouteOptions[key] = oldRoute.options[key]; + } + }); + const removedKeys = new Set(); + Object.keys(oldRoute.options).forEach(key => { + if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { + removedKeys.add(key); + delete oldRoute.options[key]; + } + }); + const oldHasShellComponent = "shellComponent" in oldRoute.options; + const newHasShellComponent = "shellComponent" in newRoute.options; + const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; + const componentKeys = ["component", "shellComponent", "pendingComponent", "errorComponent", "notFoundComponent"]; + if (preserveComponentIdentity) { + componentKeys.forEach(key => { + if (key in oldRoute.options && key in newRoute.options) { + newRoute.options[key] = oldRoute.options[key]; + } + }); + } + ; + const nextOptions = { + ...newRoute.options, + ...generatedRouteOptions + }; + oldRoute.options = nextOptions; + oldRoute.update(nextOptions); + oldRoute._componentsPromise = undefined; + oldRoute._lazyPromise = undefined; + router.setRoutes(router.buildRouteTree()); + syncHotRouteExport(oldRoute); + router.resolvePathCache.clear(); + const filter = m => m.routeId === oldRoute.id; + const activeMatch = router.stores.matches.get().find(filter); + const pendingMatch = router.stores.pendingMatches.get().find(filter); + const cachedMatches = router.stores.cachedMatches.get().filter(filter); + if (activeMatch || pendingMatch || cachedMatches.length > 0) { + if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { + const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + router.batch(() => { + for (const matchId of matchIds) { + const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + if (store) { + store.set(prev => { + const next = { + ...prev + }; + if (removedKeys.has("loader")) { + next.loaderData = undefined; + } + ; + if (removedKeys.has("beforeLoad")) { + next.__beforeLoadContext = undefined; + next.context = rebuildMatchContextWithoutBeforeLoad(next); + } + ; + return next; + }); + } + } + }); + } + ; + router.invalidate({ + filter, + sync: true + }); + } + ; + function syncHotRouteExport(liveRoute) { + newRoute.options = liveRoute.options; + newRoute.parentRoute = liveRoute.parentRoute; + newRoute._path = liveRoute._path; + newRoute._id = liveRoute._id; + newRoute._fullPath = liveRoute._fullPath; + newRoute._to = liveRoute._to; + } + function getStoreMatch(matchId) { + return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); + } + function getMatchList(matchId) { + const pendingMatches = router.stores.pendingMatches.get(); + if (pendingMatches.some(match => match.id === matchId)) { + return pendingMatches; + } + ; + const activeMatches = router.stores.matches.get(); + if (activeMatches.some(match => match.id === matchId)) { + return activeMatches; + } + ; + const cachedMatches = router.stores.cachedMatches.get(); + if (cachedMatches.some(match => match.id === matchId)) { + return cachedMatches; + } + ; + return []; + } + function getParentMatch(match) { + const matchList = getMatchList(match.id); + const matchIndex = matchList.findIndex(item => item.id === match.id); + if (matchIndex <= 0) { + return undefined; + } + ; + const parentMatch = matchList[matchIndex - 1]; + return getStoreMatch(parentMatch.id) || parentMatch; + } + function rebuildMatchContextWithoutBeforeLoad(match) { + const parentMatch = getParentMatch(match); + const getParentContext = router.getParentContext; + const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + return { + ...(parentContext ?? {}), + ...(match.__routeContext ?? {}) + }; + } + }; + const initialRouteId = Route.id ?? hotData['tsr-route-id']; + if (initialRouteId) { + hotData['tsr-route-id'] = initialRouteId; + } + const existingRoute = typeof window !== 'undefined' && initialRouteId ? window.__TSR_ROUTER__?.routesById?.[initialRouteId] : undefined; + if (initialRouteId && existingRoute && existingRoute !== Route) { + handleRouteUpdate(initialRouteId, Route); + hotData['tsr-route-update-handled'] = Route; + } + hot.accept(newModule => { + if (Route && newModule && newModule.Route) { + const routeId = hotData['tsr-route-id'] ?? Route.id; + if (routeId) { + hotData['tsr-route-id'] = routeId; + } + if (hotData['tsr-route-update-handled'] === newModule.Route) { + delete hotData['tsr-route-update-handled']; + return; + } + handleRouteUpdate(routeId, newModule.Route); + } + }); +} \ No newline at end of file diff --git a/packages/router-plugin/tests/add-hmr/test-files/react/createFileRoute-lowercase-components.tsx b/packages/router-plugin/tests/add-hmr/test-files/react/createFileRoute-lowercase-components.tsx new file mode 100644 index 0000000000..a09f8d24e2 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/test-files/react/createFileRoute-lowercase-components.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/lowercase-components')({ + pendingComponent: pendingComponent, + errorComponent: errorComponent, + component: component, +}) + +function pendingComponent() { + return
Pending
+} + +function errorComponent() { + return
Error
+} + +function component() { + return
Hello
+} diff --git a/packages/router-plugin/tests/add-hmr/test-files/react/createRootRoute-lowercase-components.tsx b/packages/router-plugin/tests/add-hmr/test-files/react/createRootRoute-lowercase-components.tsx new file mode 100644 index 0000000000..20c9a07798 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/test-files/react/createRootRoute-lowercase-components.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + shellComponent: shellComponent, + pendingComponent: pendingComponent, + errorComponent: errorComponent, + component: component, +}) + +function shellComponent({ children }: { children: React.ReactNode }) { + return {children} +} + +function pendingComponent() { + return
Pending
+} + +function errorComponent() { + return
Error
+} + +function component() { + return
Hello
+} diff --git a/packages/router-plugin/tests/code-splitter.test.ts b/packages/router-plugin/tests/code-splitter.test.ts index ae64e2778b..967f102e4d 100644 --- a/packages/router-plugin/tests/code-splitter.test.ts +++ b/packages/router-plugin/tests/code-splitter.test.ts @@ -20,6 +20,7 @@ import { } from '../src/core/code-splitter/compilers' import { createIdentifier } from '@tanstack/router-utils' import { defaultCodeSplitGroupings } from '../src/core/constants' +import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' import { frameworks } from './constants' import type { CodeSplitGroupings } from '../src/core/constants' @@ -165,6 +166,62 @@ describe('code-splitter works', () => { }) }) +describe('React Refresh virtual route compiler plugin', () => { + const splitComponentCases = [ + { splitType: 'component', splitIdentifier: 'SplitComponent' }, + { + splitType: 'pendingComponent', + splitIdentifier: 'SplitPendingComponent', + }, + { splitType: 'errorComponent', splitIdentifier: 'SplitErrorComponent' }, + ] as const + + function createRouteCode(splitType: string) { + return ` +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ ${splitType} }) + +function ${splitType}() { + return
lowercase function declaration
+} +` + } + + it.each(splitComponentCases)( + 'renames a lowercase $splitType binding when HMR is enabled', + ({ splitType, splitIdentifier }) => { + const result = compileCodeSplitVirtualRoute({ + code: createRouteCode(splitType), + filename: `lowercase-function-declaration.tsx?tsr-split=${splitType}`, + splitTargets: [splitType], + compilerPlugins: getFrameworkHmrCompilerPlugins({ + targetFramework: 'react', + }), + }) + + expect(result.code).toContain(`function ${splitIdentifier}()`) + expect(result.code).toContain( + `export { ${splitIdentifier} as ${splitType} }`, + ) + }, + ) + + it.each(splitComponentCases)( + 'keeps a lowercase $splitType binding when HMR is disabled', + ({ splitType }) => { + const result = compileCodeSplitVirtualRoute({ + code: createRouteCode(splitType), + filename: `lowercase-function-declaration.tsx?tsr-split=${splitType}`, + splitTargets: [splitType], + }) + + expect(result.code).toContain(`function ${splitType}()`) + expect(result.code).toContain(`export { ${splitType} }`) + }, + ) +}) + describe('computeSharedBindings fast paths', () => { it('returns empty when only one split group is present (default groupings)', () => { const code = ` diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration.tsx new file mode 100644 index 0000000000..b2f908ab45 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration.tsx @@ -0,0 +1,12 @@ +const $$splitErrorComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=errorComponent'); +const $$splitComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=component'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), + pendingComponent, + errorComponent: lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent') +}); +function pendingComponent() { + return
lowercase pending function declaration
; +} \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@component.tsx new file mode 100644 index 0000000000..65735a0f20 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@component.tsx @@ -0,0 +1,4 @@ +function component() { + return
lowercase function declaration
; +} +export { component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@errorComponent.tsx new file mode 100644 index 0000000000..acc1c0be8d --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@errorComponent.tsx @@ -0,0 +1,4 @@ +function errorComponent() { + return
lowercase error function declaration
; +} +export { errorComponent }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@notFoundComponent.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/lowercase-function-declaration@shared.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration.tsx new file mode 100644 index 0000000000..75657cb44a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration.tsx @@ -0,0 +1,10 @@ +const $$splitErrorComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent'); +const $$splitPendingComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent'); +const $$splitComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), + pendingComponent: lazyRouteComponent($$splitPendingComponentImporter, 'pendingComponent'), + errorComponent: lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent') +}); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 0000000000..86876d616e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,12 @@ +function component() { + return
lowercase function declaration
; +} +function pendingComponent() { + return
lowercase pending function declaration
; +} +function errorComponent() { + return
lowercase error function declaration
; +} +export { component }; +export { pendingComponent }; +export { errorComponent }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@loader.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/lowercase-function-declaration@shared.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration.tsx new file mode 100644 index 0000000000..574c6d4c2b --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration.tsx @@ -0,0 +1,10 @@ +const $$splitErrorComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=errorComponent'); +const $$splitPendingComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +const $$splitComponentImporter = () => import('lowercase-function-declaration.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent'); +import { lazyRouteComponent } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), + pendingComponent: lazyRouteComponent($$splitPendingComponentImporter, 'pendingComponent'), + errorComponent: lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent') +}); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 0000000000..382621ea9a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,8 @@ +function component() { + return
lowercase function declaration
; +} +function pendingComponent() { + return
lowercase pending function declaration
; +} +export { component }; +export { pendingComponent }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@errorComponent.tsx new file mode 100644 index 0000000000..acc1c0be8d --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@errorComponent.tsx @@ -0,0 +1,4 @@ +function errorComponent() { + return
lowercase error function declaration
; +} +export { errorComponent }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/lowercase-function-declaration@shared.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/lowercase-function-declaration.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/lowercase-function-declaration.tsx new file mode 100644 index 0000000000..bfc37b1d6a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/lowercase-function-declaration.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component, + pendingComponent, + errorComponent, +}) + +function component() { + return
lowercase function declaration
+} + +function pendingComponent() { + return
lowercase pending function declaration
+} + +function errorComponent() { + return
lowercase error function declaration
+} diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index 40664c50a8..ecb6e7e167 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -1,8 +1,9 @@ import path from 'node:path' import * as t from '@babel/types' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { parseAst } from '@tanstack/router-utils' import { createRouterCodeSplitterPlugin } from '../src/core/router-code-splitter-plugin' +import { unpluginRouterComposedFactory } from '../src/core/router-composed-plugin' import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin' import { createRouterPluginContext } from '../src/core/router-plugin-context' import { normalizePath } from '../src/core/utils' @@ -11,19 +12,30 @@ import type { UnpluginOptions, TransformResult } from 'unplugin' const referencePluginName = 'tanstack-router:code-splitter:compile-reference-file' +const virtualPluginName = 'tanstack-router:code-splitter:compile-virtual-file' -function getReferencePlugin( +function getCodeSplitterPlugin( plugins: ReturnType, + pluginName: string, ): UnpluginOptions { const pluginArray = Array.isArray(plugins) ? plugins : [plugins] - const plugin = pluginArray.find((item) => item.name === referencePluginName) + const plugin = pluginArray.find((item) => item.name === pluginName) if (!plugin) { - throw new Error('Reference code-splitter plugin not found') + throw new Error(`Code-splitter plugin "${pluginName}" not found`) } return plugin } -async function configurePlugin(plugin: UnpluginOptions) { +function getReferencePlugin( + plugins: ReturnType, +) { + return getCodeSplitterPlugin(plugins, referencePluginName) +} + +async function configurePlugin( + plugin: UnpluginOptions, + command: 'serve' | 'build' = 'serve', +) { const hook = plugin.vite?.configResolved if (!hook) { return @@ -31,6 +43,7 @@ async function configurePlugin(plugin: UnpluginOptions) { const config = { root: process.cwd(), + command, plugins: [{ name: referencePluginName }], } as never @@ -77,6 +90,87 @@ function countProgramHotDeclarations(code: string) { } describe('router plugin context', () => { + it.each([ + { mode: 'production', production: true }, + { mode: 'development with addHmr disabled', production: false }, + ])( + 'does not run React HMR compiler plugins in $mode', + async ({ production }) => { + const routeFile = normalizePath( + path.join(process.cwd(), 'src/routes/lowercase.tsx'), + ) + const routeCode = ` +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/lowercase')({ component }) + +function component() { + return
Hello
+} +` + + const context = createRouterPluginContext() + context.routesByFile.set(routeFile, { routeId: '/lowercase' }) + + if (production) { + vi.stubEnv('NODE_ENV', 'production') + } + + try { + const plugins = createRouterCodeSplitterPlugin( + { + target: 'react', + autoCodeSplitting: true, + codeSplittingOptions: production ? undefined : { addHmr: false }, + }, + context, + ) + const referencePlugin = getReferencePlugin(plugins) + const virtualPlugin = getCodeSplitterPlugin(plugins, virtualPluginName) + + await configurePlugin(referencePlugin, production ? 'build' : 'serve') + + const referenceCode = getCode( + await transformReferenceRoute(referencePlugin, routeCode, routeFile), + ) + const virtualCode = getCode( + await transformReferenceRoute( + virtualPlugin, + routeCode, + `${routeFile}?tsr-split=component`, + ), + ) + + expect(referenceCode).not.toContain('TSRFastRefreshAnchor') + expect(virtualCode).toContain('function component()') + expect(virtualCode).toContain('export { component }') + expect(virtualCode).not.toContain('SplitComponent') + } finally { + if (production) { + vi.unstubAllEnvs() + } + } + }, + ) + + it('does not install the standalone route HMR plugin in production', () => { + vi.stubEnv('NODE_ENV', 'production') + + try { + const plugins = unpluginRouterComposedFactory( + { target: 'react', autoCodeSplitting: false }, + { framework: 'vite' }, + ) + + const pluginArray = Array.isArray(plugins) ? plugins : [plugins] + expect( + pluginArray.some((plugin) => plugin.name === 'tanstack-router:hmr'), + ).toBe(false) + } finally { + vi.unstubAllEnvs() + } + }) + it('keeps multiple code-splitter instances isolated by explicit context', async () => { const routeFile = normalizePath( path.join(process.cwd(), 'src/routes-a/owned.tsx'),