@@ -4,9 +4,20 @@ import {
44 useSearchParams as useNextSearchParams
55} from "next/navigation" ;
66import { z } from "zod" ;
7+ import { useCallback , useEffect , useMemo , useRef } from "react" ;
78
8- import { RouteBuilder } from "./makeRoute" ;
9+ import type { RouteBuilder } from "./makeRoute" ;
10+ import {
11+ ZodArray ,
12+ type ZodTypeAny ,
13+ ZodOptional ,
14+ ZodNullable ,
15+ ZodDefault ,
16+ ZodEffects
17+ } from "zod" ;
918
19+ import debounce from "lodash.debounce" ;
20+ import throttle from "lodash.throttle" ;
1021const emptySchema = z . object ( { } ) ;
1122
1223type PushOptions = Parameters < ReturnType < typeof useRouter > [ "push" ] > [ 1 ] ;
@@ -15,59 +26,265 @@ export function usePush<
1526 Params extends z . ZodSchema ,
1627 Search extends z . ZodSchema = typeof emptySchema
1728> ( builder : RouteBuilder < Params , Search > ) {
18- const { push } = useRouter ( ) ;
29+ const router = useRouter ( ) ;
1930 return (
2031 p : z . input < Params > ,
2132 search ?: z . input < Search > ,
2233 options ?: PushOptions
2334 ) => {
24- push ( builder ( p , search ) , options ) ;
35+ router . push ( builder ( p , search ) , options ) ;
2536 } ;
2637}
2738
39+ type UseParamsConfig = {
40+ partial ?: boolean ;
41+ } ;
42+ const defaultUseParamsConfig = {
43+ partial : false
44+ } as const satisfies UseParamsConfig ;
45+
2846export function useParams <
29- Params extends z . ZodSchema ,
30- Search extends z . ZodSchema = typeof emptySchema
31- > ( builder : RouteBuilder < Params , Search > ) : z . output < Params > {
32- const res = builder . paramsSchema . safeParse ( useNextParams ( ) ) ;
47+ Params extends z . AnyZodObject ,
48+ Search extends z . AnyZodObject = typeof emptySchema ,
49+ TConfig extends UseParamsConfig = typeof defaultUseParamsConfig
50+ > (
51+ builder : RouteBuilder < Params , Search > ,
52+ _config ?: TConfig
53+ ) : TConfig [ "partial" ] extends true
54+ ? Partial < z . output < Params > >
55+ : z . output < Params > {
56+ const nextParams = useNextParams ( ) ;
57+ const shouldUsePartial = _config ?. partial ?? defaultUseParamsConfig . partial ;
58+ const targetSchema = shouldUsePartial
59+ ? builder . paramsSchema . partial ( )
60+ : builder . paramsSchema ;
61+ const isSafeParsedWithPartial = builder . paramsSchema
62+ . partial ( )
63+ . safeParse ( nextParams ) . success ;
64+ const res = targetSchema . safeParse ( nextParams ) ;
3365 if ( ! res . success ) {
3466 throw new Error (
35- `Invalid route params for route ${ builder . routeName } : ${ res . error . message } `
67+ [
68+ `Invalid route params for route ${ builder . routeName } : ${ res . error . message } .` ,
69+ `${ isSafeParsedWithPartial ? "ℹ️ If you wanted to use partial params, pass {partial:true} as second parameter." : "" } `
70+ ]
71+ . filter ( Boolean )
72+ . join ( " " )
3673 ) ;
3774 }
3875 return res . data ;
3976}
4077
4178export function useSearchParams <
42- Params extends z . ZodSchema ,
43- Search extends z . ZodSchema = typeof emptySchema
44- > ( builder : RouteBuilder < Params , Search > ) : z . output < Search > {
45- const res = builder . searchSchema ! . safeParse (
46- convertURLSearchParamsToObject ( useNextSearchParams ( ) )
79+ Params extends z . AnyZodObject ,
80+ Search extends z . AnyZodObject = typeof emptySchema
81+ > (
82+ builder : RouteBuilder < Params , Search > ,
83+ config ?: UseParamsConfig
84+ ) : z . output < Search > {
85+ const searchSchema = useMemo (
86+ ( ) => ( config ?. partial ? builder . searchSchema : builder . searchSchema ) ,
87+ [ config ?. partial , builder . searchSchema ]
88+ ) ;
89+
90+ const rawParams = convertURLSearchParamsToObject (
91+ useNextSearchParams ( ) ,
92+ searchSchema
4793 ) ;
94+
95+ const res = builder . searchSchema . safeParse ( rawParams ) ;
4896 if ( ! res . success ) {
4997 throw new Error (
5098 `Invalid search params for route ${ builder . routeName } : ${ res . error . message } `
5199 ) ;
52100 }
53101 return res . data ;
54102}
103+ export function useSearchParamsState <
104+ Params extends z . AnyZodObject ,
105+ Search extends z . AnyZodObject = typeof emptySchema
106+ > ( builder : RouteBuilder < Params , Search > , config ?: UseParamsConfig ) {
107+ const _searchParams = useSearchParams ( builder , config ) ;
108+ const push = usePush ( builder ) ;
109+ const params = useParams ( builder , config ) ;
110+ const searchParams = useMemo ( ( ) => _searchParams , [ _searchParams ] ) ;
111+
112+ /**
113+ * @param newValues - The new values to set. If you want to unset a value, pass `null`. If you want to dynamically set or not set a value, use `undefined` because `undefined` values will be omitted.
114+ */
115+ const setSearchParams = useCallback (
116+ (
117+ newValues : Partial < {
118+ [ K in keyof z . output < Search > ] : z . output < Search > [ K ] | null | undefined ;
119+ } >
120+ ) => {
121+ if ( Object . keys ( newValues ) . every ( ( val ) => val === undefined ) ) {
122+ return ;
123+ }
124+ const updatedValues : Partial < z . output < Search > > = builder . searchSchema
125+ . partial ( )
126+ . parse ( { ..._searchParams , ...newValues } ) ;
127+ for ( const [ key , value ] of Object . entries ( updatedValues ) ) {
128+ if ( value === null ) {
129+ delete updatedValues [ key ] ;
130+ }
131+
132+ if (
133+ value === "" &&
134+ builder . searchSchema . shape [ key ] instanceof ZodOptional
135+ ) {
136+ delete updatedValues [ key ] ;
137+ }
138+ }
139+
140+ push ( params , updatedValues ) ;
141+ } ,
142+ [ _searchParams , push , params , builder . searchSchema ]
143+ ) ;
144+
145+ const debouncedSetSearchParams = useDebounceCallback (
146+ setSearchParams
147+ ) as typeof setSearchParams ;
148+ const resetAllValues = useCallback ( ( ) => {
149+ push ( params , builder . searchSchema . parse ( { } ) as z . output < Search > ) ;
150+ } , [ push , params , builder ] ) ;
151+
152+ return {
153+ searchParams,
154+ setSearchParams,
155+ resetAllValues,
156+ debouncedSetSearchParams
157+ } as const ;
158+ }
159+ export type DebounceOptions = {
160+ leading ?: boolean ;
161+ trailing ?: boolean ;
162+ maxWait ?: number ;
163+ delay ?: number ;
164+ // debounceOrThrottle?: "debounce" | "throttle";
165+ } ;
166+ type DebounceCallbackParam = DebounceOptions & {
167+ debounceOrThrottle ?: "debounce" | "throttle" ;
168+ } ;
169+
170+ type ControlFunctions = {
171+ cancel : ( ) => void ;
172+ flush : ( ) => void ;
173+ isPending : ( ) => boolean ;
174+ } ;
175+
176+ export type DebouncedState < T extends ( ...args : any ) => ReturnType < T > > = ( (
177+ ...args : Parameters < T >
178+ ) => ReturnType < T > | undefined ) &
179+ ControlFunctions ;
180+
181+ const defaultDebounceCallbackParam : DebounceCallbackParam = {
182+ delay : 500 ,
183+ debounceOrThrottle : "debounce"
184+ } ;
185+ export function useDebounceCallback < T extends ( ...args : any ) => ReturnType < T > > (
186+ func : T ,
187+ _options : DebounceCallbackParam = { }
188+ ) : DebouncedState < T > {
189+ const options = useMemo (
190+ ( ) => ( { ...defaultDebounceCallbackParam , ..._options } ) ,
191+ [ _options ]
192+ ) ;
193+ const debounceOrThrottle = useMemo (
194+ ( ) => options . debounceOrThrottle ,
195+ [ options . debounceOrThrottle ]
196+ ) ;
197+ const delay = useMemo ( ( ) => options . delay , [ options . delay ] ) ;
198+ const debounceOptions = useMemo ( ( ) => {
199+ return {
200+ ...options ,
201+ leading : options . leading ?? false ,
202+ debounceOrThrottle,
203+ delay
204+ } ;
205+ } , [ options , debounceOrThrottle , delay ] ) ;
206+ const debouncedFunc = useRef < ReturnType < typeof debounce > > ( undefined ) ;
207+
208+ useEffect ( ( ) => {
209+ if ( debouncedFunc . current ) {
210+ debouncedFunc . current . cancel ( ) ;
211+ }
212+ } ) ;
213+
214+ const debounced = useMemo ( ( ) => {
215+ const debouncedFuncInstance =
216+ debounceOrThrottle === "throttle"
217+ ? throttle ( func , delay , debounceOptions )
218+ : debounce ( func , delay , debounceOptions ) ;
219+
220+ const wrappedFunc : DebouncedState < T > = ( ...args : Parameters < T > ) => {
221+ return debouncedFuncInstance ( ...args ) ;
222+ } ;
223+
224+ wrappedFunc . cancel = ( ) => {
225+ debouncedFuncInstance . cancel ( ) ;
226+ } ;
227+
228+ wrappedFunc . isPending = ( ) => {
229+ return ! ! debouncedFunc . current ;
230+ } ;
231+
232+ wrappedFunc . flush = ( ) => {
233+ return debouncedFuncInstance . flush ( ) ;
234+ } ;
235+
236+ return wrappedFunc ;
237+ } , [ debounceOrThrottle , func , delay , debounceOptions ] ) ;
238+
239+ // Update the debounced function ref whenever func, wait, or options change
240+ useEffect ( ( ) => {
241+ debouncedFunc . current = debounce ( func , delay , debounceOptions ) ;
242+ } , [ func , delay , debounceOptions ] ) ;
243+
244+ return debounced ;
245+ }
55246
56247function convertURLSearchParamsToObject (
57- params : Readonly < URLSearchParams > | null
248+ params : Readonly < URLSearchParams > | null ,
249+ schema : z . ZodTypeAny
58250) : Record < string , string | string [ ] > {
59251 if ( ! params ) {
60252 return { } ;
61253 }
62254
63255 const obj : Record < string , string | string [ ] > = { } ;
64- // @ts -ignore
65- for ( const [ key , value ] of params . entries ( ) ) {
66- if ( params . getAll ( key ) . length > 1 ) {
67- obj [ key ] = params . getAll ( key ) ;
256+ const arrayKeys = getArrayKeysFromZodSchema ( schema ) ;
257+ const uniqueKeys = [ ...new Set ( params . keys ( ) ) ] ;
258+ for ( const key of uniqueKeys ) {
259+ if ( arrayKeys . includes ( key ) ) {
260+ obj [ key ] = params . getAll ( key ) . filter ( Boolean ) ;
68261 } else {
69- obj [ key ] = value ;
262+ const value = params . get ( key ) ;
263+ if ( value ) {
264+ obj [ key ] = value ;
265+ }
70266 }
71267 }
72268 return obj ;
73269}
270+ export function getArrayKeysFromZodSchema ( schema : z . ZodTypeAny ) : string [ ] {
271+ if ( ! ( schema instanceof z . ZodObject ) ) return [ ] ;
272+ return Object . entries ( schema . shape ) . flatMap ( ( [ key , value ] ) => {
273+ const unwrapped = unwrapZodType (
274+ value as Parameters < typeof unwrapZodType > [ 0 ]
275+ ) ;
276+ return unwrapped instanceof ZodArray ? [ key ] : [ ] ;
277+ } ) ;
278+ }
279+ function unwrapZodType ( schema : ZodTypeAny ) : ZodTypeAny {
280+ const unwrappableInstances = [
281+ ZodOptional ,
282+ ZodNullable ,
283+ ZodDefault ,
284+ ZodEffects
285+ ] ;
286+ if ( unwrappableInstances . some ( ( instance ) => schema instanceof instance ) ) {
287+ return unwrapZodType ( schema . _def . innerType || schema . _def . schema ) ;
288+ }
289+ return schema ;
290+ }
0 commit comments