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
1 change: 1 addition & 0 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
id: match.id,
status: match.status,
error: match.error,
invalid: match.invalid,
_forcePending: match._forcePending,
_displayPending: match._displayPending,
},
Expand Down
200 changes: 200 additions & 0 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,206 @@ describe('invalidate', () => {
).toBeInTheDocument()
expect(screen.queryByTestId('loader-route')).not.toBeInTheDocument()
})

/**
* Regression test for HMR with inline arrow function components:
* - When a route uses an inline arrow function for `component` (common in file-based routing),
* React Refresh cannot register the component for HMR updates.
* - The router's HMR handler calls `router.invalidate()` to trigger updates.
* - The Match component must include `invalid` in its useRouterState selector so that
* React detects the state change and re-renders the component.
* - Without this, HMR updates are sent but the UI doesn't update because React
* doesn't see any state change to trigger a re-render.
*
* This test simulates HMR by:
* 1. Rendering a route with component v1
* 2. Swapping to component v2 (simulating what HMR does to route.options.component)
* 3. Calling router.invalidate()
* 4. Verifying that the NEW component v2 is now rendered
*/
it('picks up new component after invalidate simulating HMR (HMR regression)', async () => {
const history = createMemoryHistory({
initialEntries: ['/hmr-test'],
})

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const hmrRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/hmr-test',
// Using inline arrow function - this is what React Refresh cannot track
component: () => {
return <div data-testid="hmr-component">Version 1</div>
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([hmrRoute]),
history,
})

render(<RouterProvider router={router} />)

await act(() => router.load())

// Verify initial component renders
expect(await screen.findByTestId('hmr-component')).toHaveTextContent(
'Version 1',
)

// Simulate HMR: swap the component (this is what happens when Vite hot-reloads a module)
hmrRoute.options.component = () => {
return <div data-testid="hmr-component">Version 2</div>
}

// Simulate HMR invalidation - this is what the router's HMR handler does
await act(() => router.invalidate())

// The NEW component should now be rendered
// Without the fix (invalid not in selector), this would still show "Version 1"
expect(await screen.findByTestId('hmr-component')).toHaveTextContent(
'Version 2',
)
})

/**
* Test to verify render count after invalidate (no loader).
* The fix should cause minimal re-renders - ideally just enough to pick up the new component.
*/
it('renders minimal times after invalidate without loader (render count verification)', async () => {
const history = createMemoryHistory({
initialEntries: ['/render-count-test'],
})

// Use a mock to track renders across component swaps
const renderTracker = vi.fn()

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/render-count-test',
component: () => {
renderTracker('v1')
return <div data-testid="test-component">Version 1</div>
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([testRoute]),
history,
})

render(<RouterProvider router={router} />)
await act(() => router.load())

expect(await screen.findByTestId('test-component')).toHaveTextContent(
'Version 1',
)
const initialCallCount = renderTracker.mock.calls.length

// Simulate HMR: swap component (keep using same tracker)
testRoute.options.component = () => {
renderTracker('v2')
return <div data-testid="test-component">Version 2</div>
}

await act(() => router.invalidate())

expect(await screen.findByTestId('test-component')).toHaveTextContent(
'Version 2',
)

// Count renders after invalidate
const totalCalls = renderTracker.mock.calls.length
const rendersAfterInvalidate = totalCalls - initialCallCount

// We expect exactly 1 render to pick up new component
expect(rendersAfterInvalidate).toBe(1)
})

/**
* Test to verify render count after invalidate WITH async loader.
* Component consumes loader data and loader returns different data on each call.
*/
it('renders minimal times after invalidate with async loader (render count verification)', async () => {
const history = createMemoryHistory({
initialEntries: ['/render-count-loader-test'],
})

const renderTracker = vi.fn()
let loaderCallCount = 0
const loader = vi.fn(async () => {
await new Promise((r) => setTimeout(r, 10))
loaderCallCount++
return { data: `loaded-${loaderCallCount}` }
})

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/render-count-loader-test',
loader,
component: () => {
const loaderData = testRoute.useLoaderData()
renderTracker('v1', loaderData)
return (
<div data-testid="test-component">Version 1 - {loaderData.data}</div>
)
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([testRoute]),
history,
})

render(<RouterProvider router={router} />)
await act(() => router.load())

expect(await screen.findByTestId('test-component')).toHaveTextContent(
'Version 1 - loaded-1',
)
const initialCallCount = renderTracker.mock.calls.length
const initialLoaderCalls = loader.mock.calls.length

// Simulate HMR: swap component to new version that also consumes loader data
testRoute.options.component = () => {
const loaderData = testRoute.useLoaderData()
renderTracker('v2', loaderData)
return (
<div data-testid="test-component">Version 2 - {loaderData.data}</div>
)
}

await act(() => router.invalidate())

// Wait for new component with new loader data
await waitFor(() => {
expect(screen.getByTestId('test-component')).toHaveTextContent(
'Version 2 - loaded-2',
)
})

const rendersAfterInvalidate =
renderTracker.mock.calls.length - initialCallCount
const loaderCallsAfterInvalidate =
loader.mock.calls.length - initialLoaderCalls

// Loader should be called once
expect(loaderCallsAfterInvalidate).toBe(1)
// Component renders twice when consuming loader data that changes:
// 1. Once for invalidation (new component picks up)
// 2. Once when new loader data arrives
expect(rendersAfterInvalidate).toBe(2)
})
})

describe('search params in URL', () => {
Expand Down
Loading