Skip to content

Commit

Permalink
feat: add useSearchParams hook
Browse files Browse the repository at this point in the history
  • Loading branch information
junwen-k committed Nov 30, 2023
1 parent dd372ac commit daec7b0
Show file tree
Hide file tree
Showing 11 changed files with 276 additions and 3 deletions.
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
22 changes: 22 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,26 @@ 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 = useMemo(() => new URLSearchParams(search), [search]);

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

return [searchParams, 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
20 changes: 19 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 { 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 +33,23 @@ const currentSearch = () => location.search;
export const useSearch = ({ ssrSearch = "" } = {}) =>
useLocationProperty(currentSearch, () => ssrSearch);

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

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

return [searchParams, setSearchParams];
};

const currentPathname = () => location.pathname;

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

const currentHistoryState = () => history.state;

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

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

0 comments on commit daec7b0

Please sign in to comment.