Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useSearchParams Bug Fix | Do not regenerate the setSearchParams function when the searchParams change #10740

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
- rubeonline
- ryanflorence
- ryanhiebert
- rwilliams3088
- sanketshah19
- scarf005
- senseibarni
Expand Down
103 changes: 103 additions & 0 deletions packages/react-router-dom/__tests__/search-params-test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { waitFor } from "@testing-library/react";
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import { act } from "react-dom/test-utils";
import type { SetURLSearchParams } from "react-router-dom";
import { MemoryRouter, Routes, Route, useSearchParams } from "react-router-dom";

describe("useSearchParams", () => {
Expand Down Expand Up @@ -182,4 +184,105 @@ describe("useSearchParams", () => {
`"<p>value=initial&amp;a=1&amp;b=2</p>"`
);
});

it("does not modify the setSearchParams reference when the searchParams change", async () => {
interface TestParams {
incrementParamsUpdateCount: () => number;
incrementSetterUpdateCount: () => number;
}

function TestComponent(params: Readonly<TestParams>) {
const { incrementParamsUpdateCount, incrementSetterUpdateCount } = params;
const queryRef = React.useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useSearchParams({ q: "" });
const query = searchParams.get("q")!;

React.useEffect(() => {
incrementParamsUpdateCount();
}, [searchParams]);

React.useEffect(() => {
incrementSetterUpdateCount();
}, [setSearchParams]);

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (queryRef.current) {
setSearchParams({ q: queryRef.current.value });
}
}

return (
<div>
<p>The current query is "{query}".</p>
<form onSubmit={handleSubmit}>
<input name="q" defaultValue={query} ref={queryRef} />
</form>
</div>
);
}

function TestApp(params: TestParams) {
return (
<MemoryRouter initialEntries={["/search?q=Something"]}>
<Routes>
<Route path="search" element={<TestComponent {...params} />} />
</Routes>
</MemoryRouter>
);
}

const state = {
paramsUpdateCount: 0,
setterUpdateCount: 0
};

const params: TestParams = {
incrementParamsUpdateCount: () => ++state.paramsUpdateCount,
incrementSetterUpdateCount: () => ++state.setterUpdateCount
};

// Initial Rendering of the TestApp
// The TestComponent should increment both the paramsUpdateCount and setterUpdateCount to 1
act(() => {
ReactDOM.createRoot(node).render(<TestApp {...params} />);
});

let form = node.querySelector("form")!;
let queryInput = node.querySelector<HTMLInputElement>("input[name=q]")!;
await waitFor(() => {
expect(form).toBeDefined();
expect(queryInput).toBeDefined();
expect(state.paramsUpdateCount).toEqual(1);
expect(state.setterUpdateCount).toEqual(1);
});

// Modify the search params via the form in the TestComponent.
// This should trigger a re-render of the component and update the paramsUpdateCount (only)
act(() => {
queryInput.value = "Something+Else";
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true })
);
});

await waitFor(() => {
expect(state.paramsUpdateCount).toEqual(2);
expect(state.setterUpdateCount).toEqual(1);
});

// Third Times The Charm
// Verifies that the setter is still valid
act(() => {
queryInput.value = "on+a+boat";
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true })
);
});

await waitFor(() => {
expect(state.paramsUpdateCount).toEqual(3);
expect(state.setterUpdateCount).toEqual(1);
});
});
});
51 changes: 30 additions & 21 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -991,33 +991,42 @@ export function useSearchParams(
`user.`
);

let location = useLocation();
let defaultSearchParamsRef = React.useRef(createSearchParams(defaultInit));
let hasSetSearchParamsRef = React.useRef(false);

let location = useLocation();
let searchParams = React.useMemo(
() =>
// Only merge in the defaults if we haven't yet called setSearchParams.
// Once we call that we want those to take precedence, otherwise you can't
// remove a param with setSearchParams({}) if it has an initial value
getSearchParamsForLocation(
location.search,
hasSetSearchParamsRef.current ? null : defaultSearchParamsRef.current
),
[location.search]
let [searchParams, __setSearchParams] = React.useState<URLSearchParams>(
getSearchParamsForLocation( location.search, defaultSearchParamsRef.current )
);
let searchParamsRef = React.useRef<URLSearchParams>(searchParams);

let initializedRef = React.useRef<boolean>(false);
React.useEffect(() => {
// Prevent re-assigning the search params on initialization
if (!initializedRef.current) {
initializedRef.current = true;
return;
}

// Only merge in the defaults if we haven't yet called setSearchParams.
// Once we call that we want those to take precedence, otherwise you can't
// remove a param with setSearchParams({}) if it has an initial value
searchParamsRef.current = getSearchParamsForLocation(
location.search,
hasSetSearchParamsRef.current ? null : defaultSearchParamsRef.current
);

__setSearchParams(searchParamsRef.current);
}, [location.search, __setSearchParams]);

let navigate = useNavigate();
let setSearchParams = React.useCallback<SetURLSearchParams>(
(nextInit, navigateOptions) => {
const newSearchParams = createSearchParams(
typeof nextInit === "function" ? nextInit(searchParams) : nextInit
);
hasSetSearchParamsRef.current = true;
navigate("?" + newSearchParams, navigateOptions);
},
[navigate, searchParams]
);
let setSearchParams = React.useCallback<SetURLSearchParams>((nextInit, navigateOptions) => {
const newSearchParams = createSearchParams(
typeof nextInit === "function" ? nextInit(searchParamsRef.current) : nextInit
);
hasSetSearchParamsRef.current = true;
navigate("?" + newSearchParams, navigateOptions);
},[navigate]);

return [searchParams, setSearchParams];
}
Expand Down
134 changes: 111 additions & 23 deletions packages/react-router-native/__tests__/search-params-test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as React from "react";
import { View, Text, TextInput } from "react-native";
import type {
SetURLSearchParams} from "react-router-native";
import {
NativeRouter,
Routes,
Route,
useSearchParams,
useSearchParams
} from "react-router-native";
import * as TestRenderer from "react-test-renderer";

Expand All @@ -22,7 +24,7 @@ describe("useSearchParams", () => {
return <View>{children}</View>;
}

it("reads and writes the search string", () => {
it("reads and writes the search string", async () => {
function SearchPage() {
let [searchParams, setSearchParams] = useSearchParams({ q: "" });
let [query, setQuery] = React.useState(searchParams.get("q")!);
Expand All @@ -43,7 +45,7 @@ describe("useSearchParams", () => {
}

let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
await TestRenderer.act(async () => {
renderer = TestRenderer.create(
<NativeRouter initialEntries={["/search?q=Michael+Jackson"]}>
<Routes>
Expand All @@ -53,24 +55,22 @@ describe("useSearchParams", () => {
);
});

expect(renderer.toJSON()).toMatchSnapshot();

let textInput = renderer.root.findByType(TextInput);
expect(renderer!.toJSON()).toMatchSnapshot();

TestRenderer.act(() => {
await TestRenderer.act(() => {
let textInput = renderer.root.findByType(TextInput);
textInput.props.onChangeText("Ryan Florence");
});

let searchForm = renderer.root.findByType(SearchForm);

TestRenderer.act(() => {
await TestRenderer.act(() => {
let searchForm = renderer.root.findByType(SearchForm);
searchForm.props.onSubmit();
});

expect(renderer.toJSON()).toMatchSnapshot();
expect(renderer!.toJSON()).toMatchSnapshot();
});

it("reads and writes the search string (functional update)", () => {
it("reads and writes the search string (functional update)", async () => {
function SearchPage() {
let [searchParams, setSearchParams] = useSearchParams({ q: "" });
let [query, setQuery] = React.useState(searchParams.get("q")!);
Expand All @@ -96,7 +96,7 @@ describe("useSearchParams", () => {
}

let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
await TestRenderer.act(() => {
renderer = TestRenderer.create(
<NativeRouter initialEntries={["/search?q=Michael+Jackson"]}>
<Routes>
Expand All @@ -106,18 +106,17 @@ describe("useSearchParams", () => {
);
});

expect(renderer.toJSON()).toMatchSnapshot();

let searchForm = renderer.root.findByType(SearchForm);
expect(renderer!.toJSON()).toMatchSnapshot();

TestRenderer.act(() => {
let searchForm = renderer.root.findByType(SearchForm);
searchForm.props.onSubmit();
});

expect(renderer.toJSON()).toMatchSnapshot();
expect(renderer!.toJSON()).toMatchSnapshot();
});

it("allows removal of search params when a default is provided", () => {
it("allows removal of search params when a default is provided", async () => {
function SearchPage() {
let [searchParams, setSearchParams] = useSearchParams({
value: "initial",
Expand All @@ -132,7 +131,7 @@ describe("useSearchParams", () => {
}

let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
await TestRenderer.act(() => {
renderer = TestRenderer.create(
<NativeRouter initialEntries={["/search?value=initial"]}>
<Routes>
Expand All @@ -142,14 +141,103 @@ describe("useSearchParams", () => {
);
});

expect(renderer.toJSON()).toMatchSnapshot();
expect(renderer!.toJSON()).toMatchSnapshot();

await TestRenderer.act(() => {
let button = renderer.root.findByType(Button);
button.props.onClick();
});

let button = renderer.root.findByType(Button);
expect(renderer!.toJSON()).toMatchSnapshot();
});

it("does not modify the setSearchParams reference when the searchParams change", async () => {
interface TestParams {
incrementParamsUpdateCount: () => number;
incrementSetterUpdateCount: () => number;
}

function TestComponent(params: Readonly<TestParams>) {
const { incrementParamsUpdateCount, incrementSetterUpdateCount } = params;
const lastParamsRef = React.useRef<URLSearchParams>();
const lastSetterRef = React.useRef<SetURLSearchParams>();
const [searchParams, setSearchParams] = useSearchParams({ q: "" });
const [query] = React.useState(searchParams.get("q")!);

React.useEffect(() => {
lastParamsRef.current = searchParams;
incrementParamsUpdateCount();
}, [searchParams, incrementParamsUpdateCount]);

React.useEffect(() => {
lastSetterRef.current = setSearchParams;
incrementSetterUpdateCount();
}, [setSearchParams, incrementSetterUpdateCount]);

function handleSubmit() {
setSearchParams(cur => {
cur.set("q", `${cur.get("q")} - appended`);
return cur;
});
}

return (
<View>
<SearchForm onSubmit={handleSubmit}>
<TextInput value={query} /*onChangeText={setQuery}*/ />
</SearchForm>
</View>
);
}

function TestApp(params: TestParams) {
return (
<NativeRouter initialEntries={["/search?q=Michael+Jackson"]}>
<Routes>
<Route path="search" element={<TestComponent {...params} />} />
</Routes>
</NativeRouter>
);
}

const state = {
paramsUpdateCount: 0,
setterUpdateCount: 0
};

const params: TestParams = {
incrementParamsUpdateCount: () => ++state.paramsUpdateCount,
incrementSetterUpdateCount: () => ++state.setterUpdateCount
};

// Initial Rendering of the TestApp
// The TestComponent should increment both the paramsUpdateCount and setterUpdateCount to 1
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
button.props.onClick();
renderer = TestRenderer.create(<TestApp {...params}/>);
});

expect(state.paramsUpdateCount).toEqual(1);
expect(state.setterUpdateCount).toEqual(1);

// Modify the search params via the form in the TestComponent.
// This should trigger a re-render of the component and update the paramsUpdateCount (only)
const searchForm = renderer!.root.findByType(SearchForm);
TestRenderer.act(() => {
searchForm.props.onSubmit();
});

expect(state.paramsUpdateCount).toEqual(2);
expect(state.setterUpdateCount).toEqual(1);

// Third Times The Charm
// Verifies that the setter is still valid now that we aren't regenerating each time the
// searchParams reference changes
TestRenderer.act(() => {
searchForm.props.onSubmit();
});

expect(renderer.toJSON()).toMatchSnapshot();
expect(state.paramsUpdateCount).toEqual(3);
expect(state.setterUpdateCount).toEqual(1);
});
});