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..f58e8bb8 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -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, {}]; diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 22ffb8ec..20cb2c0f 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 { 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); diff --git a/packages/wouter/src/use-hash-location.js b/packages/wouter/src/use-hash-location.js index eb8fdc34..5a19035f 100644 --- a/packages/wouter/src/use-hash-location.js +++ b/packages/wouter/src/use-hash-location.js @@ -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, + } ); }; 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;