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

Feat: useSearchParams utility hook #391

Open
wants to merge 4 commits into
base: v3
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions packages/wouter-preact/src/preact-deps.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export {
useLayoutEffect as useIsomorphicLayoutEffect,
useLayoutEffect as useInsertionEffect,
useState,
useCallback,
useContext,
useMemo,
} from "preact/hooks";

// Copied from:
Expand Down
21 changes: 21 additions & 0 deletions packages/wouter-preact/types/location-hook.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export type Path = string;

export type SearchString = string;

export type URLSearchParamsInit = ConstructorParameters<
typeof URLSearchParams
>[0];

// the base useLocation hook type. Any custom hook (including the
// default one) should inherit from it.
export type BaseLocationHook = (
Expand All @@ -14,6 +18,23 @@ export type BaseLocationHook = (

export type BaseSearchHook = (...args: any[]) => SearchString;

export type BaseSearchParamsHook = (
...args: Parameters<BaseSearchHook>
) => [
URLSearchParams,
(
nextInit:
| URLSearchParamsInit
| ((prev: URLSearchParams) => URLSearchParamsInit),
...args: Parameters<ReturnType<BaseLocationHook>[1]> extends [
infer _,
...infer Args
]
? Args
: never
) => void
];

/*
* Utility types that operate on hook
*/
Expand Down
17 changes: 16 additions & 1 deletion packages/wouter-preact/types/use-browser-location.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Path, SearchString } from "./location-hook.js";
import { Path, SearchString, URLSearchParamsInit } from "./location-hook.js";

type Primitive = string | number | bigint | boolean | null | undefined | symbol;

export const useLocationProperty: <S extends Primitive>(
fn: () => S,
ssrFn?: () => S
Expand All @@ -12,6 +13,20 @@ export type BrowserSearchHook = (options?: {

export const useSearch: BrowserSearchHook;

export type BrowserSearchParamsHook = (
...args: Parameters<BrowserSearchHook>
) => [
URLSearchParams,
(
nextInit:
| URLSearchParamsInit
| ((prev: URLSearchParams) => URLSearchParamsInit),
options?: Parameters<typeof navigate>[1]
) => void
];

export const useSearchParams: BrowserSearchParamsHook;

export const usePathname: (options?: { ssrPath?: Path }) => Path;

export const useHistoryState: <T = any>() => T;
Expand Down
28 changes: 28 additions & 0 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {

import {
useRef,
useCallback,
useContext,
useMemo,
createContext,
isValidElement,
cloneElement,
Expand Down Expand Up @@ -76,6 +78,32 @@ export const useSearch = () => {
return unescape(stripQm(router.searchHook(router)));
};

export const useSearchParams = () => {
const router = useRouter();
const [, navigate] = useLocationFromRouter(router);

const search = unescape(stripQm(router.searchHook(router)));
const searchParamsRef = useRef(new URLSearchParams(search));
searchParamsRef.current = useMemo(
() => new URLSearchParams(search),
[search]
);

const setSearchParams = useCallback(
(nextInit, navOpts) => {
const newSearchParams = new URLSearchParams(
typeof nextInit === "function"
? nextInit(searchParamsRef.current)
: nextInit
);
navigate("?" + newSearchParams, navOpts);
},
[navigate]
);

return [searchParamsRef.current, setSearchParams];
};

const matchRoute = (parser, route, path, loose) => {
// falsy patterns mean this route "always matches"
if (!route) return [true, {}];
Expand Down
2 changes: 2 additions & 0 deletions packages/wouter/src/react-deps.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ const {
export {
useRef,
useState,
useCallback,
useContext,
useMemo,
createContext,
isValidElement,
cloneElement,
Expand Down
28 changes: 27 additions & 1 deletion packages/wouter/src/use-browser-location.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { useSyncExternalStore } from "./react-deps.js";
import {
useRef,
useCallback,
useMemo,
useSyncExternalStore,
} from "./react-deps.js";

/**
* History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History
Expand Down Expand Up @@ -33,6 +38,26 @@ const currentSearch = () => location.search;
export const useSearch = ({ ssrSearch = "" } = {}) =>
useLocationProperty(currentSearch, () => ssrSearch);

export const useSearchParams = ({ ssrSearch = "" } = {}) => {
const search = useSearch({ ssrSearch });
const searchParamsRef = useRef(new URLSearchParams(search));
searchParamsRef.current = useMemo(
() => new URLSearchParams(search),
[search]
);

const setSearchParams = useCallback((nextInit, navOpts) => {
const newSearchParams = new URLSearchParams(
typeof nextInit === "function"
? nextInit(searchParamsRef.current)
: nextInit
);
navigate("?" + newSearchParams, navOpts);
}, []);

return [searchParamsRef.current, setSearchParams];
};

const currentPathname = () => location.pathname;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some ideas to reduce extra React imports and make this function smaller:

Suggested change
export const useSearchParams = ({ ssrSearch = "" } = {}) => {
const search = useSearch({ ssrSearch });
const searchParamsRef = useRef(new URLSearchParams(search));
searchParamsRef.current = useMemo(
() => new URLSearchParams(search),
[search]
);
const setSearchParams = useCallback((nextInit, navOpts) => {
const newSearchParams = new URLSearchParams(
typeof nextInit === "function"
? nextInit(searchParamsRef.current)
: nextInit
);
navigate("?" + newSearchParams, navOpts);
}, []);
return [searchParamsRef.current, setSearchParams];
};
export const useSearchParams = ({ ssrSearch = "" } = {}) => {
const search = useSearch({ ssrSearch });
const params = useMemo(() => new URLSearchParams(search)));
const setSearchParams = useEvent((nextInit, navOpts) => {
const newSearchParams = new URLSearchParams(
typeof nextInit === "function"
? nextInit(params)
: nextInit
);
navigate("?" + newSearchParams, navOpts);
});
return [params, setSearchParams];
};

Here, I replaced useCallback with useEvent. We should not see degraded performance, unless the component that uses it renders too often, e.g. once every 50-300 ms (but that isn't really our responsibility, since when used standalone this hook will re-render only when the query string changes, which in real-life scenarios shouldn't be too often). Plus, useEvent is already part of react-deps.js.

I'm still trying to figure out how useMemo can be avoided further on. The question is whether we really want to memoise it. Considering, that the hook doesn't render too often, it should be safe to always return a new instance of URLSearchParams. Why:

  1. Immutability. Is it safe to re-use this object? What if developer decides to mutate it for some reason, this would cause some undefined bevahiour.
  2. The memory/CPU footprint of URLSearchParams. I'm not sure how browsers implement it, but at the first glance it doesn't seem like a heavy resource. Though, I will make a proper research to figure this out.
  3. Stability. One of the caveats could be that we always return a new object reference. This could harm performance only if searchParams instance is further passed down to components as a prop. But in reality, why would someone want to do that? I assume that the normal use-case is the get the object, and then extract the required primitive objects from it, e.g. searchParams.get("foo")

Anyways, there is a trade-off that we should evaluate there.


export const usePathname = ({ ssrPath } = {}) =>
Expand All @@ -42,6 +67,7 @@ export const usePathname = ({ ssrPath } = {}) =>
);

const currentHistoryState = () => history.state;

export const useHistoryState = () =>
useLocationProperty(currentHistoryState, () => null);

Expand Down
16 changes: 9 additions & 7 deletions packages/wouter/src/use-hash-location.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { navigate as browserNavigate } from "./use-browser-location.js";
import { useSyncExternalStore } from "./react-deps.js";

// array of callback subscribed to hash updates
Expand All @@ -23,17 +24,18 @@ const subscribeToHashUpdates = (callback) => {
const currentHashLocation = () => "/" + location.hash.replace(/^#?\/?/, "");

export const navigate = (to, { state = null } = {}) => {
// calling `replaceState` allows us to set the history
// state without creating an extra entry
history.replaceState(
state,
"",
// keep the current pathname, current query string, but replace the hash
browserNavigate(
location.pathname +
location.search +
// update location hash, this will cause `hashchange` event to fire
// normalise the value before updating, so it's always preceeded with "#/"
(location.hash = `#/${to.replace(/^#?\/?/, "")}`)
(location.hash = `#/${to.replace(/^#?\/?/, "")}`),
{
// calling `replaceState` allows us to set the history
// state without creating an extra entry
replace: true,
state,
}
);
};

Expand Down
97 changes: 97 additions & 0 deletions packages/wouter/test/use-browser-location.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useBrowserLocation,
navigate,
useSearch,
useSearchParams,
useHistoryState,
} from "wouter/use-browser-location";

Expand Down Expand Up @@ -194,3 +195,99 @@ describe("`update` second parameter", () => {
unmount();
});
});

describe("`useSearchParams` hook", () => {
beforeEach(() => history.replaceState(null, "", "/"));

it("returns a pair [value, update]", () => {
const { result } = renderHook(() => useSearchParams());
const [value, update] = result.current;

expect(value).toBeInstanceOf(URLSearchParams);
expect(typeof update).toBe("function");
});

it("allows to get current url search params", () => {
const { result } = renderHook(() => useSearchParams());
act(() => navigate("/foo?hello=world&whats=up"));

expect(result.current[0].get("hello")).toBe("world");
expect(result.current[0].get("whats")).toBe("up");
});

it("returns empty url search params when there is no search string", () => {
const { result } = renderHook(() => useSearchParams());

expect(Array.from(result.current[0]).length).toBe(0);

act(() => navigate("/foo"));
expect(Array.from(result.current[0]).length).toBe(0);

act(() => navigate("/foo? "));
expect(Array.from(result.current[0]).length).toBe(0);
});

it("does not re-render when only pathname is changed", () => {
// count how many times each hook is rendered
const locationRenders = { current: 0 };
const searchParamsRenders = { current: 0 };

// count number of rerenders for each hook
renderHook(() => {
useEffect(() => {
locationRenders.current += 1;
});
return useBrowserLocation();
});

renderHook(() => {
useEffect(() => {
searchParamsRenders.current += 1;
});
return useSearchParams();
});

expect(locationRenders.current).toBe(1);
expect(searchParamsRenders.current).toBe(1);

act(() => navigate("/foo"));

expect(locationRenders.current).toBe(2);
expect(searchParamsRenders.current).toBe(1);

act(() => navigate("/foo?bar"));
expect(locationRenders.current).toBe(2); // no re-render
expect(searchParamsRenders.current).toBe(2);

act(() => navigate("/baz?bar"));
expect(locationRenders.current).toBe(3); // no re-render
expect(searchParamsRenders.current).toBe(2);
});

it("support setting search params with different formats", () => {
const { result } = renderHook(() => useSearchParams());

expect(Array.from(result.current[0]).length).toBe(0);

act(() => result.current[1]("hello=world"));
expect(result.current[0].get("hello")).toBe("world");

act(() => result.current[1]("?whats=up"));
expect(result.current[0].get("whats")).toBe("up");

act(() => result.current[1]({ object: "previous" }));
expect(result.current[0].get("object")).toBe("previous");

act(() =>
result.current[1]((prev) => ({
object: prev.get("object")!,
function: "syntax",
}))
);
expect(result.current[0].get("object")).toBe("previous");
expect(result.current[0].get("function")).toBe("syntax");

act(() => result.current[1]([["key", "value"]]));
expect(result.current[0].get("key")).toBe("value");
});
});
54 changes: 54 additions & 0 deletions packages/wouter/test/use-search-params.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { renderHook, act } from "@testing-library/react";
import {
useSearchParams,
Router,
BaseLocationHook,
BaseSearchHook,
} from "wouter";
import { navigate } from "wouter/use-browser-location";
import { it, expect, beforeEach, vi } from "vitest";

beforeEach(() => history.replaceState(null, "", "/"));

it("returns browser url search params", () => {
history.replaceState(null, "", "/users?active=true");
const { result } = renderHook(() => useSearchParams());
const [value] = result.current;

expect(value.get("active")).toEqual("true");
});

it("can be customized in the Router", () => {
const customSearchHook: BaseSearchHook = ({ customOption = "unused" }) =>
"hello=world";
const navigate = vi.fn();
const customHook: BaseLocationHook = () => ["/foo", navigate];

const { result } = renderHook(() => useSearchParams(), {
wrapper: (props) => {
return (
<Router hook={customHook} searchHook={customSearchHook}>
{props.children}
</Router>
);
},
});

expect(result.current[0].get("hello")).toEqual("world");

act(() => result.current[1]("active=false"));
expect(navigate).toBeCalledTimes(1);
expect(navigate).toBeCalledWith("?active=false", undefined);
});

it("unescapes search string", () => {
const { result } = renderHook(() => useSearchParams());

act(() => result.current[1]("?nonce=not Found&country=საქართველო"));
expect(result.current[0].get("nonce")).toBe("not Found");
expect(result.current[0].get("country")).toBe("საქართველო");

// question marks
act(() => result.current[1]("?вопрос=как дела?"));
expect(result.current[0].get("вопрос")).toBe("как дела?");
});
6 changes: 6 additions & 0 deletions packages/wouter/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
HookReturnValue,
HookNavigationOptions,
BaseSearchHook,
BaseSearchParamsHook,
} from "./location-hook.js";
import {
BrowserLocationHook,
BrowserSearchHook,
BrowserSearchParamsHook,
} from "./use-browser-location.js";

import { RouterObject, RouterOptions } from "./router.js";
Expand Down Expand Up @@ -155,6 +157,10 @@ export function useSearch<
H extends BaseSearchHook = BrowserSearchHook
>(): ReturnType<H>;

export function useSearchParams<
H extends BaseSearchParamsHook = BrowserSearchParamsHook
>(): ReturnType<H>;

export function useParams<T = undefined>(): T extends string
? RouteParams<T>
: T extends undefined
Expand Down