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..231637d 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; @@ -43,6 +48,7 @@ type CoreRouteElements< paramsSchema: Params; search: z.output; searchSchema: Search; + urlBuilder: (p?: z.input, search?: z.input) => string; }; type PutRouteBuilder< @@ -101,13 +107,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 +209,11 @@ function createRouteBuilder< const baseUrl = fn(checkedParams); const searchString = search && queryString.stringify(search); - return [baseUrl, searchString ? `?${searchString}` : ""].join(""); + return [ + baseUrl.startsWith("/") ? "" : "/", + baseUrl, + searchString ? `?${searchString}` : "" + ].join(""); }; } @@ -269,6 +278,7 @@ export function makePostRoute< routeBuilder.bodySchema = postInfo.body; routeBuilder.result = undefined as z.output; routeBuilder.resultSchema = postInfo.result; + routeBuilder.urlBuilder = urlBuilder; return routeBuilder; } @@ -332,6 +342,7 @@ export function makePutRoute< routeBuilder.bodySchema = putInfo.body; routeBuilder.result = undefined as z.output; routeBuilder.resultSchema = putInfo.result; + routeBuilder.urlBuilder = urlBuilder; return routeBuilder; } @@ -376,6 +387,7 @@ export function makeGetRoute< routeBuilder.searchSchema = info.search; routeBuilder.result = undefined as z.output; routeBuilder.resultSchema = getInfo.result; + routeBuilder.urlBuilder = urlBuilder; return routeBuilder; } @@ -412,6 +424,7 @@ export function makeDeleteRoute< routeBuilder.paramsSchema = info.params; routeBuilder.search = undefined as z.output; routeBuilder.searchSchema = info.search; + routeBuilder.urlBuilder = urlBuilder; return routeBuilder; } @@ -473,6 +486,7 @@ export function makeRoute< urlBuilder.paramsSchema = info.params; urlBuilder.search = undefined as z.output; urlBuilder.searchSchema = info.search; + urlBuilder.urlBuilder = urlBuilder; return urlBuilder; } 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 +