Skip to content

Commit 4547c80

Browse files
authored
Fix rendering/router.subscribe race condition during hydration (#14497)
1 parent dd8d237 commit 4547c80

3 files changed

Lines changed: 61 additions & 0 deletions

File tree

.changeset/calm-crabs-bathe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Fix a potential race condition that can occur when rendering a `HydrateFallback` and initial loaders land before the `router.subscribe` call happens in the `RouterProvider` layout effect

packages/react-router/__tests__/dom/data-browser-router-test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,48 @@ function testDomRouter(
561561
</div>"
562562
`);
563563
});
564+
565+
it("handles race conditions if router initialization completes prior to the layout effect router.subscribe() call", async () => {
566+
const sleep = (ms: number) =>
567+
new Promise((resolve) => setTimeout(resolve, ms));
568+
569+
// Kick off some async data load _before_ any react stuff
570+
let suspensePromise = sleep(100).then(() => "DATA");
571+
572+
// Create a router that will initialize shortly after the suspense boundary resolves
573+
let router = createTestRouter([
574+
{
575+
path: "/",
576+
// Only fails when this is around 200ms - passes if you bump it to ~500ms
577+
loader: () => sleep(200).then(() => "LOADER"),
578+
Component: () => <p>Data:{useLoaderData()}</p>,
579+
HydrateFallback: () => "Hydrate Fallback",
580+
},
581+
]);
582+
expect(router.state.initialized).toBe(false);
583+
584+
// Render a component that will suspend until `suspensePromise` resolves, then
585+
// renders RouterProvider which sets up listeners for the router state
586+
function App() {
587+
// @ts-expect-error Needs React 19 types
588+
React.use(suspensePromise);
589+
return <RouterProvider router={router} />;
590+
}
591+
592+
// Needs to be wrapped in `act()` for suspense to work properly
593+
// https://github.com/testing-library/react-testing-library/issues/1375
594+
await act(async () => {
595+
render(
596+
<React.Suspense fallback="Suspense Fallback">
597+
<App />
598+
</React.Suspense>,
599+
);
600+
});
601+
602+
expect(screen.getByText("Suspense Fallback")).toBeDefined();
603+
await waitFor(() => screen.getByText("Data:LOADER"));
604+
expect(screen.queryByText("Suspense Fallback")).toBeNull();
605+
});
564606
});
565607

566608
describe("navigations", () => {

packages/react-router/lib/components.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,20 @@ export function RouterProvider({
618618
// pick up on any render-driven redirects/navigations (useEffect/<Navigate>)
619619
React.useLayoutEffect(() => router.subscribe(setState), [router, setState]);
620620

621+
// Track race conditions where we finish initializing prior to the layout
622+
// effect above running to register our listener. If we manually detect a
623+
// change in `state.initialized`, automatically sync state.
624+
let initialized = state.initialized;
625+
React.useLayoutEffect(() => {
626+
if (!initialized && router.state.initialized) {
627+
setState(router.state, {
628+
deletedFetchers: [],
629+
flushSync: false,
630+
newErrors: null,
631+
});
632+
}
633+
}, [initialized, setState, router.state]);
634+
621635
// When we start a view transition, create a Deferred we can use for the
622636
// eventual "completed" render
623637
React.useEffect(() => {

0 commit comments

Comments
 (0)