From 9c2eef5050f014a2f8e1b76c58fe5645765842d7 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Sat, 7 Feb 2026 20:54:09 -0500 Subject: [PATCH 1/2] fix(react-router): make setSearchParams reference stable across renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setSearchParams` was recreated on every render when search params changed because `searchParams` was listed as a `useCallback` dependency. This broke `useEffect` patterns where `setSearchParams` appeared in the dependency array, causing infinite re-renders or unnecessary effect re-runs. The fix stores `searchParams` in a ref that is updated via `useEffect` (not during render, to stay safe under concurrent rendering). The callback reads from the ref, so `searchParams` can be removed from the `useCallback` dependency array. This makes `setSearchParams` behave like React's `setState` — reference-stable across renders. The ref is updated in an effect rather than during render to comply with React's rules for concurrent mode, where render functions may be called multiple times before committing. This addresses the concern raised in the review of #10740. Fixes #9991 Co-authored-by: Cursor --- .../__tests__/dom/search-params-test.tsx | 142 +++++++++++++++++- packages/react-router/lib/dom/lib.tsx | 16 +- 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/packages/react-router/__tests__/dom/search-params-test.tsx b/packages/react-router/__tests__/dom/search-params-test.tsx index 33ed179985..e966a4b0dc 100644 --- a/packages/react-router/__tests__/dom/search-params-test.tsx +++ b/packages/react-router/__tests__/dom/search-params-test.tsx @@ -192,7 +192,147 @@ describe("useSearchParams", () => { ); }); - it("does not reflect functional update mutation when navigation is blocked", () => { + // @see https://github.com/remix-run/react-router/issues/9991 + it("returns a stable setSearchParams reference across renders", () => { + let setSearchParamsRefs: Function[] = []; + + function SearchPage() { + let [searchParams, setSearchParams] = useSearchParams(); + setSearchParamsRefs.push(setSearchParams); + + return ( +
+

The current query is "{searchParams.get("q") || ""}".

+ +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + + , + ); + }); + + expect(node.innerHTML).toMatch(/The current query is "initial"/); + expect(setSearchParamsRefs.length).toBe(1); + + act(() => { + node + .querySelector("button")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(node.innerHTML).toMatch(/The current query is "updated"/); + expect(setSearchParamsRefs.length).toBe(2); + expect(setSearchParamsRefs[0]).toBe(setSearchParamsRefs[1]); + }); + + // @see https://github.com/remix-run/react-router/issues/9991 + it("does not re-trigger useEffect when search params change", () => { + let effectCount = 0; + + function SearchPage() { + let [searchParams, setSearchParams] = useSearchParams(); + + React.useEffect(() => { + effectCount++; + }, [setSearchParams]); + + return ( +
+

The current query is "{searchParams.get("q") || ""}".

+ +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + + , + ); + }); + + expect(effectCount).toBe(1); + + act(() => { + node + .querySelector("button")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(effectCount).toBe(1); + }); + + // @see https://github.com/remix-run/react-router/issues/9991 + it("provides current search params to functional updates after stabilization", () => { + function SearchPage() { + let [searchParams, setSearchParams] = useSearchParams(); + + return ( +
+

a={searchParams.get("a") || ""} b={searchParams.get("b") || ""}

+ + +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + + , + ); + }); + + act(() => { + node + .querySelector("#set-a")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(node.innerHTML).toMatch(/a=1/); + + act(() => { + node + .querySelector("#set-b")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(node.innerHTML).toMatch(/b=2/); + }); + + it("does not reflect functional update mutation when navigation is blocked", () => { let router = createBrowserRouter([ { path: "/", diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 39bc64675f..4d3a9db308 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -2310,17 +2310,29 @@ export function useSearchParams( ); let navigate = useNavigate(); + + // Keep a ref to the latest searchParams so the callback can read + // the current value without listing it as a dependency. This makes + // setSearchParams reference-stable like React's setState. + // The ref is updated in an effect (not during render) to stay safe + // under React's concurrent rendering mode. + // @see https://github.com/remix-run/react-router/issues/9991 + let searchParamsRef = React.useRef(searchParams); + React.useEffect(() => { + searchParamsRef.current = searchParams; + }, [searchParams]); + let setSearchParams = React.useCallback( (nextInit, navigateOptions) => { const newSearchParams = createSearchParams( typeof nextInit === "function" - ? nextInit(new URLSearchParams(searchParams)) + ? nextInit(new URLSearchParams(searchParamsRef.current)) : nextInit, ); hasSetSearchParamsRef.current = true; navigate("?" + newSearchParams, navigateOptions); }, - [navigate, searchParams], + [navigate], ); return [searchParams, setSearchParams]; From a786573b486e004061bf48a606ef2998130876a4 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Sun, 8 Feb 2026 00:39:33 -0500 Subject: [PATCH 2/2] sign CLA: add DukeDeSouth to contributors.yml Co-authored-by: Cursor --- contributors.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/contributors.yml b/contributors.yml index 0966b71f78..d2041f3fcb 100644 --- a/contributors.yml +++ b/contributors.yml @@ -473,3 +473,4 @@ - zeromask1337 - zheng-chuang - zxTomw +- DukeDeSouth