Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-routes-refresh.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions e2e/react-start/hmr/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -319,6 +340,7 @@ const rootRouteChildren: RootRouteChildren = {
ComponentHmrInlineErrorSplitRoute: ComponentHmrInlineErrorSplitRoute,
ComponentHmrInlineNosplitRoute: ComponentHmrInlineNosplitRoute,
ComponentHmrInlineSplitRoute: ComponentHmrInlineSplitRoute,
ComponentHmrLowercaseNamedSplitRoute: ComponentHmrLowercaseNamedSplitRoute,
ComponentHmrNamedErrorSplitRoute: ComponentHmrNamedErrorSplitRoute,
ComponentHmrNamedNosplitRoute: ComponentHmrNamedNosplitRoute,
ComponentHmrNamedSplitRoute: ComponentHmrNamedSplitRoute,
Expand Down
6 changes: 6 additions & 0 deletions e2e/react-start/hmr/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<main className="hmr-card flex flex-col gap-5">
<p className="hmr-marker" data-testid="component-hmr-marker">
component-hmr-lowercase-named-split-baseline
</p>
<p data-testid="component-hmr-count">Count: {count}</p>
<button
data-testid="component-hmr-increment"
onClick={() => setCount((value) => value + 1)}
>
Increment
</button>
<input data-testid="component-hmr-message" defaultValue="" />
</main>
)
}
145 changes: 145 additions & 0 deletions e2e/react-start/hmr/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -158,13 +166,43 @@ 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 <RootShellDocument marker="${marker}">{children}</RootShellDocument>\n}\n\nfunction Breadcrumbs() {`,
'function Breadcrumbs() {',
)
}

for (const marker of [
'root-shell-lowercase-baseline',
'root-shell-lowercase-updated',
]) {
next = next.replace(
`function shellComponent({ children }: { children: ReactNode }) {\n return <RootShellDocument marker="${marker}">{children}</RootShellDocument>\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 <RootDocument marker="root-component-baseline">\n <p data-testid="root-error-marker">${marker}</p>\n </RootDocument>\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')
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 <RootShellDocument marker="root-shell-lowercase-baseline">{children}</RootShellDocument>\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 <RootDocument marker="root-component-baseline">\n <p data-testid="root-error-marker">root-error-lowercase-baseline</p>\n </RootDocument>\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,
}) => {
Expand Down Expand Up @@ -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,
}) => {
Expand Down
14 changes: 12 additions & 2 deletions packages/router-plugin/src/core/code-splitter/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -364,7 +364,7 @@ function removeSharedDeclarations(ast: t.File, sharedBindings: Set<string>) {
export function compileCodeSplitReferenceRoute(
opts: ParseAstOptions &
CompileCodeSplitReferenceRouteOptions & {
compilerPlugins?: Array<ReferenceRouteCompilerPlugin>
compilerPlugins?: Array<CodeSplitCompilerPlugin>
},
): GeneratorResult | null {
const ast = parseAst(opts)
Expand Down Expand Up @@ -879,6 +879,7 @@ export function compileCodeSplitVirtualRoute(
splitTargets: Array<SplitRouteIdentNodes>
filename: string
sharedBindings?: Set<string>
compilerPlugins?: Array<CodeSplitCompilerPlugin>
},
): GeneratorResult {
const ast = parseAst(opts)
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down
Loading
Loading