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