From f578e486910b24c935abf6278d0a4c5de23eae8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B9=A4=E4=BB=99?= Date: Wed, 22 Jan 2025 14:21:40 +0800 Subject: [PATCH] feat(useSearchParams): new hook --- packages/wouter-preact/src/preact-deps.js | 1 + packages/wouter/src/index.js | 20 +++++++ packages/wouter/src/react-deps.js | 1 + .../wouter/test/use-search-params.test.tsx | 60 +++++++++++++++++++ packages/wouter/types/index.d.ts | 8 +++ 5 files changed, 90 insertions(+) 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..3753c661 100644 --- a/packages/wouter-preact/src/preact-deps.js +++ b/packages/wouter-preact/src/preact-deps.js @@ -7,6 +7,7 @@ export { Fragment, } from "preact"; export { + useMemo, useRef, useLayoutEffect as useIsomorphicLayoutEffect, useLayoutEffect as useInsertionEffect, diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 795abed2..aa6abec0 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -16,6 +16,7 @@ import { forwardRef, useIsomorphicLayoutEffect, useEvent, + useMemo, } from "./react-deps.js"; import { absolutePath, relativePath, sanitizeSearch } from "./paths.js"; @@ -202,6 +203,25 @@ const useCachedParams = (value) => { return (prev.current = curr); }; +export function useSearchParams() { + const [location, navigate] = useLocation(); + + const search = useSearch(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + // cached value before next render, so you can call setSearchParams multiple times + let tempSearchParams = searchParams; + + const setSearchParams = useEvent((nextInit, options) => { + tempSearchParams = new URLSearchParams( + typeof nextInit === 'function' ? nextInit(tempSearchParams) : nextInit, + ); + navigate(location + '?' + tempSearchParams, options); + }) + + return [searchParams, setSearchParams]; +} + export const Route = ({ path, nest, match, ...renderProps }) => { const router = useRouter(); const [location] = useLocationFromRouter(router); diff --git a/packages/wouter/src/react-deps.js b/packages/wouter/src/react-deps.js index 61b560b6..573fbd1d 100644 --- a/packages/wouter/src/react-deps.js +++ b/packages/wouter/src/react-deps.js @@ -5,6 +5,7 @@ import * as React from "react"; const useBuiltinInsertionEffect = React["useInsertion" + "Effect"]; export { + useMemo, useRef, useState, useContext, 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..3795aa8c --- /dev/null +++ b/packages/wouter/test/use-search-params.test.tsx @@ -0,0 +1,60 @@ +import { renderHook, act } from "@testing-library/react"; +import { useSearchParams, Router } from "wouter"; +import { navigate } from "wouter/use-browser-location"; +import { it, expect, beforeEach } from "vitest"; + +beforeEach(() => history.replaceState(null, "", "/")); + +it("can return browser search params", () => { + history.replaceState(null, "", "/users?active=true"); + const { result } = renderHook(() => useSearchParams()); + + expect(result.current[0].get('active')).toBe("true"); +}); + +it("can change browser search params", () => { + history.replaceState(null, "", "/users?active=true"); + const { result } = renderHook(() => useSearchParams()); + + expect(result.current[0].get('active')).toBe("true"); + + act(() => result.current[1](prev => { + prev.set('active', 'false'); + return prev; + })); + + expect(result.current[0].get('active')).toBe("false"); +}); + +it("can be customized in the Router", () => { + const customSearchHook = ({ customOption = "unused" }) => "none"; + + const { result } = renderHook(() => useSearchParams(), { + wrapper: (props) => { + return {props.children}; + }, + }); + + expect(Array.from(result.current[0].keys())).toEqual(["none"]); +}); + +it("unescapes search string", () => { + const { result: searchResult } = renderHook(() => useSearchParams()); + + expect(Array.from(searchResult.current[0].keys()).length).toBe(0); + + act(() => navigate("/?nonce=not Found&country=საქართველო")); + expect(searchResult.current[0].get('nonce')).toBe("not Found"); + expect(searchResult.current[0].get('country')).toBe("საქართველო"); + + // question marks + act(() => navigate("/?вопрос=как дела?")); + expect(searchResult.current[0].get('вопрос')).toBe("как дела?"); +}); + +it("is safe against parameter injection", () => { + history.replaceState(null, "", "/?search=foo%26parameter_injection%3Dbar"); + const { result } = renderHook(() => useSearchParams()); + + expect(result.current[0].get('search')).toBe("foo¶meter_injection=bar"); +}); diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index e1d17048..0f8f481c 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -185,6 +185,14 @@ export function useSearch< H extends BaseSearchHook = BrowserSearchHook >(): ReturnType; +export type URLSearchParamsInit = ConstructorParameters[0]; +export type SetSearchParams = ( + nextInit: URLSearchParamsInit | ((prev: URLSearchParams) => URLSearchParamsInit), + options?: { replace?: boolean; state?: any }, +) => void; + +export function useSearchParams(): [URLSearchParams, SetSearchParams]; + export function useParams(): T extends string ? StringRouteParams : T extends undefined