From fee6467186fe942430d06d6256bf7855e2b7698e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 22 Dec 2025 23:03:43 -0800 Subject: [PATCH 1/3] fix(react-router): HMR not updating inline arrow function components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a route uses an inline arrow function for its 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, but the MatchInner component's useRouterState selector did not include the 'invalid' field, so React didn't detect the state change and didn't re-render. This fix adds 'invalid: match.invalid' to the selector, ensuring React detects the state change when invalidate() is called and re-renders the component tree, picking up the new component reference. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/react-router/src/Match.tsx | 1 + packages/react-router/tests/router.test.tsx | 177 ++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 6c16c330b5..a0c67c2f0a 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -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, }, diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 1c80d79b89..715eb3411b 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1486,6 +1486,183 @@ 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: () => , + }) + + const hmrRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/hmr-test', + // Using inline arrow function - this is what React Refresh cannot track + component: () => { + return
Version 1
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([hmrRoute]), + history, + }) + + render() + + 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
Version 2
+ } + + // 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: () => , + }) + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/render-count-test', + component: () => { + renderTracker('v1') + return
Version 1
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + history, + }) + + render() + 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
Version 2
+ } + + 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. + */ + 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() + const loader = vi.fn(async () => { + await new Promise((r) => setTimeout(r, 10)) + return { data: 'loaded' } + }) + + const rootRoute = createRootRoute({ + component: () => , + }) + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/render-count-loader-test', + loader, + component: () => { + renderTracker('v1') + return
Version 1
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + history, + }) + + render() + await act(() => router.load()) + + expect(await screen.findByTestId('test-component')).toHaveTextContent('Version 1') + const initialCallCount = renderTracker.mock.calls.length + const initialLoaderCalls = loader.mock.calls.length + + // Simulate HMR: swap component + testRoute.options.component = () => { + renderTracker('v2') + return
Version 2
+ } + + await act(() => router.invalidate()) + + expect(await screen.findByTestId('test-component')).toHaveTextContent('Version 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 should render at most 2 times (once for invalidation, possibly once for load complete) + expect(rendersAfterInvalidate).toBeGreaterThanOrEqual(1) + expect(rendersAfterInvalidate).toBeLessThanOrEqual(2) + }) }) describe('search params in URL', () => { From a0b0996cbfc50e2b8024098928386b206bcb970c Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 23 Dec 2025 00:29:11 -0800 Subject: [PATCH 2/3] test: improve async loader test to consume loader data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per reviewer feedback, the async loader test now: - Consumes loader data via useLoaderData() - Loader returns different data each call (loaded-1 → loaded-2) - Verifies new component receives new loader data - Expects 2 renders (1 for invalidation, 1 for new data) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/react-router/tests/router.test.tsx | 46 +++++++++++++++------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 715eb3411b..95a87e7882 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1606,6 +1606,7 @@ describe('invalidate', () => { /** * 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({ @@ -1613,9 +1614,11 @@ describe('invalidate', () => { }) const renderTracker = vi.fn() + let loaderCallCount = 0 const loader = vi.fn(async () => { await new Promise((r) => setTimeout(r, 10)) - return { data: 'loaded' } + loaderCallCount++ + return { data: `loaded-${loaderCallCount}` } }) const rootRoute = createRootRoute({ @@ -1627,8 +1630,13 @@ describe('invalidate', () => { path: '/render-count-loader-test', loader, component: () => { - renderTracker('v1') - return
Version 1
+ const loaderData = testRoute.useLoaderData() + renderTracker('v1', loaderData) + return ( +
+ Version 1 - {loaderData.data} +
+ ) }, }) @@ -1640,28 +1648,42 @@ describe('invalidate', () => { render() await act(() => router.load()) - expect(await screen.findByTestId('test-component')).toHaveTextContent('Version 1') + 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 + // Simulate HMR: swap component to new version that also consumes loader data testRoute.options.component = () => { - renderTracker('v2') - return
Version 2
+ const loaderData = testRoute.useLoaderData() + renderTracker('v2', loaderData) + return ( +
+ Version 2 - {loaderData.data} +
+ ) } await act(() => router.invalidate()) - expect(await screen.findByTestId('test-component')).toHaveTextContent('Version 2') + // 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 rendersAfterInvalidate = + renderTracker.mock.calls.length - initialCallCount const loaderCallsAfterInvalidate = loader.mock.calls.length - initialLoaderCalls // Loader should be called once expect(loaderCallsAfterInvalidate).toBe(1) - // Component should render at most 2 times (once for invalidation, possibly once for load complete) - expect(rendersAfterInvalidate).toBeGreaterThanOrEqual(1) - expect(rendersAfterInvalidate).toBeLessThanOrEqual(2) + // 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) }) }) From 4b3f06e4c9aa3cf2bf4b51e08497bf56ba679d70 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:35:50 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/react-router/tests/router.test.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 95a87e7882..1228f83711 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1583,7 +1583,9 @@ describe('invalidate', () => { render() await act(() => router.load()) - expect(await screen.findByTestId('test-component')).toHaveTextContent('Version 1') + expect(await screen.findByTestId('test-component')).toHaveTextContent( + 'Version 1', + ) const initialCallCount = renderTracker.mock.calls.length // Simulate HMR: swap component (keep using same tracker) @@ -1594,7 +1596,9 @@ describe('invalidate', () => { await act(() => router.invalidate()) - expect(await screen.findByTestId('test-component')).toHaveTextContent('Version 2') + expect(await screen.findByTestId('test-component')).toHaveTextContent( + 'Version 2', + ) // Count renders after invalidate const totalCalls = renderTracker.mock.calls.length @@ -1633,9 +1637,7 @@ describe('invalidate', () => { const loaderData = testRoute.useLoaderData() renderTracker('v1', loaderData) return ( -
- Version 1 - {loaderData.data} -
+
Version 1 - {loaderData.data}
) }, }) @@ -1659,9 +1661,7 @@ describe('invalidate', () => { const loaderData = testRoute.useLoaderData() renderTracker('v2', loaderData) return ( -
- Version 2 - {loaderData.data} -
+
Version 2 - {loaderData.data}
) } @@ -1676,7 +1676,8 @@ describe('invalidate', () => { const rendersAfterInvalidate = renderTracker.mock.calls.length - initialCallCount - const loaderCallsAfterInvalidate = loader.mock.calls.length - initialLoaderCalls + const loaderCallsAfterInvalidate = + loader.mock.calls.length - initialLoaderCalls // Loader should be called once expect(loaderCallsAfterInvalidate).toBe(1)