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 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
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
17 changes: 17 additions & 0 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ 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 searchParams = new URLSearchParams(search);

const setSearchParams = useEvent((nextInit, navOpts) => {
const newSearchParams = new URLSearchParams(
typeof nextInit === "function" ? nextInit(searchParams) : nextInit
);
navigate("?" + newSearchParams, navOpts);
});

return [searchParams, setSearchParams];
};

const matchRoute = (parser, route, path, loose) => {
// falsy patterns mean this route "always matches"
if (!route) return [true, {}];
Expand Down
17 changes: 16 additions & 1 deletion packages/wouter/src/use-browser-location.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSyncExternalStore } from "./react-deps.js";
import { useEvent, 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 +33,20 @@ const currentSearch = () => location.search;
export const useSearch = ({ ssrSearch = "" } = {}) =>
useLocationProperty(currentSearch, () => ssrSearch);

export const useSearchParams = ({ ssrSearch = "" } = {}) => {
const search = useSearch({ ssrSearch });
const searchParams = new URLSearchParams(search);

const setSearchParams = useEvent((nextInit, navOpts) => {
const newSearchParams = new URLSearchParams(
typeof nextInit === "function" ? nextInit(searchParams) : nextInit
);
navigate("?" + newSearchParams, navOpts);
});

return [searchParams, setSearchParams];
};

const currentPathname = () => location.pathname;

export const usePathname = ({ ssrPath } = {}) =>
Expand All @@ -42,6 +56,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
21 changes: 21 additions & 0 deletions packages/wouter/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
Loading
Loading