diff --git a/packages/wouter-preact/src/preact-deps.js b/packages/wouter-preact/src/preact-deps.js index 7315083b..4488c4cc 100644 --- a/packages/wouter-preact/src/preact-deps.js +++ b/packages/wouter-preact/src/preact-deps.js @@ -11,7 +11,9 @@ export { useLayoutEffect as useIsomorphicLayoutEffect, useLayoutEffect as useInsertionEffect, useState, + useCallback, useContext, + useMemo, } from "preact/hooks"; // Copied from: diff --git a/packages/wouter-preact/types/location-hook.d.ts b/packages/wouter-preact/types/location-hook.d.ts index ab3ca90c..8bdfc76f 100644 --- a/packages/wouter-preact/types/location-hook.d.ts +++ b/packages/wouter-preact/types/location-hook.d.ts @@ -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 +) => [ + URLSearchParams, + ( + nextInit: + | URLSearchParamsInit + | ((prev: URLSearchParams) => URLSearchParamsInit), + ...args: Parameters[1]> extends [ + infer _, + ...infer Args + ] + ? Args + : never + ) => void +]; + /* * Utility types that operate on hook */ diff --git a/packages/wouter-preact/types/use-browser-location.d.ts b/packages/wouter-preact/types/use-browser-location.d.ts index 6485d76e..a6359f84 100644 --- a/packages/wouter-preact/types/use-browser-location.d.ts +++ b/packages/wouter-preact/types/use-browser-location.d.ts @@ -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: ( fn: () => S, ssrFn?: () => S @@ -12,6 +13,20 @@ export type BrowserSearchHook = (options?: { export const useSearch: BrowserSearchHook; +export type BrowserSearchParamsHook = ( + ...args: Parameters +) => [ + URLSearchParams, + ( + nextInit: + | URLSearchParamsInit + | ((prev: URLSearchParams) => URLSearchParamsInit), + options?: Parameters[1] + ) => void +]; + +export const useSearchParams: BrowserSearchParamsHook; + export const usePathname: (options?: { ssrPath?: Path }) => Path; export const useHistoryState: () => T; diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index d56e21ac..2d19192e 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -7,7 +7,9 @@ import { import { useRef, + useCallback, useContext, + useMemo, createContext, isValidElement, cloneElement, @@ -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, {}]; diff --git a/packages/wouter/src/react-deps.js b/packages/wouter/src/react-deps.js index efe935d7..c98a3c8b 100644 --- a/packages/wouter/src/react-deps.js +++ b/packages/wouter/src/react-deps.js @@ -11,7 +11,9 @@ const { export { useRef, useState, + useCallback, useContext, + useMemo, createContext, isValidElement, cloneElement, diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 22ffb8ec..2558560b 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -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 @@ -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 } = {}) => @@ -42,6 +59,7 @@ export const usePathname = ({ ssrPath } = {}) => ); const currentHistoryState = () => history.state; + export const useHistoryState = () => useLocationProperty(currentHistoryState, () => null); diff --git a/packages/wouter/test/use-browser-location.test.tsx b/packages/wouter/test/use-browser-location.test.tsx index e9ce9a99..c7002d03 100644 --- a/packages/wouter/test/use-browser-location.test.tsx +++ b/packages/wouter/test/use-browser-location.test.tsx @@ -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"); + }); +}); diff --git a/packages/wouter/test/use-search-params.test.tsx b/packages/wouter/test/use-search-params.test.tsx new file mode 100644 index 00000000..787eb88c --- /dev/null +++ b/packages/wouter/test/use-search-params.test.tsx @@ -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 ( + + {props.children} + + ); + }, + }); + + 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("как дела?"); +}); diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index 53f89bef..c86add2f 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -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; +export function useSearchParams< + H extends BaseSearchParamsHook = BrowserSearchParamsHook +>(): ReturnType; + export function useParams(): T extends string ? RouteParams : T extends undefined diff --git a/packages/wouter/types/location-hook.d.ts b/packages/wouter/types/location-hook.d.ts index ab3ca90c..8bdfc76f 100644 --- a/packages/wouter/types/location-hook.d.ts +++ b/packages/wouter/types/location-hook.d.ts @@ -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 +) => [ + URLSearchParams, + ( + nextInit: + | URLSearchParamsInit + | ((prev: URLSearchParams) => URLSearchParamsInit), + ...args: Parameters[1]> extends [ + infer _, + ...infer Args + ] + ? Args + : never + ) => void +]; + /* * Utility types that operate on hook */ diff --git a/packages/wouter/types/use-browser-location.d.ts b/packages/wouter/types/use-browser-location.d.ts index 6485d76e..a6359f84 100644 --- a/packages/wouter/types/use-browser-location.d.ts +++ b/packages/wouter/types/use-browser-location.d.ts @@ -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: ( fn: () => S, ssrFn?: () => S @@ -12,6 +13,20 @@ export type BrowserSearchHook = (options?: { export const useSearch: BrowserSearchHook; +export type BrowserSearchParamsHook = ( + ...args: Parameters +) => [ + URLSearchParams, + ( + nextInit: + | URLSearchParamsInit + | ((prev: URLSearchParams) => URLSearchParamsInit), + options?: Parameters[1] + ) => void +]; + +export const useSearchParams: BrowserSearchParamsHook; + export const usePathname: (options?: { ssrPath?: Path }) => Path; export const useHistoryState: () => T;