From a41c83fa9b919439afce22eaae86615064afe896 Mon Sep 17 00:00:00 2001 From: DJanocha <50969285+DJanocha@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:12:35 +0200 Subject: [PATCH 1/3] added use-search-params-state with example (examples/with-use-search-params-state) --- assets/nextjs/hooks.ts | 255 +- assets/nextjs/makeRoute.tsx | 16 +- assets/shared/info.ts.template | 3 +- .../with-use-search-params-state/.env.example | 14 + .../with-use-search-params-state/.gitignore | 46 + .../with-use-search-params-state/.npmrc | 2 + .../with-use-search-params-state/FEATURES.md | 347 + .../with-use-search-params-state/README.md | 29 + .../components.json | 21 + .../declarative-routing.config.json | 9 + .../eslint.config.js | 48 + .../next.config.js | 17 + .../with-use-search-params-state/package.json | 109 + .../pnpm-lock.yaml | 6035 ++++++++ .../postcss.config.js | 5 + .../prettier.config.js | 4 + .../public/favicon.ico | Bin 0 -> 23600 bytes .../src/app/api/trpc/[trpc]/route.info.ts | 10 + .../src/app/api/trpc/[trpc]/route.ts | 34 + .../src/app/layout.tsx | 28 + .../src/app/page.info.ts | 9 + .../src/app/page.tsx | 6 + .../src/app/pokemon/[id]/page.info.ts | 11 + .../src/app/pokemon/[id]/page.tsx | 95 + .../src/app/pokemon/layout.tsx | 253 + .../src/app/pokemon/page.info.ts | 9 + .../src/app/pokemon/page.tsx | 10 + .../src/components/data-table.tsx | 533 + .../src/components/pokemon-type-badge.tsx | 24 + .../src/components/ui/accordion.tsx | 66 + .../src/components/ui/alert-dialog.tsx | 157 + .../src/components/ui/alert.tsx | 66 + .../src/components/ui/aspect-ratio.tsx | 11 + .../src/components/ui/avatar.tsx | 53 + .../src/components/ui/badge.tsx | 46 + .../src/components/ui/breadcrumb.tsx | 109 + .../src/components/ui/button.tsx | 59 + .../src/components/ui/calendar.tsx | 214 + .../src/components/ui/card.tsx | 92 + .../src/components/ui/carousel.tsx | 241 + .../src/components/ui/chart.tsx | 353 + .../src/components/ui/checkbox.tsx | 32 + .../src/components/ui/collapsible.tsx | 33 + .../src/components/ui/command.tsx | 184 + .../src/components/ui/context-menu.tsx | 252 + .../src/components/ui/dialog.tsx | 143 + .../src/components/ui/drawer.tsx | 135 + .../src/components/ui/dropdown-menu.tsx | 257 + .../src/components/ui/form.tsx | 167 + .../src/components/ui/hover-card.tsx | 44 + .../src/components/ui/input-otp.tsx | 77 + .../src/components/ui/input.tsx | 21 + .../src/components/ui/label.tsx | 24 + .../src/components/ui/menubar.tsx | 276 + .../src/components/ui/navigation-menu.tsx | 168 + .../src/components/ui/pagination.tsx | 127 + .../src/components/ui/popover.tsx | 48 + .../src/components/ui/progress.tsx | 31 + .../src/components/ui/radio-group.tsx | 45 + .../src/components/ui/resizable.tsx | 56 + .../src/components/ui/scroll-area.tsx | 58 + .../src/components/ui/select.tsx | 185 + .../src/components/ui/separator.tsx | 28 + .../src/components/ui/sheet.tsx | 139 + .../src/components/ui/sidebar.tsx | 726 + .../src/components/ui/skeleton.tsx | 13 + .../src/components/ui/slider.tsx | 63 + .../src/components/ui/sonner.tsx | 25 + .../src/components/ui/switch.tsx | 31 + .../src/components/ui/table.tsx | 124 + .../src/components/ui/tabs.tsx | 66 + .../src/components/ui/textarea.tsx | 18 + .../src/components/ui/toggle-group.tsx | 73 + .../src/components/ui/toggle.tsx | 47 + .../src/components/ui/tooltip.tsx | 61 + .../with-use-search-params-state/src/env.js | 40 + .../src/global-types.d.ts | 13 + .../src/hooks/use-debounced-callback.ts | 85 + .../src/hooks/use-intersection-observer.ts | 64 + .../src/hooks/use-mobile.ts | 19 + .../src/hooks/use-unmount.ts | 14 + .../src/lib/utils.ts | 6 + .../src/pokeapi-data/pokemon.ts | 4020 ++++++ .../src/pokeapi-data/type-colors.ts | 207 + .../type-name-to-pokemon-mapping.ts | 8189 +++++++++++ .../src/pokeapi-data/type.ts | 109 + .../src/routes/README.md | 124 + .../src/routes/hooks.ts | 298 + .../src/routes/index.ts | 42 + .../src/routes/makeRoute.tsx | 482 + .../src/routes/openapi.template.ts | 27 + .../src/routes/openapi.ts | 27 + .../src/routes/utils.ts | 184 + .../src/schemas/api/pokemon/list.ts | 22 + .../src/schemas/pagination.ts | 80 + .../src/schemas/pokemon.ts | 137 + .../src/schemas/routes/pokemon-search.ts | 11 + .../src/server/api/root.ts | 25 + .../src/server/api/routers/pokemon.ts | 96 + .../src/server/api/routers/post.ts | 40 + .../src/server/api/trpc.ts | 103 + .../src/styles/globals.css | 125 + .../src/trpc/query-client.ts | 25 + .../src/trpc/react.tsx | 78 + .../src/trpc/server.ts | 30 + .../with-use-search-params-state/src/types.ts | 1 + .../tsconfig.json | 42 + package.json | 3 +- pnpm-lock.yaml | 11432 ++++++++++++++-- pnpm-workspace.yaml | 5 + src/nextjs/init.ts | 8 +- 111 files changed, 38581 insertions(+), 858 deletions(-) create mode 100644 examples/nextjs/with-use-search-params-state/.env.example create mode 100644 examples/nextjs/with-use-search-params-state/.gitignore create mode 100644 examples/nextjs/with-use-search-params-state/.npmrc create mode 100644 examples/nextjs/with-use-search-params-state/FEATURES.md create mode 100644 examples/nextjs/with-use-search-params-state/README.md create mode 100644 examples/nextjs/with-use-search-params-state/components.json create mode 100644 examples/nextjs/with-use-search-params-state/declarative-routing.config.json create mode 100644 examples/nextjs/with-use-search-params-state/eslint.config.js create mode 100644 examples/nextjs/with-use-search-params-state/next.config.js create mode 100644 examples/nextjs/with-use-search-params-state/package.json create mode 100644 examples/nextjs/with-use-search-params-state/pnpm-lock.yaml create mode 100644 examples/nextjs/with-use-search-params-state/postcss.config.js create mode 100644 examples/nextjs/with-use-search-params-state/prettier.config.js create mode 100644 examples/nextjs/with-use-search-params-state/public/favicon.ico create mode 100644 examples/nextjs/with-use-search-params-state/src/app/api/trpc/[trpc]/route.info.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/app/api/trpc/[trpc]/route.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/app/layout.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/app/page.info.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/app/page.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/app/pokemon/[id]/page.info.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/app/pokemon/[id]/page.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/app/pokemon/layout.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/app/pokemon/page.info.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/app/pokemon/page.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/data-table.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/pokemon-type-badge.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/accordion.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/alert-dialog.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/alert.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/aspect-ratio.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/avatar.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/badge.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/breadcrumb.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/button.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/calendar.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/card.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/carousel.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/chart.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/checkbox.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/collapsible.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/command.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/context-menu.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/dialog.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/drawer.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/dropdown-menu.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/form.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/hover-card.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/input-otp.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/input.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/label.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/menubar.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/navigation-menu.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/pagination.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/popover.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/progress.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/radio-group.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/resizable.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/scroll-area.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/select.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/separator.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/sheet.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/sidebar.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/skeleton.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/slider.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/sonner.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/switch.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/table.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/tabs.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/textarea.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/toggle-group.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/toggle.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/components/ui/tooltip.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/env.js create mode 100644 examples/nextjs/with-use-search-params-state/src/global-types.d.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/hooks/use-debounced-callback.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/hooks/use-intersection-observer.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/hooks/use-mobile.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/hooks/use-unmount.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/lib/utils.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/pokeapi-data/pokemon.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/pokeapi-data/type-colors.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/pokeapi-data/type-name-to-pokemon-mapping.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/pokeapi-data/type.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/README.md create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/hooks.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/index.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/makeRoute.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/openapi.template.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/openapi.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/routes/utils.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/schemas/api/pokemon/list.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/schemas/pagination.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/schemas/pokemon.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/schemas/routes/pokemon-search.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/server/api/root.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/server/api/routers/pokemon.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/server/api/routers/post.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/server/api/trpc.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/styles/globals.css create mode 100644 examples/nextjs/with-use-search-params-state/src/trpc/query-client.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/trpc/react.tsx create mode 100644 examples/nextjs/with-use-search-params-state/src/trpc/server.ts create mode 100644 examples/nextjs/with-use-search-params-state/src/types.ts create mode 100644 examples/nextjs/with-use-search-params-state/tsconfig.json create mode 100644 pnpm-workspace.yaml diff --git a/assets/nextjs/hooks.ts b/assets/nextjs/hooks.ts index 14b34b3..846b701 100644 --- a/assets/nextjs/hooks.ts +++ b/assets/nextjs/hooks.ts @@ -4,9 +4,20 @@ import { useSearchParams as useNextSearchParams } from "next/navigation"; import { z } from "zod"; +import { useCallback, useEffect, useMemo, useRef } from "react"; -import { RouteBuilder } from "./makeRoute"; +import type { RouteBuilder } from "./makeRoute"; +import { + ZodArray, + type ZodTypeAny, + ZodOptional, + ZodNullable, + ZodDefault, + ZodEffects +} from "zod"; +import debounce from "lodash.debounce"; +import throttle from "lodash.throttle"; const emptySchema = z.object({}); type PushOptions = Parameters["push"]>[1]; @@ -15,36 +26,73 @@ export function usePush< Params extends z.ZodSchema, Search extends z.ZodSchema = typeof emptySchema >(builder: RouteBuilder) { - const { push } = useRouter(); + const router = useRouter(); return ( p: z.input, search?: z.input, options?: PushOptions ) => { - push(builder(p, search), options); + router.push(builder(p, search), options); }; } +type UseParamsConfig = { + partial?: boolean; +}; +const defaultUseParamsConfig = { + partial: false +} as const satisfies UseParamsConfig; + export function useParams< - Params extends z.ZodSchema, - Search extends z.ZodSchema = typeof emptySchema ->(builder: RouteBuilder): z.output { - const res = builder.paramsSchema.safeParse(useNextParams()); + Params extends z.AnyZodObject, + Search extends z.AnyZodObject = typeof emptySchema, + TConfig extends UseParamsConfig = typeof defaultUseParamsConfig +>( + builder: RouteBuilder, + _config?: TConfig +): TConfig["partial"] extends true + ? Partial> + : z.output { + const nextParams = useNextParams(); + const shouldUsePartial = _config?.partial ?? defaultUseParamsConfig.partial; + const targetSchema = shouldUsePartial + ? builder.paramsSchema.partial() + : builder.paramsSchema; + const isSafeParsedWithPartial = builder.paramsSchema + .partial() + .safeParse(nextParams).success; + const res = targetSchema.safeParse(nextParams); if (!res.success) { throw new Error( - `Invalid route params for route ${builder.routeName}: ${res.error.message}` + [ + `Invalid route params for route ${builder.routeName}: ${res.error.message}.`, + `${isSafeParsedWithPartial ? "â„šī¸ If you wanted to use partial params, pass {partial:true} as second parameter." : ""}` + ] + .filter(Boolean) + .join(" ") ); } return res.data; } export function useSearchParams< - Params extends z.ZodSchema, - Search extends z.ZodSchema = typeof emptySchema ->(builder: RouteBuilder): z.output { - const res = builder.searchSchema!.safeParse( - convertURLSearchParamsToObject(useNextSearchParams()) + Params extends z.AnyZodObject, + Search extends z.AnyZodObject = typeof emptySchema +>( + builder: RouteBuilder, + config?: UseParamsConfig +): z.output { + const searchSchema = useMemo( + () => (config?.partial ? builder.searchSchema : builder.searchSchema), + [config?.partial, builder.searchSchema] + ); + + const rawParams = convertURLSearchParamsToObject( + useNextSearchParams(), + searchSchema ); + + const res = builder.searchSchema.safeParse(rawParams); if (!res.success) { throw new Error( `Invalid search params for route ${builder.routeName}: ${res.error.message}` @@ -52,22 +100,191 @@ export function useSearchParams< } return res.data; } +export function useSearchParamsState< + Params extends z.AnyZodObject, + Search extends z.AnyZodObject = typeof emptySchema +>(builder: RouteBuilder, config?: UseParamsConfig) { + const _searchParams = useSearchParams(builder, config); + const push = usePush(builder); + const params = useParams(builder, config); + const searchParams = useMemo(() => _searchParams, [_searchParams]); + + /** + * @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. + */ + const setSearchParams = useCallback( + ( + newValues: Partial<{ + [K in keyof z.output]: z.output[K] | null | undefined; + }> + ) => { + if (Object.keys(newValues).every((val) => val === undefined)) { + return; + } + const updatedValues: Partial> = builder.searchSchema + .partial() + .parse({ ..._searchParams, ...newValues }); + for (const [key, value] of Object.entries(updatedValues)) { + if (value === null) { + delete updatedValues[key]; + } + + if ( + value === "" && + builder.searchSchema.shape[key] instanceof ZodOptional + ) { + delete updatedValues[key]; + } + } + + push(params, updatedValues); + }, + [_searchParams, push, params, builder.searchSchema] + ); + + const debouncedSetSearchParams = useDebounceCallback( + setSearchParams + ) as typeof setSearchParams; + const resetAllValues = useCallback(() => { + push(params, builder.searchSchema.parse({}) as z.output); + }, [push, params, builder]); + + return { + searchParams, + setSearchParams, + resetAllValues, + debouncedSetSearchParams + } as const; +} +export type DebounceOptions = { + leading?: boolean; + trailing?: boolean; + maxWait?: number; + delay?: number; + // debounceOrThrottle?: "debounce" | "throttle"; +}; +type DebounceCallbackParam = DebounceOptions & { + debounceOrThrottle?: "debounce" | "throttle"; +}; + +type ControlFunctions = { + cancel: () => void; + flush: () => void; + isPending: () => boolean; +}; + +export type DebouncedState ReturnType> = (( + ...args: Parameters +) => ReturnType | undefined) & + ControlFunctions; + +const defaultDebounceCallbackParam: DebounceCallbackParam = { + delay: 500, + debounceOrThrottle: "debounce" +}; +export function useDebounceCallback ReturnType>( + func: T, + _options: DebounceCallbackParam = {} +): DebouncedState { + const options = useMemo( + () => ({ ...defaultDebounceCallbackParam, ..._options }), + [_options] + ); + const debounceOrThrottle = useMemo( + () => options.debounceOrThrottle, + [options.debounceOrThrottle] + ); + const delay = useMemo(() => options.delay, [options.delay]); + const debounceOptions = useMemo(() => { + return { + ...options, + leading: options.leading ?? false, + debounceOrThrottle, + delay + }; + }, [options, debounceOrThrottle, delay]); + const debouncedFunc = useRef>(undefined); + + useEffect(() => { + if (debouncedFunc.current) { + debouncedFunc.current.cancel(); + } + }); + + const debounced = useMemo(() => { + const debouncedFuncInstance = + debounceOrThrottle === "throttle" + ? throttle(func, delay, debounceOptions) + : debounce(func, delay, debounceOptions); + + const wrappedFunc: DebouncedState = (...args: Parameters) => { + return debouncedFuncInstance(...args); + }; + + wrappedFunc.cancel = () => { + debouncedFuncInstance.cancel(); + }; + + wrappedFunc.isPending = () => { + return !!debouncedFunc.current; + }; + + wrappedFunc.flush = () => { + return debouncedFuncInstance.flush(); + }; + + return wrappedFunc; + }, [debounceOrThrottle, func, delay, debounceOptions]); + + // Update the debounced function ref whenever func, wait, or options change + useEffect(() => { + debouncedFunc.current = debounce(func, delay, debounceOptions); + }, [func, delay, debounceOptions]); + + return debounced; +} function convertURLSearchParamsToObject( - params: Readonly | null + params: Readonly | null, + schema: z.ZodTypeAny ): Record { if (!params) { return {}; } const obj: Record = {}; - // @ts-ignore - for (const [key, value] of params.entries()) { - if (params.getAll(key).length > 1) { - obj[key] = params.getAll(key); + const arrayKeys = getArrayKeysFromZodSchema(schema); + const uniqueKeys = [...new Set(params.keys())]; + for (const key of uniqueKeys) { + if (arrayKeys.includes(key)) { + obj[key] = params.getAll(key).filter(Boolean); } else { - obj[key] = value; + const value = params.get(key); + if (value) { + obj[key] = value; + } } } return obj; } +export function getArrayKeysFromZodSchema(schema: z.ZodTypeAny): string[] { + if (!(schema instanceof z.ZodObject)) return []; + return Object.entries(schema.shape).flatMap(([key, value]) => { + const unwrapped = unwrapZodType( + value as Parameters[0] + ); + return unwrapped instanceof ZodArray ? [key] : []; + }); +} +function unwrapZodType(schema: ZodTypeAny): ZodTypeAny { + const unwrappableInstances = [ + ZodOptional, + ZodNullable, + ZodDefault, + ZodEffects + ]; + if (unwrappableInstances.some((instance) => schema instanceof instance)) { + return unwrapZodType(schema._def.innerType || schema._def.schema); + } + return schema; +} diff --git a/assets/nextjs/makeRoute.tsx b/assets/nextjs/makeRoute.tsx index 0b39734..7a482bf 100644 --- a/assets/nextjs/makeRoute.tsx +++ b/assets/nextjs/makeRoute.tsx @@ -1,7 +1,7 @@ /* Derived from: https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety */ -import { z } from "zod"; +import { type AnyZodObject, z } from "zod"; import queryString from "query-string"; import Link from "next/link"; @@ -16,6 +16,11 @@ export type RouteInfo< search: Search; description?: string; }; +export type BaseRouteInfo = Omit< + RouteInfo, + "search" +> & + Partial, "search">>; export type GetInfo = { result: Result; @@ -101,13 +106,12 @@ type GetRouteBuilder< type DeleteRouteBuilder< Params extends z.ZodSchema, Search extends z.ZodSchema -> = CoreRouteElements & { - ( +> = CoreRouteElements & + (( p?: z.input, search?: z.input, options?: FetchOptions - ): Promise; -}; + ) => Promise); export type RouteBuilder< Params extends z.ZodSchema, @@ -204,7 +208,7 @@ function createRouteBuilder< const baseUrl = fn(checkedParams); const searchString = search && queryString.stringify(search); - return [baseUrl, searchString ? `?${searchString}` : ""].join(""); + return ["/", baseUrl, searchString ? `?${searchString}` : ""].join(""); }; } diff --git a/assets/shared/info.ts.template b/assets/shared/info.ts.template index 965c471..092fdab 100644 --- a/assets/shared/info.ts.template +++ b/assets/shared/info.ts.template @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { BaseRouteInfo } from "@/routes/makeRoute"; export const Route = { name: "{{{name}}}", @@ -7,7 +8,7 @@ export const Route = { {{{this}}}, {{/each}} }) -}; +} satisfies BaseRouteInfo; {{#each verbs}} export const {{{this.verb}}} = { diff --git a/examples/nextjs/with-use-search-params-state/.env.example b/examples/nextjs/with-use-search-params-state/.env.example new file mode 100644 index 0000000..adfe836 --- /dev/null +++ b/examples/nextjs/with-use-search-params-state/.env.example @@ -0,0 +1,14 @@ +# Since the ".env" file is gitignored, you can use the ".env.example" file to +# build a new ".env" file when you clone the repo. Keep this file up-to-date +# when you add new variables to `.env`. + +# This file will be committed to version control, so make sure not to have any +# secrets in it. If you are cloning this repo, create a copy of this file named +# ".env" and populate it with your secrets. + +# When adding additional environment variables, the schema in "/src/env.js" +# should be updated accordingly. + +# Example: +# SERVERVAR="foo" +# NEXT_PUBLIC_CLIENTVAR="bar" diff --git a/examples/nextjs/with-use-search-params-state/.gitignore b/examples/nextjs/with-use-search-params-state/.gitignore new file mode 100644 index 0000000..c24a835 --- /dev/null +++ b/examples/nextjs/with-use-search-params-state/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# database +/prisma/db.sqlite +/prisma/db.sqlite-journal +db.sqlite + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +# idea files +.idea \ No newline at end of file diff --git a/examples/nextjs/with-use-search-params-state/.npmrc b/examples/nextjs/with-use-search-params-state/.npmrc new file mode 100644 index 0000000..463ff0a --- /dev/null +++ b/examples/nextjs/with-use-search-params-state/.npmrc @@ -0,0 +1,2 @@ +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=*prettier* \ No newline at end of file diff --git a/examples/nextjs/with-use-search-params-state/FEATURES.md b/examples/nextjs/with-use-search-params-state/FEATURES.md new file mode 100644 index 0000000..43aba81 --- /dev/null +++ b/examples/nextjs/with-use-search-params-state/FEATURES.md @@ -0,0 +1,347 @@ +# Enhanced URL Search Parameter State Management + +This example demonstrates the new **URL search parameter state management** features added to the declarative-routing library. These changes provide a React state-like interface for managing URL search parameters with automatic synchronization, debouncing, and type safety. + +## Key Features & Changes + +### 1. **New `useSearchParamsState` Hook** đŸŽ¯ + +The main addition is the `useSearchParamsState` hook, which provides a React state-like interface for URL search parameters: + +```tsx +const { + searchParams, // Current search parameter values + setSearchParams, // Update search parameters (immediate) + debouncedSetSearchParams, // Debounced version for real-time input + resetAllValues // Reset all search parameters to defaults +} = useSearchParamsState(routeBuilder, config?); +``` + +#### Configuration Options: + +```tsx +useSearchParamsState(routeBuilder, { + partial?: boolean; // Allow partial parameter parsing (default: false) +}); +``` + +**Why the `config` parameter?** + +- Since `useSearchParamsState` uses `useParams` internally, it needs to support the same configuration options +- The `partial: true` option is especially useful in layouts where you want to conditionally handle state based on parameter presence +- This ensures consistency across all the routing hooks + +#### Key Behaviors: + +- **`undefined` values**: Ignored (no change to URL) +- **`null` values**: Remove the parameter from URL +- **Empty strings**: For optional parameters, treated as removal +- **Automatic URL updates**: Changes immediately reflect in browser URL +- **Type safety**: Full TypeScript support with Zod schema validation + +**Example Usage:** + +```tsx +// Update search with debouncing (great for search inputs) +debouncedSetSearchParams({ + searchValue: e.target.value, + cursor: 0, // Reset pagination +}); + +// Remove a filter +setSearchParams({ + types: null, // This removes 'types' from URL +}); + +// Conditional updates +setSearchParams({ + someFilter: shouldApplyFilter ? "value" : undefined, // Only updates if true +}); +``` + +### 2. **Enhanced `useParams` with Partial Support** 🔧 + +#### The Problem: + +In layouts, you often want to know if child routes are rendered without throwing errors. For example, in our Pokemon app: + +- Children (Pokemon details) are displayed when `id` parameter is present +- Without `{partial: true}`, `useParams` throws an error if `id` is missing +- This prevents conditional styling/animations based on parameter presence + +#### The Solution: + +```tsx +const { id: selectedPokemonId } = useParams(PokemonId, { + partial: true, // ✅ No error if 'id' is missing +}); + +// Now you can safely use conditional logic: +const screenSplitStrategy = selectedPokemonId + ? "list and details" + : "only list"; +``` + +#### Enhanced Error Messages: + +```tsx +// If you use full parsing but partial would work, you get a helpful hint: +Invalid route params for route PokemonId: Required at "id". +â„šī¸ If you wanted to use partial params, pass {partial:true} as second parameter. +``` + +### 3. **Type System Improvements** đŸ›Ąī¸ + +#### Changed from `ZodSchema` to `ZodAnyObject`: + +```tsx +// Before (limited) +export function useParams + +// After (more capable) +export function useParams +``` + +**Why this change?** + +- `ZodAnyObject` supports `.partial()` method needed for flexible parameter parsing +- Enables better type inference and validation +- Required for the partial parameter functionality + +### 4. **Advanced Debouncing System** ⚡ + +#### New `useDebounceCallback` Hook: + +```tsx +export function useDebounceCallback ReturnType>( + func: T, + options: { + delay?: number; // Default: 500ms + debounceOrThrottle?: "debounce" | "throttle"; // Default: "debounce" + leading?: boolean; // Execute on leading edge + trailing?: boolean; // Execute on trailing edge + maxWait?: number; // Maximum wait time + } = {}, +): DebouncedState; +``` + +**Features:** + +- **Supports both debounce and throttle** +- **Proper cleanup** on component unmount +- **Control methods**: `cancel()`, `flush()`, `isPending()` +- **Flexible configuration** + +#### When to Use Debounce vs Throttle: + +**Debounce** (default) - Wait for user to stop typing: + +```tsx +// Search input - only search after user stops typing for 500ms +debouncedSetSearchParams({ + searchValue: e.target.value, +}); +``` + +**Throttle** - Execute at regular intervals: + +```tsx +// Auto-save form data every 5 seconds while user is typing +const throttledSaveToServer = useDebounceCallback( + (formData) => { + // Save to server/local storage + saveFormData(formData); + }, + { + delay: 5000, + debounceOrThrottle: "throttle", + }, +); + +// Usage in a textarea +