From daec7b0a124a441c7370af420dac58773b5d3a04 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Thu, 30 Nov 2023 22:43:41 +0800 Subject: [PATCH 1/4] feat: add useSearchParams hook --- packages/wouter-preact/src/preact-deps.js | 2 + .../wouter-preact/types/location-hook.d.ts | 21 ++++ .../types/use-browser-location.d.ts | 17 +++- packages/wouter/src/index.js | 22 +++++ packages/wouter/src/react-deps.js | 2 + packages/wouter/src/use-browser-location.js | 20 +++- .../wouter/test/use-browser-location.test.tsx | 97 +++++++++++++++++++ .../wouter/test/use-search-params.test.tsx | 54 +++++++++++ packages/wouter/types/index.d.ts | 6 ++ packages/wouter/types/location-hook.d.ts | 21 ++++ .../wouter/types/use-browser-location.d.ts | 17 +++- 11 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 packages/wouter/test/use-search-params.test.tsx 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; From 3d4b2b9d12a6fd6a1cf5040fa476fba7ecfa9fda Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Thu, 30 Nov 2023 22:44:10 +0800 Subject: [PATCH 2/4] refactor(hash-location): use navigate from use-browser-location --- packages/wouter/src/use-hash-location.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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, + } ); }; From 1823ef679f1c1c484b6e6c9fbb5342b015753f72 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Sat, 2 Dec 2023 23:13:14 +0800 Subject: [PATCH 3/4] fix(useSearchParams): stable setSearchParams --- packages/wouter/src/index.js | 14 ++++++--- packages/wouter/src/use-browser-location.js | 32 +++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 2d19192e..4b461076 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -83,19 +83,25 @@ export const useSearchParams = () => { const [, navigate] = useLocationFromRouter(router); const search = unescape(stripQm(router.searchHook(router))); - const searchParams = useMemo(() => new URLSearchParams(search), [search]); + const searchParamsRef = useRef(new URLSearchParams(search)); + searchParamsRef.current = useMemo( + () => new URLSearchParams(search), + [search] + ); const setSearchParams = useCallback( (nextInit, navOpts) => { const newSearchParams = new URLSearchParams( - typeof nextInit === "function" ? nextInit(searchParams) : nextInit + typeof nextInit === "function" + ? nextInit(searchParamsRef.current) + : nextInit ); navigate("?" + newSearchParams, navOpts); }, - [navigate, searchParams] + [navigate] ); - return [searchParams, setSearchParams]; + return [searchParamsRef.current, setSearchParams]; }; const matchRoute = (parser, route, path, loose) => { diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 2558560b..55f89064 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -1,4 +1,9 @@ -import { useCallback, useMemo, useSyncExternalStore } from "./react-deps.js"; +import { + useRef, + useCallback, + useMemo, + useSyncExternalStore, +} from "./react-deps.js"; /** * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History @@ -35,19 +40,22 @@ export const useSearch = ({ 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] + const searchParamsRef = useRef(new URLSearchParams(search)); + searchParamsRef.current = useMemo( + () => new URLSearchParams(search), + [search] ); - return [searchParams, setSearchParams]; + const setSearchParams = useCallback((nextInit, navOpts) => { + const newSearchParams = new URLSearchParams( + typeof nextInit === "function" + ? nextInit(searchParamsRef.current) + : nextInit + ); + navigate("?" + newSearchParams, navOpts); + }, []); + + return [searchParamsRef.current, setSearchParams]; }; const currentPathname = () => location.pathname; From 972b05b5d1523bf7e5c7e12d692f4f1133f78d5f Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Tue, 19 Dec 2023 15:44:06 +0800 Subject: [PATCH 4/4] fix(useSearchParams): use useEvent and remove memo --- packages/wouter-preact/src/preact-deps.js | 2 -- packages/wouter/src/index.js | 31 +++++++-------------- packages/wouter/src/react-deps.js | 2 -- packages/wouter/src/use-browser-location.js | 23 ++++----------- 4 files changed, 16 insertions(+), 42 deletions(-) diff --git a/packages/wouter-preact/src/preact-deps.js b/packages/wouter-preact/src/preact-deps.js index 4488c4cc..7315083b 100644 --- a/packages/wouter-preact/src/preact-deps.js +++ b/packages/wouter-preact/src/preact-deps.js @@ -11,9 +11,7 @@ export { useLayoutEffect as useIsomorphicLayoutEffect, useLayoutEffect as useInsertionEffect, useState, - useCallback, useContext, - useMemo, } from "preact/hooks"; // Copied from: diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 4b461076..f58e8bb8 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -7,9 +7,7 @@ import { import { useRef, - useCallback, useContext, - useMemo, createContext, isValidElement, cloneElement, @@ -83,25 +81,16 @@ export const useSearchParams = () => { const [, navigate] = useLocationFromRouter(router); const search = unescape(stripQm(router.searchHook(router))); - const searchParamsRef = useRef(new URLSearchParams(search)); - searchParamsRef.current = useMemo( - () => new URLSearchParams(search), - [search] - ); - - const setSearchParams = useCallback( - (nextInit, navOpts) => { - const newSearchParams = new URLSearchParams( - typeof nextInit === "function" - ? nextInit(searchParamsRef.current) - : nextInit - ); - navigate("?" + newSearchParams, navOpts); - }, - [navigate] - ); - - return [searchParamsRef.current, setSearchParams]; + 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) => { diff --git a/packages/wouter/src/react-deps.js b/packages/wouter/src/react-deps.js index c98a3c8b..efe935d7 100644 --- a/packages/wouter/src/react-deps.js +++ b/packages/wouter/src/react-deps.js @@ -11,9 +11,7 @@ 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 55f89064..20cb2c0f 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -1,9 +1,4 @@ -import { - useRef, - useCallback, - useMemo, - 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 @@ -40,22 +35,16 @@ export const useSearch = ({ ssrSearch = "" } = {}) => export const useSearchParams = ({ ssrSearch = "" } = {}) => { const search = useSearch({ ssrSearch }); - const searchParamsRef = useRef(new URLSearchParams(search)); - searchParamsRef.current = useMemo( - () => new URLSearchParams(search), - [search] - ); + const searchParams = new URLSearchParams(search); - const setSearchParams = useCallback((nextInit, navOpts) => { + const setSearchParams = useEvent((nextInit, navOpts) => { const newSearchParams = new URLSearchParams( - typeof nextInit === "function" - ? nextInit(searchParamsRef.current) - : nextInit + typeof nextInit === "function" ? nextInit(searchParams) : nextInit ); navigate("?" + newSearchParams, navOpts); - }, []); + }); - return [searchParamsRef.current, setSearchParams]; + return [searchParams, setSearchParams]; }; const currentPathname = () => location.pathname;