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 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];