From 9652326c00f3407ea4acf19205fb4e761e4368a8 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Thu, 5 Mar 2026 22:25:24 +0900 Subject: [PATCH 1/2] fix(Link): memoize merged ref to prevent unnecessary ref callback invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap `mergeRefs(forwardedRef, prefetchRef)` in `React.useMemo` so that the merged ref callback identity is stable across re-renders. Without this, React treats every render as a ref change, calling the old ref with `null` and the new ref with the DOM node — even when the consumer passes a stable ref callback. Fixes #12705 --- .changeset/stable-link-ref-callback.md | 5 + .../__tests__/dom/link-click-test.tsx | 123 +++++++++++++++++- packages/react-router/lib/dom/lib.tsx | 7 +- 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 .changeset/stable-link-ref-callback.md diff --git a/.changeset/stable-link-ref-callback.md b/.changeset/stable-link-ref-callback.md new file mode 100644 index 0000000000..57a376ebe7 --- /dev/null +++ b/.changeset/stable-link-ref-callback.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Memoize the merged `Link` ref callback to avoid unnecessary ref callback invocations on re-renders. diff --git a/packages/react-router/__tests__/dom/link-click-test.tsx b/packages/react-router/__tests__/dom/link-click-test.tsx index 198824029c..861ab29bcf 100644 --- a/packages/react-router/__tests__/dom/link-click-test.tsx +++ b/packages/react-router/__tests__/dom/link-click-test.tsx @@ -1,7 +1,13 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; import { act } from "@testing-library/react"; -import { MemoryRouter, Routes, Route, Link } from "../../index"; +import { + MemoryRouter, + Routes, + Route, + Link, + UNSAFE_FrameworkContext as FrameworkContext, +} from "../../index"; function click(anchor: HTMLAnchorElement, eventInit?: MouseEventInit) { let event = new MouseEvent("click", { @@ -360,6 +366,121 @@ describe("A click", () => { }); }); + describe("when rerendering with a stable ref callback", () => { + it("does not re-invoke the ref callback", () => { + let ref = jest.fn(); + + function Home() { + let [count, setCount] = React.useState(0); + + return ( +
+ + + Home {count} + +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + + , + ); + }); + + let anchor = node.querySelector("a"); + let button = node.querySelector("button"); + expect(anchor).not.toBeNull(); + expect(button).not.toBeNull(); + expect(ref).toHaveBeenCalledTimes(1); + expect(ref).toHaveBeenLastCalledWith(anchor); + + act(() => { + button?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(ref).toHaveBeenCalledTimes(1); + }); + + it("does not re-invoke the ref callback with prefetch='intent'", () => { + let ref = jest.fn(); + + function Home() { + return ( +
+ + Home + +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + + } /> + + + , + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + expect(ref).toHaveBeenCalledTimes(1); + expect(ref).toHaveBeenLastCalledWith(anchor); + + // mouseenter triggers setMaybePrefetch(true) → re-render + act(() => { + anchor?.dispatchEvent( + new MouseEvent("mouseenter", { + view: window, + bubbles: true, + cancelable: true, + }), + ); + }); + + // mouseleave triggers cancelIntent() → re-render + act(() => { + anchor?.dispatchEvent( + new MouseEvent("mouseleave", { + view: window, + bubbles: true, + cancelable: true, + }), + ); + }); + + // ref should still have been called only once (on mount) + expect(ref).toHaveBeenCalledTimes(1); + }); + }); + describe("with a right click", () => { it("stays on the same page", () => { function Home() { diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index d3924ca980..d60fb362a6 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1386,6 +1386,11 @@ export const Link = React.forwardRef( } } + let mergedRef = React.useMemo( + () => mergeRefs(forwardedRef, prefetchRef), + [forwardedRef, prefetchRef], + ); + let isSpaLink = !(parsed.isExternal || reloadDocument); let link = ( // eslint-disable-next-line jsx-a11y/anchor-has-content @@ -1396,7 +1401,7 @@ export const Link = React.forwardRef( (isSpaLink ? maskedHref : undefined) || parsed.absoluteURL || href } onClick={isSpaLink ? handleClick : onClick} - ref={mergeRefs(forwardedRef, prefetchRef)} + ref={mergedRef} target={target} data-discover={ !isAbsolute && discover === "render" ? "true" : undefined From f988a43b46298436a57a2c21288503757d8eec00 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Thu, 5 Mar 2026 22:28:39 +0900 Subject: [PATCH 2/2] sign CLA --- contributors.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/contributors.yml b/contributors.yml index 31584cfcfc..93164f8007 100644 --- a/contributors.yml +++ b/contributors.yml @@ -453,6 +453,7 @@ - vladinator1000 - vonagam - WalkAlone0325 +- wataryooou - whxhlgy - wilcoxmd - willemarcel