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

Closed
wants to merge 4 commits into from
Closed
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
@@ -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 = (
@@ -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
*/
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
@@ -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;
17 changes: 17 additions & 0 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
@@ -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, {}];
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
@@ -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 } = {}) =>
@@ -42,6 +56,7 @@ export const usePathname = ({ ssrPath } = {}) =>
);

const currentHistoryState = () => history.state;

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

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
@@ -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,
}
);
};

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

@@ -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
@@ -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";
@@ -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
21 changes: 21 additions & 0 deletions packages/wouter/types/location-hook.d.ts
Original file line number Diff line number Diff line change
@@ -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 = (
@@ -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
*/
Loading