use-query-guard
Version:
A router-agnostic query-string management hook for React with Zod validation
1 lines • 10.8 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/useQueryGuard.ts"],"sourcesContent":["export * from './useQueryGuard'\n","import { useCallback, useMemo, useReducer, useSyncExternalStore } from 'react'\nimport z, { ZodNumber, ZodObject, type ZodRawShape } from 'zod'\n\n/* ------------------------------------------------------------------ */\n/* 追加ユーティリティ:URL に載せられる値型へマッピング */\n/* ------------------------------------------------------------------ */\ntype Paramifiable<T> =\n | (T extends string ? string : never)\n | (T extends number ? number : never)\n | (T extends boolean ? boolean : never)\n | string // fallback (未知の型 → 文字列扱い)\n | null // ← 削除フラグ\n\n/* ------------------------------------------------------------------ */\n/* updateParams に渡せる引数型 */\n/* ------------------------------------------------------------------ */\ntype UpdateArgs<Schema> =\n Schema extends ZodObject<ZodRawShape>\n ? Partial<{\n [K in keyof z.infer<Schema>]: Paramifiable<z.infer<Schema>[K]>\n }>\n : Record<string, string | number | boolean | null>\n\n/* ------------------------------------------------------------------ */\n/* 1. URL 操作を差し替えられる “アダプタ” を定義 */\n/* ------------------------------------------------------------------ */\n\nexport interface QueryGuardAdapter {\n /** 現在の search 部分 (`?foo=1&bar=2`) を返す */\n getSearch(): string\n /** search を pushState/replaceState 相当で更新する */\n setSearch(next: string): void\n /** `popstate` 等を購読し、変化時にコールバックを呼ぶ */\n subscribe(cb: () => void): () => void\n}\n\n/* --- デフォルト実装(window + History API 直接) --- */\nconst browserAdapter: QueryGuardAdapter = {\n getSearch: () =>\n typeof window === 'undefined' ? '' : window.location.search,\n setSearch: (next) => {\n if (typeof window === 'undefined') return\n const url =\n window.location.pathname +\n (next.startsWith('?') || next === '' ? next : `?${next}`) +\n window.location.hash\n window.history.pushState(null, '', url)\n /* pushState では popstate が発火しないので自前で */\n window.dispatchEvent(new Event('popstate'))\n },\n subscribe: (cb) => {\n if (typeof window === 'undefined') return () => {}\n window.addEventListener('popstate', cb)\n return () => window.removeEventListener('popstate', cb)\n },\n}\n\n/* ------------------------------------------------------------------ */\n/* 2. ユーティリティ型 */\n/* ------------------------------------------------------------------ */\n\ntype Optionalise<T> = { [K in keyof T]: T[K] | undefined }\n\n/**\n * SearchParamsOptions\n * -------------------\n * resolver: z.object() スキーマ(無ければゆるい string 辞書)\n * preprocess: 読み込み時に key/value を加工\n * adapter: URL 操作をフレームワークに合わせて差し替え\n */\nexport type QueryGuardOptions<\n Schema extends ZodObject<ZodRawShape> | undefined = undefined,\n> = {\n resolver?: Schema\n preprocess?: Schema extends ZodObject<ZodRawShape>\n ? (key: keyof z.infer<Schema> & string, value: string) => string\n : (key: string, value: string) => string\n adapter?: QueryGuardAdapter\n mode?: 'strict' | 'pick'\n}\n\n/* ------------------------------------------------------------------ */\n/* 3. メインフック */\n/* ------------------------------------------------------------------ */\n\nexport function useQueryGuard<\n Schema extends ZodObject<ZodRawShape> | undefined = undefined,\n>(options?: QueryGuardOptions<Schema>) {\n const {\n resolver,\n preprocess,\n adapter = browserAdapter,\n mode = 'pick',\n } = options ?? {}\n\n /* -- URL 変化購読 (`useSyncExternalStore` で安定) -- */\n const search = useSyncExternalStore(\n adapter.subscribe,\n adapter.getSearch,\n () => ''\n )\n\n /* --- ready 判定 (初回読込後 true) --- */\n const [isReady, setReadyTrue] = useReducer(() => true, false)\n\n /* ----------------------------------------------------------------\n * URLSearchParams → オブジェクト化&前処理&Zod 検証\n * ---------------------------------------------------------------- */\n const { data, isError } = useMemo(() => {\n /* 1) URLSearchParams を取得し文字列辞書へ ---------- */\n const usp = new URLSearchParams(search)\n const raw: Record<string, string> = {}\n usp.forEach((value, key) => {\n raw[key] = preprocess ? preprocess(key as never, value) : value\n })\n\n setReadyTrue()\n\n /* 2) resolver 無しなら丸ごと返す -------------------- */\n if (!resolver) {\n return { data: raw as Record<string, string | undefined>, isError: false }\n }\n\n /* 3) 文字列 → number など事前変換 ------------------- */\n const converted: Record<string, unknown> = {}\n for (const key of Object.keys(resolver.shape) as Array<\n keyof typeof resolver.shape\n >) {\n const schema = resolver.shape[key]\n const v = raw[key as string]\n if (v === undefined) continue\n converted[key as string] = schema instanceof ZodNumber ? Number(v) : v\n }\n\n /* 4) バリデーションモードごとに処理 ------------------ */\n const base = Object.fromEntries(\n Object.keys(resolver.shape).map((k) => [k, undefined])\n ) as Optionalise<z.infer<typeof resolver>>\n\n if (mode === 'pick') {\n /* ---- 部分一致モード ---- */\n let anyError = false\n const picked: Record<string, unknown> = {}\n\n for (const [key, schema] of Object.entries(resolver.shape)) {\n const val = converted[key]\n if (val === undefined) continue\n\n const r = (schema as z.ZodTypeAny).safeParse(val)\n if (r.success) picked[key] = r.data\n else anyError = true\n }\n\n return {\n data: { ...base, ...picked },\n isError: anyError,\n }\n } else {\n /* ---- 完全一致モード ---- */\n const parsed = resolver.safeParse(converted)\n return {\n data: parsed.success ? { ...base, ...parsed.data } : base,\n isError: !parsed.success,\n }\n }\n }, [search, resolver, preprocess, mode])\n\n /* ----------------------------------------------------------------\n * 更新ヘルパ(差分チェック付き)\n * ---------------------------------------------------------------- */\n const updateParams = useCallback(\n (params: UpdateArgs<Schema>) => {\n /* 1) 現在のクエリを取得し、URLSearchParams へ ---------------- */\n const usp = new URLSearchParams(adapter.getSearch())\n\n /* 2) 渡された params を反映 ----------------------------------- */\n Object.entries(params).forEach(([k, v]) => {\n if (v === null || v === '') {\n usp.delete(k) // null / 空文字 → 削除\n } else if (v !== undefined) {\n usp.set(k, String(v)) // それ以外 → 文字列でセット\n }\n })\n\n /* 3) 変更後のクエリ文字列を生成(? を付けずに比較) ------------ */\n // ① URLSearchParams#sort() でキー順を安定化(ES2021~ 対応)\n usp.sort()\n const next = usp.toString()\n\n // ② adapter.getSearch() から先頭の ? を除いて比較\n const current = adapter.getSearch().replace(/^\\?/, '')\n if (next === current) return // 差分無しなら何もしない\n\n /* 4) 差分があれば更新 ----------------------------------------- */\n adapter.setSearch(next)\n },\n [adapter]\n )\n /* ---------------------------------------------------------------- */\n /* 型条件付きで戻す */\n /* ---------------------------------------------------------------- */\n return {\n data: data as Schema extends ZodObject<ZodRawShape>\n ? Optionalise<z.infer<Schema>>\n : Record<string, string | undefined>,\n isReady,\n isError,\n updateParams,\n }\n}\n"],"mappings":"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","names":[]}