Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,4 @@
- zeromask1337
- zheng-chuang
- zxTomw
- DukeDeSouth
142 changes: 141 additions & 1 deletion packages/react-router/__tests__/dom/search-params-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<p>The current query is "{searchParams.get("q") || ""}".</p>
<button onClick={() => setSearchParams({ q: "updated" })}>
Update
</button>
</div>
);
}

act(() => {
ReactDOM.createRoot(node).render(
<MemoryRouter initialEntries={["/search?q=initial"]}>
<Routes>
<Route path="search" element={<SearchPage />} />
</Routes>
</MemoryRouter>,
);
});

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 (
<div>
<p>The current query is "{searchParams.get("q") || ""}".</p>
<button onClick={() => setSearchParams({ q: "updated" })}>
Update
</button>
</div>
);
}

act(() => {
ReactDOM.createRoot(node).render(
<MemoryRouter initialEntries={["/search?q=initial"]}>
<Routes>
<Route path="search" element={<SearchPage />} />
</Routes>
</MemoryRouter>,
);
});

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 (
<div>
<p>a={searchParams.get("a") || ""} b={searchParams.get("b") || ""}</p>
<button
id="set-a"
onClick={() => setSearchParams({ a: "1" })}
>
Set A
</button>
<button
id="set-b"
onClick={() =>
setSearchParams((prev) => {
prev.set("b", "2");
return prev;
})
}
>
Set B
</button>
</div>
);
}

act(() => {
ReactDOM.createRoot(node).render(
<MemoryRouter initialEntries={["/search"]}>
<Routes>
<Route path="search" element={<SearchPage />} />
</Routes>
</MemoryRouter>,
);
});

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: "/",
Expand Down
16 changes: 14 additions & 2 deletions packages/react-router/lib/dom/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetURLSearchParams>(
(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];
Expand Down