diff --git a/.changeset/fix-blocker-loop.md b/.changeset/fix-blocker-loop.md new file mode 100644 index 0000000000..3a1c6b6178 --- /dev/null +++ b/.changeset/fix-blocker-loop.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Fix loop in `unstable_useBlocker` when used with an unstable blocker function diff --git a/packages/react-router-dom/__tests__/use-blocker-test.tsx b/packages/react-router-dom/__tests__/use-blocker-test.tsx index 9fa664c44b..4ffe9338b3 100644 --- a/packages/react-router-dom/__tests__/use-blocker-test.tsx +++ b/packages/react-router-dom/__tests__/use-blocker-test.tsx @@ -114,6 +114,50 @@ describe("navigation blocking with useBlocker", () => { act(() => root.unmount()); }); + it("handles unstable blocker function identities", async () => { + let count = 0; + router = createMemoryRouter([ + { + element: React.createElement(() => { + // New function identity on each render + let b = useBlocker(() => false); + blocker = b; + if (++count > 50) { + throw new Error("useBlocker caused a re-render loop!"); + } + return ( +
+ /about + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + }, + ], + }, + ]); + + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + + expect(node.querySelector("h1")?.textContent).toBe("Home"); + + act(() => click(node.querySelector("a[href='/about']"))); + expect(node.querySelector("h1")?.textContent).toBe("About"); + + act(() => root.unmount()); + }); + describe("on navigation", () => { describe("blocker returns false", () => { beforeEach(() => { diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 0135ea0bcc..c3177d8645 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -972,12 +972,23 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { [basename, shouldBlock] ); + // This effect is in charge of blocker key assignment and deletion (which is + // tightly coupled to the key) React.useEffect(() => { let key = String(++blockerId); - setBlocker(router.getBlocker(key, blockerFunction)); setBlockerKey(key); return () => router.deleteBlocker(key); - }, [router, setBlocker, setBlockerKey, blockerFunction]); + }, [router]); + + // This effect handles assigning the blockerFunction. This is to handle + // unstable blocker function identities, and happens only after the prior + // effect so we don't get an orphaned blockerFunction in the router with a + // key of "". Until then we just have the IDLE_BLOCKER. + React.useEffect(() => { + if (blockerKey !== "") { + setBlocker(router.getBlocker(blockerKey, blockerFunction)); + } + }, [router, blockerKey, blockerFunction]); // Prefer the blocker from state since DataRouterContext is memoized so this // ensures we update on blocker state updates