Skip to content

Commit 6069eba

Browse files
fix: hoist inline component definitions for proper React HMR (#6919)
* fix: hoist inline component definitions for proper React HMR this reverts #6197 and implements a proper fix fixes #6339 * add changeset
1 parent 80c3196 commit 6069eba

15 files changed

Lines changed: 316 additions & 205 deletions

.changeset/soft-pianos-lie.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/router-plugin': patch
3+
'@tanstack/react-router': patch
4+
---
5+
6+
fix: hoist inline component definitions for proper React HMR#6919

packages/react-router/src/Match.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({
204204
id: match.id,
205205
status: match.status,
206206
error: match.error,
207-
invalid: match.invalid,
208207
_forcePending: match._forcePending,
209208
_displayPending: match._displayPending,
210209
},

packages/react-router/tests/router.test.tsx

Lines changed: 0 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,206 +1525,6 @@ describe('invalidate', () => {
15251525
).toBeInTheDocument()
15261526
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
15271527
})
1528-
1529-
/**
1530-
* Regression test for HMR with inline arrow function components:
1531-
* - When a route uses an inline arrow function for `component` (common in file-based routing),
1532-
* React Refresh cannot register the component for HMR updates.
1533-
* - The router's HMR handler calls `router.invalidate()` to trigger updates.
1534-
* - The Match component must include `invalid` in its useRouterState selector so that
1535-
* React detects the state change and re-renders the component.
1536-
* - Without this, HMR updates are sent but the UI doesn't update because React
1537-
* doesn't see any state change to trigger a re-render.
1538-
*
1539-
* This test simulates HMR by:
1540-
* 1. Rendering a route with component v1
1541-
* 2. Swapping to component v2 (simulating what HMR does to route.options.component)
1542-
* 3. Calling router.invalidate()
1543-
* 4. Verifying that the NEW component v2 is now rendered
1544-
*/
1545-
it('picks up new component after invalidate simulating HMR (HMR regression)', async () => {
1546-
const history = createMemoryHistory({
1547-
initialEntries: ['/hmr-test'],
1548-
})
1549-
1550-
const rootRoute = createRootRoute({
1551-
component: () => <Outlet />,
1552-
})
1553-
1554-
const hmrRoute = createRoute({
1555-
getParentRoute: () => rootRoute,
1556-
path: '/hmr-test',
1557-
// Using inline arrow function - this is what React Refresh cannot track
1558-
component: () => {
1559-
return <div data-testid="hmr-component">Version 1</div>
1560-
},
1561-
})
1562-
1563-
const router = createRouter({
1564-
routeTree: rootRoute.addChildren([hmrRoute]),
1565-
history,
1566-
})
1567-
1568-
render(<RouterProvider router={router} />)
1569-
1570-
await act(() => router.load())
1571-
1572-
// Verify initial component renders
1573-
expect(await screen.findByTestId('hmr-component')).toHaveTextContent(
1574-
'Version 1',
1575-
)
1576-
1577-
// Simulate HMR: swap the component (this is what happens when Vite hot-reloads a module)
1578-
hmrRoute.options.component = () => {
1579-
return <div data-testid="hmr-component">Version 2</div>
1580-
}
1581-
1582-
// Simulate HMR invalidation - this is what the router's HMR handler does
1583-
await act(() => router.invalidate())
1584-
1585-
// The NEW component should now be rendered
1586-
// Without the fix (invalid not in selector), this would still show "Version 1"
1587-
expect(await screen.findByTestId('hmr-component')).toHaveTextContent(
1588-
'Version 2',
1589-
)
1590-
})
1591-
1592-
/**
1593-
* Test to verify render count after invalidate (no loader).
1594-
* The fix should cause minimal re-renders - ideally just enough to pick up the new component.
1595-
*/
1596-
it('renders minimal times after invalidate without loader (render count verification)', async () => {
1597-
const history = createMemoryHistory({
1598-
initialEntries: ['/render-count-test'],
1599-
})
1600-
1601-
// Use a mock to track renders across component swaps
1602-
const renderTracker = vi.fn()
1603-
1604-
const rootRoute = createRootRoute({
1605-
component: () => <Outlet />,
1606-
})
1607-
1608-
const testRoute = createRoute({
1609-
getParentRoute: () => rootRoute,
1610-
path: '/render-count-test',
1611-
component: () => {
1612-
renderTracker('v1')
1613-
return <div data-testid="test-component">Version 1</div>
1614-
},
1615-
})
1616-
1617-
const router = createRouter({
1618-
routeTree: rootRoute.addChildren([testRoute]),
1619-
history,
1620-
})
1621-
1622-
render(<RouterProvider router={router} />)
1623-
await act(() => router.load())
1624-
1625-
expect(await screen.findByTestId('test-component')).toHaveTextContent(
1626-
'Version 1',
1627-
)
1628-
const initialCallCount = renderTracker.mock.calls.length
1629-
1630-
// Simulate HMR: swap component (keep using same tracker)
1631-
testRoute.options.component = () => {
1632-
renderTracker('v2')
1633-
return <div data-testid="test-component">Version 2</div>
1634-
}
1635-
1636-
await act(() => router.invalidate())
1637-
1638-
expect(await screen.findByTestId('test-component')).toHaveTextContent(
1639-
'Version 2',
1640-
)
1641-
1642-
// Count renders after invalidate
1643-
const totalCalls = renderTracker.mock.calls.length
1644-
const rendersAfterInvalidate = totalCalls - initialCallCount
1645-
1646-
// We expect exactly 1 render to pick up new component
1647-
expect(rendersAfterInvalidate).toBe(1)
1648-
})
1649-
1650-
/**
1651-
* Test to verify render count after invalidate WITH async loader.
1652-
* Component consumes loader data and loader returns different data on each call.
1653-
*/
1654-
it('renders minimal times after invalidate with async loader (render count verification)', async () => {
1655-
const history = createMemoryHistory({
1656-
initialEntries: ['/render-count-loader-test'],
1657-
})
1658-
1659-
const renderTracker = vi.fn()
1660-
let loaderCallCount = 0
1661-
const loader = vi.fn(async () => {
1662-
await new Promise((r) => setTimeout(r, 10))
1663-
loaderCallCount++
1664-
return { data: `loaded-${loaderCallCount}` }
1665-
})
1666-
1667-
const rootRoute = createRootRoute({
1668-
component: () => <Outlet />,
1669-
})
1670-
1671-
const testRoute = createRoute({
1672-
getParentRoute: () => rootRoute,
1673-
path: '/render-count-loader-test',
1674-
loader,
1675-
component: () => {
1676-
const loaderData = testRoute.useLoaderData()
1677-
renderTracker('v1', loaderData)
1678-
return (
1679-
<div data-testid="test-component">Version 1 - {loaderData.data}</div>
1680-
)
1681-
},
1682-
})
1683-
1684-
const router = createRouter({
1685-
routeTree: rootRoute.addChildren([testRoute]),
1686-
history,
1687-
})
1688-
1689-
render(<RouterProvider router={router} />)
1690-
await act(() => router.load())
1691-
1692-
expect(await screen.findByTestId('test-component')).toHaveTextContent(
1693-
'Version 1 - loaded-1',
1694-
)
1695-
const initialCallCount = renderTracker.mock.calls.length
1696-
const initialLoaderCalls = loader.mock.calls.length
1697-
1698-
// Simulate HMR: swap component to new version that also consumes loader data
1699-
testRoute.options.component = () => {
1700-
const loaderData = testRoute.useLoaderData()
1701-
renderTracker('v2', loaderData)
1702-
return (
1703-
<div data-testid="test-component">Version 2 - {loaderData.data}</div>
1704-
)
1705-
}
1706-
1707-
await act(() => router.invalidate())
1708-
1709-
// Wait for new component with new loader data
1710-
await waitFor(() => {
1711-
expect(screen.getByTestId('test-component')).toHaveTextContent(
1712-
'Version 2 - loaded-2',
1713-
)
1714-
})
1715-
1716-
const rendersAfterInvalidate =
1717-
renderTracker.mock.calls.length - initialCallCount
1718-
const loaderCallsAfterInvalidate =
1719-
loader.mock.calls.length - initialLoaderCalls
1720-
1721-
// Loader should be called once
1722-
expect(loaderCallsAfterInvalidate).toBe(1)
1723-
// Component renders twice when consuming loader data that changes:
1724-
// 1. Once for invalidation (new component picks up)
1725-
// 2. Once when new loader data arrives
1726-
expect(rendersAfterInvalidate).toBe(2)
1727-
})
17281528
})
17291529

17301530
describe('search params in URL', () => {

packages/router-plugin/src/core/code-splitter/compilers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { tsrShared, tsrSplit } from '../constants'
1111
import { routeHmrStatement } from '../route-hmr-statement'
1212
import { createIdentifier } from './path-ids'
1313
import { getFrameworkOptions } from './framework-options'
14+
import type {
15+
CompileCodeSplitReferenceRouteOptions,
16+
ReferenceRouteCompilerPlugin,
17+
} from './plugins'
1418
import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils'
1519
import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants'
1620
import type { Config, DeletableNodes } from '../config'
@@ -642,6 +646,7 @@ export function compileCodeSplitReferenceRoute(
642646
id: string
643647
addHmr?: boolean
644648
sharedBindings?: Set<string>
649+
compilerPlugins?: Array<ReferenceRouteCompilerPlugin>
645650
},
646651
): GeneratorResult | null {
647652
const ast = parseAst(opts)
@@ -714,6 +719,23 @@ export function compileCodeSplitReferenceRoute(
714719
)
715720
}
716721
if (!splittableCreateRouteFns.includes(createRouteFn)) {
722+
const insertionPath = path.getStatementParent() ?? path
723+
724+
opts.compilerPlugins?.forEach((plugin) => {
725+
const pluginResult = plugin.onUnsplittableRoute?.({
726+
programPath,
727+
callExpressionPath: path,
728+
insertionPath,
729+
routeOptions,
730+
createRouteFn,
731+
opts: opts as CompileCodeSplitReferenceRouteOptions,
732+
})
733+
734+
if (pluginResult?.modified) {
735+
modified = true
736+
}
737+
})
738+
717739
// we can't split this route but we still add HMR handling if enabled
718740
if (opts.addHmr && !hmrAdded) {
719741
programPath.pushContainer('body', routeHmrStatement)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type babel from '@babel/core'
2+
import type * as t from '@babel/types'
3+
import type { Config, DeletableNodes } from '../config'
4+
import type { CodeSplitGroupings } from '../constants'
5+
6+
export type CompileCodeSplitReferenceRouteOptions = {
7+
codeSplitGroupings: CodeSplitGroupings
8+
deleteNodes?: Set<DeletableNodes>
9+
targetFramework: Config['target']
10+
filename: string
11+
id: string
12+
addHmr?: boolean
13+
sharedBindings?: Set<string>
14+
}
15+
16+
export type ReferenceRouteCompilerPluginContext = {
17+
programPath: babel.NodePath<t.Program>
18+
callExpressionPath: babel.NodePath<t.CallExpression>
19+
insertionPath: babel.NodePath
20+
routeOptions: t.ObjectExpression
21+
createRouteFn: string
22+
opts: CompileCodeSplitReferenceRouteOptions
23+
}
24+
25+
export type ReferenceRouteCompilerPluginResult = {
26+
modified?: boolean
27+
}
28+
29+
export type ReferenceRouteCompilerPlugin = {
30+
name: string
31+
onUnsplittableRoute?: (
32+
ctx: ReferenceRouteCompilerPluginContext,
33+
) => void | ReferenceRouteCompilerPluginResult
34+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createReactRefreshRouteComponentsPlugin } from './react-refresh-route-components'
2+
import type { ReferenceRouteCompilerPlugin } from '../plugins'
3+
import type { Config } from '../../config'
4+
5+
export function getReferenceRouteCompilerPlugins(opts: {
6+
targetFramework: Config['target']
7+
addHmr?: boolean
8+
}): Array<ReferenceRouteCompilerPlugin> | undefined {
9+
switch (opts.targetFramework) {
10+
case 'react': {
11+
if (opts.addHmr) {
12+
return [createReactRefreshRouteComponentsPlugin()]
13+
}
14+
return undefined
15+
}
16+
default:
17+
return undefined
18+
}
19+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as t from '@babel/types'
2+
import { getUniqueProgramIdentifier } from '../../utils'
3+
import type { ReferenceRouteCompilerPlugin } from '../plugins'
4+
5+
const REACT_REFRESH_ROUTE_COMPONENT_IDENTS = new Set([
6+
'component',
7+
'pendingComponent',
8+
'errorComponent',
9+
'notFoundComponent',
10+
])
11+
12+
export function createReactRefreshRouteComponentsPlugin(): ReferenceRouteCompilerPlugin {
13+
return {
14+
name: 'react-refresh-route-components',
15+
onUnsplittableRoute(ctx) {
16+
if (!ctx.opts.addHmr) {
17+
return
18+
}
19+
20+
const hoistedDeclarations: Array<t.VariableDeclaration> = []
21+
22+
ctx.routeOptions.properties.forEach((prop) => {
23+
if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) {
24+
return
25+
}
26+
27+
if (!REACT_REFRESH_ROUTE_COMPONENT_IDENTS.has(prop.key.name)) {
28+
return
29+
}
30+
31+
if (
32+
!t.isArrowFunctionExpression(prop.value) &&
33+
!t.isFunctionExpression(prop.value)
34+
) {
35+
return
36+
}
37+
38+
const hoistedIdentifier = getUniqueProgramIdentifier(
39+
ctx.programPath,
40+
`TSR${prop.key.name[0]!.toUpperCase()}${prop.key.name.slice(1)}`,
41+
)
42+
43+
hoistedDeclarations.push(
44+
t.variableDeclaration('const', [
45+
t.variableDeclarator(
46+
hoistedIdentifier,
47+
t.cloneNode(prop.value, true),
48+
),
49+
]),
50+
)
51+
52+
prop.value = t.cloneNode(hoistedIdentifier)
53+
})
54+
55+
if (hoistedDeclarations.length === 0) {
56+
return
57+
}
58+
59+
ctx.insertionPath.insertBefore(hoistedDeclarations)
60+
return { modified: true }
61+
},
62+
}
63+
}

0 commit comments

Comments
 (0)