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/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 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