From 87c02de651330a682f29e10fb46f6aa0b7521aa3 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 26 May 2026 16:40:37 +0900 Subject: [PATCH 1/7] feat: add CollectionStateSynchronizer API and useSearchParamsSynchronizer Introduce a pluggable synchronizer interface for persisting collection state (filters, sort, pageSize) to external stores. - Add CollectionSnapshot and CollectionStateSynchronizer types - Add synchronizer option to UseCollectionOptions / useCollectionVariables - Implement useSearchParamsSynchronizer (URL search params persistence) with prefix and debounce support - Add tests for synchronizer integration and useSearchParamsSynchronizer --- ...-collection-variables-synchronizer.test.ts | 155 +++++++++ .../src/hooks/use-collection-variables.ts | 32 +- .../use-search-params-synchronizer.test.tsx | 295 ++++++++++++++++++ .../hooks/use-search-params-synchronizer.ts | 198 ++++++++++++ packages/core/src/index.ts | 3 + packages/core/src/types/collection.ts | 30 ++ 6 files changed, 708 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/hooks/use-collection-variables-synchronizer.test.ts create mode 100644 packages/core/src/hooks/use-search-params-synchronizer.test.tsx create mode 100644 packages/core/src/hooks/use-search-params-synchronizer.ts diff --git a/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts b/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts new file mode 100644 index 00000000..9d9a2511 --- /dev/null +++ b/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts @@ -0,0 +1,155 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import type { CollectionStateSynchronizer } from "@/types/collection"; +import { useCollectionVariables } from "./use-collection-variables"; + +describe("useCollectionVariables with synchronizer", () => { + function createMockSynchronizer(initial?: ReturnType) { + return { + read: vi.fn(() => initial), + write: vi.fn(), + } satisfies CollectionStateSynchronizer; + } + + describe("read (initial hydration)", () => { + it("uses synchronizer initial state over params defaults", () => { + const synchronizer = createMockSynchronizer({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + sort: [{ field: "name", direction: "Asc" }], + pageSize: 50, + }); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + synchronizer, + }), + ); + + expect(synchronizer.read).toHaveBeenCalledOnce(); + expect(result.current.control.filters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); + expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); + expect(result.current.control.pageSize).toBe(50); + }); + + it("falls back to params when synchronizer returns undefined", () => { + const synchronizer = createMockSynchronizer(undefined); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { + pageSize: 30, + initialSort: [{ field: "createdAt", direction: "Desc" }], + }, + synchronizer, + }), + ); + + expect(result.current.control.pageSize).toBe(30); + expect(result.current.control.sortStates).toEqual([ + { field: "createdAt", direction: "Desc" }, + ]); + expect(result.current.control.filters).toEqual([]); + }); + + it("partially overrides params (only pageSize from synchronizer)", () => { + const synchronizer = createMockSynchronizer({ + pageSize: 100, + }); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { + pageSize: 20, + initialSort: [{ field: "name", direction: "Asc" }], + }, + synchronizer, + }), + ); + + expect(result.current.control.pageSize).toBe(100); + // Sort falls back to params + expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); + }); + }); + + describe("write (state persistence)", () => { + it("calls write on filter change", () => { + const synchronizer = createMockSynchronizer(undefined); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + synchronizer, + }), + ); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(synchronizer.write).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + pageSize: 20, + }), + ); + }); + + it("calls write on sort change", () => { + const synchronizer = createMockSynchronizer(undefined); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + synchronizer, + }), + ); + + act(() => { + result.current.control.setSort("name", "Desc"); + }); + + expect(synchronizer.write).toHaveBeenCalledWith( + expect.objectContaining({ + sort: [{ field: "name", direction: "Desc" }], + pageSize: 20, + }), + ); + }); + + it("calls write on pageSize change", () => { + const synchronizer = createMockSynchronizer(undefined); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + synchronizer, + }), + ); + + act(() => { + result.current.control.setPageSize(50); + }); + + expect(synchronizer.write).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 50, + }), + ); + }); + + it("does not call write when no synchronizer is provided", () => { + const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } })); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + // No error thrown — just verifying it doesn't crash + expect(result.current.control.filters).toHaveLength(1); + }); + }); +}); diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 52c44f7c..60aec771 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { BuildQueryVariables, CollectionControl, @@ -130,14 +130,22 @@ export function useCollectionVariables( export function useCollectionVariables( options: UseCollectionOptions & { tableMetadata?: TableMetadata }, ): unknown { - const { params = {} } = options; + const { params = {}, synchronizer } = options; const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; + // --------------------------------------------------------------------------- + // Hydrate initial state from synchronizer (read once on mount) + // --------------------------------------------------------------------------- + const syncInitial = useRef(() => synchronizer?.read()).current(); + const resolvedInitialFilters = syncInitial?.filters ?? initialFilters; + const resolvedInitialSort = syncInitial?.sort ?? initialSort; + const resolvedInitialPageSize = syncInitial?.pageSize ?? initialPageSize; + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- - const [filters, setFiltersState] = useState(initialFilters); - const [sortStates, setSortStates] = useState(initialSort); + const [filters, setFiltersState] = useState(resolvedInitialFilters); + const [sortStates, setSortStates] = useState(resolvedInitialSort); const { pageSize, @@ -151,7 +159,21 @@ export function useCollectionVariables( getHasPrevPage, getHasNextPage, resetCount, - } = useCursorPagination(initialPageSize); + } = useCursorPagination(resolvedInitialPageSize); + + // --------------------------------------------------------------------------- + // Synchronizer write-back + // --------------------------------------------------------------------------- + const synchronizerRef = useRef(synchronizer); + synchronizerRef.current = synchronizer; + + useEffect(() => { + synchronizerRef.current?.write({ + filters, + sort: sortStates, + pageSize, + }); + }, [filters, sortStates, pageSize]); // --------------------------------------------------------------------------- // Filter operations diff --git a/packages/core/src/hooks/use-search-params-synchronizer.test.tsx b/packages/core/src/hooks/use-search-params-synchronizer.test.tsx new file mode 100644 index 00000000..e672c238 --- /dev/null +++ b/packages/core/src/hooks/use-search-params-synchronizer.test.tsx @@ -0,0 +1,295 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { MemoryRouter } from "react-router"; +import type { ReactNode } from "react"; +import { useSearchParamsSynchronizer } from "./use-search-params-synchronizer"; + +function createWrapper(initialEntries: string[] = ["/"]) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useSearchParamsSynchronizer", () => { + // --------------------------------------------------------------------------- + // read() + // --------------------------------------------------------------------------- + describe("read", () => { + it("returns undefined when no search params exist", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/"]), + }); + + expect(result.current.read()).toBeUndefined(); + }); + + it("reads pageSize from URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?p=50"]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.pageSize).toBe(50); + }); + + it("reads sort from URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?s=name:asc"]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.sort).toEqual([{ field: "name", direction: "Asc" }]); + }); + + it("reads sort desc from URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?s=createdAt:desc"]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); + }); + + it("reads single-value filter from URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?f.status:eq=ACTIVE"]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([{ field: "status", operator: "eq", value: "ACTIVE" }]); + }); + + it("reads multi-value filter from URL (repeated params)", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?f.status:in=ACTIVE&f.status:in=PENDING"]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([ + { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, + ]); + }); + + it("reads JSON object filter value (e.g. between)", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper([ + `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`, + ]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([ + { field: "amount", operator: "between", value: { min: 10, max: 100 } }, + ]); + }); + + it("reads all state together", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?p=25&s=name:desc&f.status:eq=ACTIVE"]), + }); + + const snapshot = result.current.read(); + expect(snapshot).toEqual({ + pageSize: 25, + sort: [{ field: "name", direction: "Desc" }], + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + }); + }); + + it("respects prefix option", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer({ prefix: "t1" }), { + wrapper: createWrapper(["/?t1.p=30&p=10"]), + }); + + const snapshot = result.current.read(); + expect(snapshot?.pageSize).toBe(30); + }); + + it("ignores invalid pageSize values", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?p=abc"]), + }); + + expect(result.current.read()).toBeUndefined(); + }); + + it("ignores filter keys without operator", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?f.status=ACTIVE"]), + }); + + expect(result.current.read()).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // write() + // --------------------------------------------------------------------------- + describe("write", () => { + it("writes pageSize to URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.pageSize).toBe(50); + }); + + it("writes sort to URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ + filters: [], + sort: [{ field: "name", direction: "Desc" }], + pageSize: 20, + }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.sort).toEqual([{ field: "name", direction: "Desc" }]); + }); + + it("writes single-value filter to URL", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + sort: [], + pageSize: 20, + }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([{ field: "status", operator: "eq", value: "ACTIVE" }]); + }); + + it("writes multi-value filter as repeated params", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ + filters: [{ field: "status", operator: "in", value: ["A", "B", "C"] }], + sort: [], + pageSize: 20, + }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([ + { field: "status", operator: "in", value: ["A", "B", "C"] }, + ]); + }); + + it("writes object filter value as JSON", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ + filters: [{ field: "amount", operator: "between", value: { min: 1, max: 99 } }], + sort: [], + pageSize: 20, + }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([ + { field: "amount", operator: "between", value: { min: 1, max: 99 } }, + ]); + }); + + it("clears old filters when writing new state", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer(), { + wrapper: createWrapper(["/?f.status:eq=OLD"]), + }); + + act(() => { + result.current.write({ + filters: [{ field: "name", operator: "contains", value: "test" }], + sort: [], + pageSize: 20, + }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.filters).toEqual([{ field: "name", operator: "contains", value: "test" }]); + }); + + it("uses prefix when writing", () => { + const { result } = renderHook(() => useSearchParamsSynchronizer({ prefix: "t1" }), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 40 }); + }); + + const snapshot = result.current.read(); + expect(snapshot?.pageSize).toBe(40); + }); + }); + + // --------------------------------------------------------------------------- + // debounce + // --------------------------------------------------------------------------- + describe("debounce", () => { + it("debounces write when debounceMs is set", () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useSearchParamsSynchronizer({ debounceMs: 100 }), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + // Not yet written + expect(result.current.read()).toBeUndefined(); + + act(() => { + vi.advanceTimersByTime(100); + }); + + // Now written + expect(result.current.read()?.pageSize).toBe(50); + + vi.useRealTimers(); + }); + + it("only applies the last write within debounce window", () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useSearchParamsSynchronizer({ debounceMs: 100 }), { + wrapper: createWrapper(["/"]), + }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 10 }); + result.current.write({ filters: [], sort: [], pageSize: 30 }); + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current.read()?.pageSize).toBe(50); + + vi.useRealTimers(); + }); + }); +}); diff --git a/packages/core/src/hooks/use-search-params-synchronizer.ts b/packages/core/src/hooks/use-search-params-synchronizer.ts new file mode 100644 index 00000000..f3a393bd --- /dev/null +++ b/packages/core/src/hooks/use-search-params-synchronizer.ts @@ -0,0 +1,198 @@ +import { useCallback, useRef } from "react"; +import { useSearchParams } from "react-router"; +import type { + CollectionSnapshot, + CollectionStateSynchronizer, + Filter, + SortState, +} from "@/types/collection"; + +const KEY_PAGE_SIZE = "p"; +const KEY_SORT = "s"; +const FILTER_PREFIX = "f."; + +export interface UseSearchParamsSynchronizerOptions { + /** Key prefix to avoid collisions when multiple tables share a page. */ + prefix?: string; + /** Debounce interval in ms for URL writes. Default: no debounce. */ + debounceMs?: number; +} + +/** + * Synchronizer hook that persists collection state to URL search params. + * + * URL format: + * - Page size: `p=20` + * - Sort: `s=name:asc` + * - Filters: `f.field:operator=value` (repeated params for multi-value) + * + * @example + * ```tsx + * const synchronizer = useSearchParamsSynchronizer(); + * const { variables, control } = useCollectionVariables({ + * params: { pageSize: 20 }, + * synchronizer, + * }); + * ``` + */ +export function useSearchParamsSynchronizer( + options: UseSearchParamsSynchronizerOptions = {}, +): CollectionStateSynchronizer { + const { prefix = "", debounceMs } = options; + const [searchParams, setSearchParams] = useSearchParams(); + const timerRef = useRef | null>(null); + + const prefixedKey = useCallback((key: string) => (prefix ? `${prefix}.${key}` : key), [prefix]); + + const read = useCallback((): CollectionSnapshot | undefined => { + const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); + const sortKey = prefixedKey(KEY_SORT); + const filterPrefix = prefixedKey(FILTER_PREFIX); + + let hasAny = false; + const snapshot: CollectionSnapshot = {}; + + // Page size + const pageSize = searchParams.get(pageSizeKey); + if (pageSize) { + const n = Number(pageSize); + if (Number.isFinite(n) && n > 0) { + snapshot.pageSize = n; + hasAny = true; + } + } + + // Sort + const sort = searchParams.get(sortKey); + if (sort) { + const [field, rawDir] = sort.split(":"); + if (field) { + const direction = rawDir === "desc" ? "Desc" : "Asc"; + snapshot.sort = [{ field, direction } as SortState]; + hasAny = true; + } + } + + // Filters + const filterKeys = new Set(); + for (const key of searchParams.keys()) { + if (key.startsWith(filterPrefix)) filterKeys.add(key); + } + if (filterKeys.size > 0) { + const filters: Filter[] = []; + for (const key of filterKeys) { + const values = searchParams.getAll(key).filter((v) => v !== ""); + if (values.length === 0) continue; + const remainder = key.slice(filterPrefix.length); + const [field, operator] = remainder.split(":"); + if (!field || !operator) continue; + filters.push({ + field, + operator: operator as Filter["operator"], + value: values.length === 1 ? parseFilterValue(values[0]) : values.map(parseFilterValue), + }); + } + if (filters.length > 0) { + snapshot.filters = filters; + hasAny = true; + } + } + + return hasAny ? snapshot : undefined; + }, [searchParams, prefixedKey]); + + const write = useCallback( + (state: Required): void => { + const doWrite = () => { + const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); + const sortKey = prefixedKey(KEY_SORT); + const filterPrefix = prefixedKey(FILTER_PREFIX); + + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + + // Page size + if (state.pageSize) { + next.set(pageSizeKey, String(state.pageSize)); + } else { + next.delete(pageSizeKey); + } + + // Sort + if (state.sort && state.sort.length > 0) { + const { field, direction } = state.sort[0]; + next.set(sortKey, `${field}:${direction === "Desc" ? "desc" : "asc"}`); + } else { + next.delete(sortKey); + } + + // Clear existing filters with this prefix + for (const key of Array.from(next.keys())) { + if (key.startsWith(filterPrefix)) next.delete(key); + } + + // Write filters + if (state.filters) { + for (const filter of state.filters) { + const key = `${filterPrefix}${filter.field}:${filter.operator}`; + if (Array.isArray(filter.value)) { + for (const v of filter.value) { + const encoded = stringifyPrimitive(v); + if (encoded !== "") next.append(key, encoded); + } + } else if (filter.value != null && filter.value !== "") { + next.set(key, encodeFilterValue(filter.value)); + } + } + } + + return next; + }, + { replace: true }, + ); + }; + + if (debounceMs != null && debounceMs > 0) { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(doWrite, debounceMs); + } else { + doWrite(); + } + }, + [prefixedKey, setSearchParams, debounceMs], + ); + + return { read, write }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseFilterValue(raw: string): unknown { + // Attempt JSON parse for objects (e.g. `{"min":1,"max":10}`) + if (raw.startsWith("{") || raw.startsWith("[")) { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + return raw; +} + +function encodeFilterValue(value: unknown): string { + if (value == null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return stringifyPrimitive(value); +} + +function stringifyPrimitive(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (value == null) return ""; + return JSON.stringify(value); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c26da3c..77e1473b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -156,6 +156,8 @@ export { type PaginationVariables, type UseCollectionOptions, type UseCollectionReturn, + type CollectionSnapshot, + type CollectionStateSynchronizer, type FieldType, type FieldMetadata, type TableMetadata, @@ -183,6 +185,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; +export { useSearchParamsSynchronizer } from "./hooks/use-search-params-synchronizer"; export { CollectionControlProvider, useCollectionControl, diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 0627c92b..e7dd5bab 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -346,6 +346,34 @@ export type TableOrderableFieldName = : never : never; +// ============================================================================= +// Collection State Synchronizer +// ============================================================================= + +/** + * Snapshot of collection state for persistence. + */ +export interface CollectionSnapshot { + filters?: Filter[]; + sort?: SortState[]; + pageSize?: number; +} + +/** + * Pluggable synchronizer that persists collection state to an external store + * (URL search params, localStorage, server, etc.). + * + * - `read()` is called synchronously on mount to hydrate initial state. + * - `write()` is called on every state change. Implementations may internally + * debounce, batch, or perform async operations (fire-and-forget). + */ +export interface CollectionStateSynchronizer { + /** Read persisted state synchronously. Returns undefined if nothing is persisted. */ + read(): CollectionSnapshot | undefined; + /** Write current state. May be async internally — the hook does not await. */ + write(state: Required>): void; +} + // ============================================================================= // Collection Control & Options // ============================================================================= @@ -362,6 +390,8 @@ export interface UseCollectionOptions< initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; pageSize?: number; }; + /** Optional synchronizer to persist collection state to an external store. */ + synchronizer?: CollectionStateSynchronizer; } /** From 3c7cc222ab7480ef6c4f553c1c583fe91dd20bef Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 26 May 2026 17:14:35 +0900 Subject: [PATCH 2/7] refactor(core): introduce reducer-based collection state with pluggable synchronizer - Add collectionReducer with source tracking (init/user/sync) to eliminate ref flags - Extract useSynchronizerBridge hook for subscribe/write-back logic - Refactor useCollectionVariables to use useReducer + bridge - Refactor useCursorPagination to accept external pageSize parameter - Update useSearchParamsSynchronizer to use subscribe/write pattern - Update tests for new architecture --- .../core/src/hooks/use-collection-state.ts | 145 ++++++++++++++++++ ...-collection-variables-synchronizer.test.ts | 75 +++++++-- .../src/hooks/use-collection-variables.ts | 94 +++++------- .../src/hooks/use-cursor-pagination.test.ts | 25 --- .../core/src/hooks/use-cursor-pagination.ts | 17 +- .../use-search-params-synchronizer.test.tsx | 104 +++++++------ .../hooks/use-search-params-synchronizer.ts | 140 ++++++++++------- packages/core/src/types/collection.ts | 12 +- 8 files changed, 398 insertions(+), 214 deletions(-) create mode 100644 packages/core/src/hooks/use-collection-state.ts diff --git a/packages/core/src/hooks/use-collection-state.ts b/packages/core/src/hooks/use-collection-state.ts new file mode 100644 index 00000000..ff7df869 --- /dev/null +++ b/packages/core/src/hooks/use-collection-state.ts @@ -0,0 +1,145 @@ +import { useEffect } from "react"; +import type { + CollectionSnapshot, + CollectionStateSynchronizer, + Filter, + SortState, +} from "@/types/collection"; + +// ============================================================================= +// State & Action types +// ============================================================================= + +export type ChangeSource = "init" | "user" | "sync"; + +export interface CollectionState { + filters: Filter[]; + sortStates: SortState[]; + pageSize: number; + source: ChangeSource; +} + +export type CollectionAction = + | { + type: "ADD_FILTER"; + field: string; + operator: string; + value: unknown; + caseSensitive?: boolean; + } + | { type: "SET_FILTERS"; filters: Filter[] } + | { type: "REMOVE_FILTER"; field: string } + | { type: "CLEAR_FILTERS" } + | { type: "SET_SORT"; field: string; direction?: "Asc" | "Desc" } + | { type: "CLEAR_SORT" } + | { type: "SET_PAGE_SIZE"; pageSize: number } + | { type: "SYNC"; snapshot: CollectionSnapshot | undefined }; + +// ============================================================================= +// Reducer +// ============================================================================= + +export function collectionReducer( + state: CollectionState, + action: CollectionAction, +): CollectionState { + switch (action.type) { + case "SYNC": { + if (!action.snapshot) return state; + return { + ...state, + ...(action.snapshot.filters != null && { + filters: action.snapshot.filters, + }), + ...(action.snapshot.sort != null && { + sortStates: action.snapshot.sort, + }), + ...(action.snapshot.pageSize != null && { + pageSize: action.snapshot.pageSize, + }), + source: "sync", + }; + } + case "ADD_FILTER": { + const { field, operator, value, caseSensitive } = action; + const newFilter: Filter = { + field, + operator: operator as Filter["operator"], + value, + caseSensitive, + }; + const existing = state.filters.findIndex((f) => f.field === field); + const filters = + existing >= 0 + ? state.filters.map((f, i) => (i === existing ? newFilter : f)) + : [...state.filters, newFilter]; + return { ...state, filters, source: "user" }; + } + case "SET_FILTERS": + return { ...state, filters: action.filters, source: "user" }; + case "REMOVE_FILTER": + return { + ...state, + filters: state.filters.filter((f) => f.field !== action.field), + source: "user", + }; + case "CLEAR_FILTERS": + return { ...state, filters: [], source: "user" }; + case "SET_SORT": { + const { field, direction } = action; + if (direction === undefined) { + return { + ...state, + sortStates: state.sortStates.filter((s) => s.field !== field), + source: "user", + }; + } + const filtered = state.sortStates.filter((s) => s.field !== field); + return { + ...state, + sortStates: [...filtered, { field, direction }], + source: "user", + }; + } + case "CLEAR_SORT": + return { ...state, sortStates: [], source: "user" }; + case "SET_PAGE_SIZE": + return { ...state, pageSize: action.pageSize, source: "user" }; + default: + return state; + } +} + +// ============================================================================= +// Synchronizer Bridge Hook +// ============================================================================= + +/** + * Connects a synchronizer to the collection reducer state. + * + * - Subscribes to external changes and dispatches `SYNC` actions. + * - Writes state back to the synchronizer when changes originate from user actions. + */ +export function useSynchronizerBridge( + synchronizer: CollectionStateSynchronizer | undefined, + state: CollectionState, + dispatch: React.Dispatch, +): void { + // Subscribe: external changes → dispatch + useEffect(() => { + if (!synchronizer) return; + return synchronizer.subscribe((snapshot) => { + dispatch({ type: "SYNC", snapshot }); + }); + }, [synchronizer, dispatch]); + + // Write-back: only when source is "user" + useEffect(() => { + if (state.source !== "user") return; + synchronizer?.write({ + filters: state.filters, + sort: state.sortStates, + pageSize: state.pageSize, + }); + }, [state.filters, state.sortStates, state.pageSize, state.source, synchronizer]); +} diff --git a/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts b/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts index 9d9a2511..f31e608d 100644 --- a/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts +++ b/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts @@ -1,17 +1,32 @@ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import type { CollectionStateSynchronizer } from "@/types/collection"; +import type { CollectionSnapshot, CollectionStateSynchronizer } from "@/types/collection"; import { useCollectionVariables } from "./use-collection-variables"; describe("useCollectionVariables with synchronizer", () => { - function createMockSynchronizer(initial?: ReturnType) { + function createMockSynchronizer(initial?: CollectionSnapshot) { + const subscribers: Array<(snapshot: CollectionSnapshot | undefined) => void> = []; return { - read: vi.fn(() => initial), + subscribe: vi.fn((onChange) => { + subscribers.push(onChange); + // BehaviorSubject: emit immediately + onChange(initial); + return () => { + const idx = subscribers.indexOf(onChange); + if (idx >= 0) subscribers.splice(idx, 1); + }; + }), write: vi.fn(), - } satisfies CollectionStateSynchronizer; + // Test helper to simulate external change + emit(snapshot: CollectionSnapshot | undefined) { + for (const cb of subscribers) cb(snapshot); + }, + } satisfies CollectionStateSynchronizer & { + emit: (s: CollectionSnapshot | undefined) => void; + }; } - describe("read (initial hydration)", () => { + describe("subscribe (initial hydration)", () => { it("uses synchronizer initial state over params defaults", () => { const synchronizer = createMockSynchronizer({ filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], @@ -26,7 +41,7 @@ describe("useCollectionVariables with synchronizer", () => { }), ); - expect(synchronizer.read).toHaveBeenCalledOnce(); + expect(synchronizer.subscribe).toHaveBeenCalledOnce(); expect(result.current.control.filters).toEqual([ { field: "status", operator: "eq", value: "ACTIVE" }, ]); @@ -34,7 +49,7 @@ describe("useCollectionVariables with synchronizer", () => { expect(result.current.control.pageSize).toBe(50); }); - it("falls back to params when synchronizer returns undefined", () => { + it("falls back to params when synchronizer emits undefined", () => { const synchronizer = createMockSynchronizer(undefined); const { result } = renderHook(() => @@ -70,12 +85,53 @@ describe("useCollectionVariables with synchronizer", () => { ); expect(result.current.control.pageSize).toBe(100); - // Sort falls back to params + // Sort remains from params since synchronizer didn't provide it expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); }); }); + describe("subscribe (external changes)", () => { + it("updates state when synchronizer emits external change", () => { + const synchronizer = createMockSynchronizer(undefined); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + synchronizer, + }), + ); + + act(() => { + synchronizer.emit({ + filters: [{ field: "name", operator: "contains", value: "test" }], + pageSize: 50, + }); + }); + + expect(result.current.control.filters).toEqual([ + { field: "name", operator: "contains", value: "test" }, + ]); + expect(result.current.control.pageSize).toBe(50); + }); + }); + describe("write (state persistence)", () => { + it("does not call write on initial mount (skip first write)", () => { + const synchronizer = createMockSynchronizer({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + pageSize: 50, + }); + + renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + synchronizer, + }), + ); + + expect(synchronizer.write).not.toHaveBeenCalled(); + }); + it("calls write on filter change", () => { const synchronizer = createMockSynchronizer(undefined); @@ -141,14 +197,13 @@ describe("useCollectionVariables with synchronizer", () => { ); }); - it("does not call write when no synchronizer is provided", () => { + it("does not crash when no synchronizer is provided", () => { const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } })); act(() => { result.current.control.addFilter("status", "eq", "ACTIVE"); }); - // No error thrown — just verifying it doesn't crash expect(result.current.control.filters).toHaveLength(1); }); }); diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 60aec771..d2fee965 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useReducer } from "react"; import type { BuildQueryVariables, CollectionControl, @@ -6,7 +6,6 @@ import type { Filter, FilterOperator, PaginationVariables, - SortState, TableFieldName, TableMetadata, TableMetadataFilter, @@ -15,6 +14,7 @@ import type { UseCollectionReturn, } from "@/types/collection"; import { useCursorPagination } from "./use-cursor-pagination"; +import { collectionReducer, useSynchronizerBridge } from "./use-collection-state"; // ----------------------------------------------------------------------------- // Case-insensitive regex conversion helpers @@ -134,49 +134,39 @@ export function useCollectionVariables( const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; // --------------------------------------------------------------------------- - // Hydrate initial state from synchronizer (read once on mount) + // State (reducer-based) // --------------------------------------------------------------------------- - const syncInitial = useRef(() => synchronizer?.read()).current(); - const resolvedInitialFilters = syncInitial?.filters ?? initialFilters; - const resolvedInitialSort = syncInitial?.sort ?? initialSort; - const resolvedInitialPageSize = syncInitial?.pageSize ?? initialPageSize; + const [state, dispatch] = useReducer(collectionReducer, { + filters: initialFilters, + sortStates: initialSort, + pageSize: initialPageSize, + source: "init", + }); + + const { filters, sortStates, pageSize } = state; // --------------------------------------------------------------------------- - // State + // Synchronizer bridge (subscribe + write-back) // --------------------------------------------------------------------------- - const [filters, setFiltersState] = useState(resolvedInitialFilters); - const [sortStates, setSortStates] = useState(resolvedInitialSort); + useSynchronizerBridge(synchronizer, state, dispatch); + // --------------------------------------------------------------------------- + // Cursor pagination (pageSize owned by reducer, passed in) + // --------------------------------------------------------------------------- const { - pageSize, paginationVariables, goToNextPage, goToPrevPage, resetPage, goToFirstPage, goToLastPage, - setPageSize, getHasPrevPage, getHasNextPage, resetCount, - } = useCursorPagination(resolvedInitialPageSize); - - // --------------------------------------------------------------------------- - // Synchronizer write-back - // --------------------------------------------------------------------------- - const synchronizerRef = useRef(synchronizer); - synchronizerRef.current = synchronizer; - - useEffect(() => { - synchronizerRef.current?.write({ - filters, - sort: sortStates, - pageSize, - }); - }, [filters, sortStates, pageSize]); + } = useCursorPagination(pageSize); // --------------------------------------------------------------------------- - // Filter operations + // Control actions (dispatch user actions + reset pagination) // --------------------------------------------------------------------------- const addFilter = useCallback( ( @@ -185,20 +175,12 @@ export function useCollectionVariables( value: unknown, filterOptions?: { caseSensitive?: boolean }, ) => { - setFiltersState((prev) => { - const existing = prev.findIndex((f) => f.field === field); - const newFilter: Filter = { - field, - operator, - value, - caseSensitive: filterOptions?.caseSensitive, - }; - if (existing >= 0) { - const updated = [...prev]; - updated[existing] = newFilter; - return updated; - } - return [...prev, newFilter]; + dispatch({ + type: "ADD_FILTER", + field, + operator, + value, + caseSensitive: filterOptions?.caseSensitive, }); resetPage(); }, @@ -207,7 +189,7 @@ export function useCollectionVariables( const setFilters = useCallback( (newFilters: Filter[]) => { - setFiltersState(newFilters); + dispatch({ type: "SET_FILTERS", filters: newFilters }); resetPage(); }, [resetPage], @@ -215,40 +197,38 @@ export function useCollectionVariables( const removeFilter = useCallback( (field: string) => { - setFiltersState((prev) => prev.filter((f) => f.field !== field)); + dispatch({ type: "REMOVE_FILTER", field }); resetPage(); }, [resetPage], ); const clearFilters = useCallback(() => { - setFiltersState([]); + dispatch({ type: "CLEAR_FILTERS" }); resetPage(); }, [resetPage]); - // --------------------------------------------------------------------------- - // Sort operations - // --------------------------------------------------------------------------- const setSort = useCallback( (field: string, direction?: "Asc" | "Desc") => { - setSortStates((prev) => { - if (direction === undefined) { - return prev.filter((s) => s.field !== field); - } - const newState: SortState = { field, direction }; - const filtered = prev.filter((s) => s.field !== field); - return [...filtered, newState]; - }); + dispatch({ type: "SET_SORT", field, direction }); resetPage(); }, [resetPage], ); const clearSort = useCallback(() => { - setSortStates([]); + dispatch({ type: "CLEAR_SORT" }); resetPage(); }, [resetPage]); + const setPageSize = useCallback( + (size: number) => { + dispatch({ type: "SET_PAGE_SIZE", pageSize: size }); + resetPage(); + }, + [resetPage], + ); + // --------------------------------------------------------------------------- // Build collection variables (Tailor Platform format) // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-cursor-pagination.test.ts b/packages/core/src/hooks/use-cursor-pagination.test.ts index 93930068..c5bad3b5 100644 --- a/packages/core/src/hooks/use-cursor-pagination.test.ts +++ b/packages/core/src/hooks/use-cursor-pagination.test.ts @@ -12,7 +12,6 @@ describe("useCursorPagination", () => { expect(result.current.cursor).toBeNull(); expect(result.current.cursorStack).toEqual([]); expect(result.current.paginationDirection).toBe("forward"); - expect(result.current.pageSize).toBe(20); }); it("returns correct initial paginationVariables", () => { @@ -184,24 +183,6 @@ describe("useCursorPagination", () => { }); }); - // --------------------------------------------------------------------------- - // setPageSize - // --------------------------------------------------------------------------- - describe("setPageSize", () => { - it("changes page size and resets to first page", () => { - const { result } = renderHook(() => useCursorPagination(20)); - - act(() => result.current.goToNextPage({ endCursor: "c1" })); - act(() => result.current.setPageSize(50)); - - expect(result.current.pageSize).toBe(50); - expect(result.current.cursor).toBeNull(); - expect(result.current.cursorStack).toEqual([]); - expect(result.current.paginationDirection).toBe("forward"); - expect(result.current.paginationVariables).toEqual({ first: 50 }); - }); - }); - // --------------------------------------------------------------------------- // paginationVariables shape // --------------------------------------------------------------------------- @@ -299,11 +280,5 @@ describe("useCursorPagination", () => { act(() => result.current.goToLastPage()); expect(result.current.resetCount).toBe(0); }); - - it("does not increment on setPageSize", () => { - const { result } = renderHook(() => useCursorPagination(20)); - act(() => result.current.setPageSize(50)); - expect(result.current.resetCount).toBe(0); - }); }); }); diff --git a/packages/core/src/hooks/use-cursor-pagination.ts b/packages/core/src/hooks/use-cursor-pagination.ts index 4913a726..9a2e6054 100644 --- a/packages/core/src/hooks/use-cursor-pagination.ts +++ b/packages/core/src/hooks/use-cursor-pagination.ts @@ -82,8 +82,6 @@ export interface UseCursorPaginationReturn { cursorStack: string[]; /** Current fetch direction — determines whether `first`/`after` or `last`/`before` is sent. */ paginationDirection: "forward" | "backward"; - /** Current page size. */ - pageSize: number; /** Ready-to-spread pagination variables for the GraphQL query. */ paginationVariables: PaginationVariables; /** @@ -116,8 +114,6 @@ export interface UseCursorPaginationReturn { * back to a full `pageSize` fetch. */ goToLastPage: (total?: number | null) => void; - /** Change the page size and reset to the first page. */ - setPageSize: (size: number) => void; /** * Determine whether a previous page exists, given the server's `pageInfo`. * @@ -157,8 +153,7 @@ export interface UseCursorPaginationReturn { * * @see {@link file://./use-collection-variables.ts useCollectionVariables} — the primary consumer. */ -export function useCursorPagination(initialPageSize: number): UseCursorPaginationReturn { - const [pageSize, setPageSizeState] = useState(initialPageSize); +export function useCursorPagination(pageSize: number): UseCursorPaginationReturn { const [cursor, setCursor] = useState(null); const [cursorStack, setCursorStack] = useState([]); const [paginationDirection, setPaginationDirection] = useState<"forward" | "backward">("forward"); @@ -258,14 +253,6 @@ export function useCursorPagination(initialPageSize: number): UseCursorPaginatio [pageSize], ); - const setPageSize = useCallback((size: number) => { - setPageSizeState(size); - setCursor(null); - setCursorStack([]); - setPaginationDirection("forward"); - setLastPageSize(null); - }, []); - // --------------------------------------------------------------------------- // Computed pagination variables // --------------------------------------------------------------------------- @@ -303,14 +290,12 @@ export function useCursorPagination(initialPageSize: number): UseCursorPaginatio cursor, cursorStack, paginationDirection, - pageSize, paginationVariables, goToNextPage, goToPrevPage, resetPage: resetPagination, goToFirstPage, goToLastPage, - setPageSize, getHasPrevPage, getHasNextPage, resetCount, diff --git a/packages/core/src/hooks/use-search-params-synchronizer.test.tsx b/packages/core/src/hooks/use-search-params-synchronizer.test.tsx index e672c238..e01356d5 100644 --- a/packages/core/src/hooks/use-search-params-synchronizer.test.tsx +++ b/packages/core/src/hooks/use-search-params-synchronizer.test.tsx @@ -2,6 +2,7 @@ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { MemoryRouter } from "react-router"; import type { ReactNode } from "react"; +import type { CollectionSnapshot } from "@/types/collection"; import { useSearchParamsSynchronizer } from "./use-search-params-synchronizer"; function createWrapper(initialEntries: string[] = ["/"]) { @@ -10,86 +11,93 @@ function createWrapper(initialEntries: string[] = ["/"]) { ); } +/** Helper: subscribe and capture the immediately-emitted snapshot */ +function getInitialSnapshot(result: { current: ReturnType }) { + let snapshot: CollectionSnapshot | undefined; + act(() => { + const unsub = result.current.subscribe((s) => { + snapshot = s; + }); + unsub(); + }); + return snapshot; +} + describe("useSearchParamsSynchronizer", () => { // --------------------------------------------------------------------------- - // read() + // subscribe (initial emit) // --------------------------------------------------------------------------- - describe("read", () => { - it("returns undefined when no search params exist", () => { + describe("subscribe (initial emit)", () => { + it("emits undefined when no search params exist", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/"]), }); - expect(result.current.read()).toBeUndefined(); + expect(getInitialSnapshot(result)).toBeUndefined(); }); - it("reads pageSize from URL", () => { + it("emits pageSize from URL", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/?p=50"]), }); - const snapshot = result.current.read(); - expect(snapshot?.pageSize).toBe(50); + expect(getInitialSnapshot(result)?.pageSize).toBe(50); }); - it("reads sort from URL", () => { + it("emits sort from URL", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/?s=name:asc"]), }); - const snapshot = result.current.read(); - expect(snapshot?.sort).toEqual([{ field: "name", direction: "Asc" }]); + expect(getInitialSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Asc" }]); }); - it("reads sort desc from URL", () => { + it("emits sort desc from URL", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/?s=createdAt:desc"]), }); - const snapshot = result.current.read(); - expect(snapshot?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); + expect(getInitialSnapshot(result)?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); }); - it("reads single-value filter from URL", () => { + it("emits single-value filter from URL", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/?f.status:eq=ACTIVE"]), }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([{ field: "status", operator: "eq", value: "ACTIVE" }]); + expect(getInitialSnapshot(result)?.filters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); }); - it("reads multi-value filter from URL (repeated params)", () => { + it("emits multi-value filter from URL (repeated params)", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/?f.status:in=ACTIVE&f.status:in=PENDING"]), }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([ + expect(getInitialSnapshot(result)?.filters).toEqual([ { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, ]); }); - it("reads JSON object filter value (e.g. between)", () => { + it("emits JSON object filter value (e.g. between)", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper([ `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`, ]), }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([ + expect(getInitialSnapshot(result)?.filters).toEqual([ { field: "amount", operator: "between", value: { min: 10, max: 100 } }, ]); }); - it("reads all state together", () => { + it("emits all state together", () => { const { result } = renderHook(() => useSearchParamsSynchronizer(), { wrapper: createWrapper(["/?p=25&s=name:desc&f.status:eq=ACTIVE"]), }); - const snapshot = result.current.read(); - expect(snapshot).toEqual({ + expect(getInitialSnapshot(result)).toEqual({ pageSize: 25, sort: [{ field: "name", direction: "Desc" }], filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], @@ -101,8 +109,7 @@ describe("useSearchParamsSynchronizer", () => { wrapper: createWrapper(["/?t1.p=30&p=10"]), }); - const snapshot = result.current.read(); - expect(snapshot?.pageSize).toBe(30); + expect(getInitialSnapshot(result)?.pageSize).toBe(30); }); it("ignores invalid pageSize values", () => { @@ -110,7 +117,7 @@ describe("useSearchParamsSynchronizer", () => { wrapper: createWrapper(["/?p=abc"]), }); - expect(result.current.read()).toBeUndefined(); + expect(getInitialSnapshot(result)).toBeUndefined(); }); it("ignores filter keys without operator", () => { @@ -118,7 +125,7 @@ describe("useSearchParamsSynchronizer", () => { wrapper: createWrapper(["/?f.status=ACTIVE"]), }); - expect(result.current.read()).toBeUndefined(); + expect(getInitialSnapshot(result)).toBeUndefined(); }); }); @@ -135,8 +142,7 @@ describe("useSearchParamsSynchronizer", () => { result.current.write({ filters: [], sort: [], pageSize: 50 }); }); - const snapshot = result.current.read(); - expect(snapshot?.pageSize).toBe(50); + expect(getInitialSnapshot(result)?.pageSize).toBe(50); }); it("writes sort to URL", () => { @@ -152,8 +158,7 @@ describe("useSearchParamsSynchronizer", () => { }); }); - const snapshot = result.current.read(); - expect(snapshot?.sort).toEqual([{ field: "name", direction: "Desc" }]); + expect(getInitialSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Desc" }]); }); it("writes single-value filter to URL", () => { @@ -169,8 +174,9 @@ describe("useSearchParamsSynchronizer", () => { }); }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([{ field: "status", operator: "eq", value: "ACTIVE" }]); + expect(getInitialSnapshot(result)?.filters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); }); it("writes multi-value filter as repeated params", () => { @@ -186,8 +192,7 @@ describe("useSearchParamsSynchronizer", () => { }); }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([ + expect(getInitialSnapshot(result)?.filters).toEqual([ { field: "status", operator: "in", value: ["A", "B", "C"] }, ]); }); @@ -199,14 +204,19 @@ describe("useSearchParamsSynchronizer", () => { act(() => { result.current.write({ - filters: [{ field: "amount", operator: "between", value: { min: 1, max: 99 } }], + filters: [ + { + field: "amount", + operator: "between", + value: { min: 1, max: 99 }, + }, + ], sort: [], pageSize: 20, }); }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([ + expect(getInitialSnapshot(result)?.filters).toEqual([ { field: "amount", operator: "between", value: { min: 1, max: 99 } }, ]); }); @@ -224,8 +234,9 @@ describe("useSearchParamsSynchronizer", () => { }); }); - const snapshot = result.current.read(); - expect(snapshot?.filters).toEqual([{ field: "name", operator: "contains", value: "test" }]); + expect(getInitialSnapshot(result)?.filters).toEqual([ + { field: "name", operator: "contains", value: "test" }, + ]); }); it("uses prefix when writing", () => { @@ -237,8 +248,7 @@ describe("useSearchParamsSynchronizer", () => { result.current.write({ filters: [], sort: [], pageSize: 40 }); }); - const snapshot = result.current.read(); - expect(snapshot?.pageSize).toBe(40); + expect(getInitialSnapshot(result)?.pageSize).toBe(40); }); }); @@ -258,14 +268,14 @@ describe("useSearchParamsSynchronizer", () => { }); // Not yet written - expect(result.current.read()).toBeUndefined(); + expect(getInitialSnapshot(result)).toBeUndefined(); act(() => { vi.advanceTimersByTime(100); }); // Now written - expect(result.current.read()?.pageSize).toBe(50); + expect(getInitialSnapshot(result)?.pageSize).toBe(50); vi.useRealTimers(); }); @@ -287,7 +297,7 @@ describe("useSearchParamsSynchronizer", () => { vi.advanceTimersByTime(100); }); - expect(result.current.read()?.pageSize).toBe(50); + expect(getInitialSnapshot(result)?.pageSize).toBe(50); vi.useRealTimers(); }); diff --git a/packages/core/src/hooks/use-search-params-synchronizer.ts b/packages/core/src/hooks/use-search-params-synchronizer.ts index f3a393bd..bb3a655c 100644 --- a/packages/core/src/hooks/use-search-params-synchronizer.ts +++ b/packages/core/src/hooks/use-search-params-synchronizer.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useSearchParams } from "react-router"; import type { CollectionSnapshot, @@ -41,69 +41,38 @@ export function useSearchParamsSynchronizer( const { prefix = "", debounceMs } = options; const [searchParams, setSearchParams] = useSearchParams(); const timerRef = useRef | null>(null); + const onChangeRef = useRef<((snapshot: CollectionSnapshot | undefined) => void) | null>(null); + const isInternalWriteRef = useRef(false); const prefixedKey = useCallback((key: string) => (prefix ? `${prefix}.${key}` : key), [prefix]); - const read = useCallback((): CollectionSnapshot | undefined => { - const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); - const sortKey = prefixedKey(KEY_SORT); - const filterPrefix = prefixedKey(FILTER_PREFIX); - - let hasAny = false; - const snapshot: CollectionSnapshot = {}; - - // Page size - const pageSize = searchParams.get(pageSizeKey); - if (pageSize) { - const n = Number(pageSize); - if (Number.isFinite(n) && n > 0) { - snapshot.pageSize = n; - hasAny = true; - } + // Notify subscriber when searchParams change externally (e.g. popstate) + useEffect(() => { + if (isInternalWriteRef.current) { + isInternalWriteRef.current = false; + return; } - - // Sort - const sort = searchParams.get(sortKey); - if (sort) { - const [field, rawDir] = sort.split(":"); - if (field) { - const direction = rawDir === "desc" ? "Desc" : "Asc"; - snapshot.sort = [{ field, direction } as SortState]; - hasAny = true; - } + if (onChangeRef.current) { + onChangeRef.current(readFromParams(searchParams, prefixedKey)); } - - // Filters - const filterKeys = new Set(); - for (const key of searchParams.keys()) { - if (key.startsWith(filterPrefix)) filterKeys.add(key); - } - if (filterKeys.size > 0) { - const filters: Filter[] = []; - for (const key of filterKeys) { - const values = searchParams.getAll(key).filter((v) => v !== ""); - if (values.length === 0) continue; - const remainder = key.slice(filterPrefix.length); - const [field, operator] = remainder.split(":"); - if (!field || !operator) continue; - filters.push({ - field, - operator: operator as Filter["operator"], - value: values.length === 1 ? parseFilterValue(values[0]) : values.map(parseFilterValue), - }); - } - if (filters.length > 0) { - snapshot.filters = filters; - hasAny = true; - } - } - - return hasAny ? snapshot : undefined; }, [searchParams, prefixedKey]); + const subscribe = useCallback( + (onChange: (snapshot: CollectionSnapshot | undefined) => void): (() => void) => { + onChangeRef.current = onChange; + // Immediately emit current state (BehaviorSubject style) + onChange(readFromParams(searchParams, prefixedKey)); + return () => { + onChangeRef.current = null; + }; + }, + [searchParams, prefixedKey], + ); + const write = useCallback( (state: Required): void => { const doWrite = () => { + isInternalWriteRef.current = true; const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); const sortKey = prefixedKey(KEY_SORT); const filterPrefix = prefixedKey(FILTER_PREFIX); @@ -163,15 +132,74 @@ export function useSearchParamsSynchronizer( [prefixedKey, setSearchParams, debounceMs], ); - return { read, write }; + return { subscribe, write }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- +function readFromParams( + searchParams: URLSearchParams, + prefixedKey: (key: string) => string, +): CollectionSnapshot | undefined { + const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); + const sortKey = prefixedKey(KEY_SORT); + const filterPrefix = prefixedKey(FILTER_PREFIX); + + let hasAny = false; + const snapshot: CollectionSnapshot = {}; + + // Page size + const pageSize = searchParams.get(pageSizeKey); + if (pageSize) { + const n = Number(pageSize); + if (Number.isFinite(n) && n > 0) { + snapshot.pageSize = n; + hasAny = true; + } + } + + // Sort + const sort = searchParams.get(sortKey); + if (sort) { + const [field, rawDir] = sort.split(":"); + if (field) { + const direction = rawDir === "desc" ? "Desc" : "Asc"; + snapshot.sort = [{ field, direction } as SortState]; + hasAny = true; + } + } + + // Filters + const filterKeys = new Set(); + for (const key of searchParams.keys()) { + if (key.startsWith(filterPrefix)) filterKeys.add(key); + } + if (filterKeys.size > 0) { + const filters: Filter[] = []; + for (const key of filterKeys) { + const values = searchParams.getAll(key).filter((v) => v !== ""); + if (values.length === 0) continue; + const remainder = key.slice(filterPrefix.length); + const [field, operator] = remainder.split(":"); + if (!field || !operator) continue; + filters.push({ + field, + operator: operator as Filter["operator"], + value: values.length === 1 ? parseFilterValue(values[0]) : values.map(parseFilterValue), + }); + } + if (filters.length > 0) { + snapshot.filters = filters; + hasAny = true; + } + } + + return hasAny ? snapshot : undefined; +} + function parseFilterValue(raw: string): unknown { - // Attempt JSON parse for objects (e.g. `{"min":1,"max":10}`) if (raw.startsWith("{") || raw.startsWith("[")) { try { return JSON.parse(raw); diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index e7dd5bab..b97a3850 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -363,13 +363,19 @@ export interface CollectionSnapshot { * Pluggable synchronizer that persists collection state to an external store * (URL search params, localStorage, server, etc.). * - * - `read()` is called synchronously on mount to hydrate initial state. + * - `subscribe()` registers a listener for external state changes (e.g. popstate). + * Called immediately with the current snapshot on subscription (BehaviorSubject style). * - `write()` is called on every state change. Implementations may internally * debounce, batch, or perform async operations (fire-and-forget). */ export interface CollectionStateSynchronizer { - /** Read persisted state synchronously. Returns undefined if nothing is persisted. */ - read(): CollectionSnapshot | undefined; + /** + * Subscribe to external state changes. The callback is invoked immediately + * with the current persisted snapshot (or undefined), then again whenever + * the external store changes (e.g. browser back/forward). + * Returns an unsubscribe function. + */ + subscribe(onChange: (snapshot: CollectionSnapshot | undefined) => void): () => void; /** Write current state. May be async internally — the hook does not await. */ write(state: Required>): void; } From 61dd4c01a07ac27ff36e57f10c06a83700d09fde Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 26 May 2026 23:05:20 +0900 Subject: [PATCH 3/7] refactor: rename synchronizer to persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CollectionStateSynchronizer → CollectionStatePersistence - useSearchParamsSynchronizer → useSearchParamsPersistence - Simplify to read()/write() pattern (no subscribe) - Inline use-collection-state into use-collection-variables - Remove react-router dependency from persistence layer --- .../src/modules/pages/data-table-demo.tsx | 3 + .../core/src/hooks/use-collection-state.ts | 145 --------- ...-collection-variables-persistence.test.ts} | 100 ++---- .../src/hooks/use-collection-variables.ts | 107 +++++- .../use-search-params-persistence.test.tsx | 264 +++++++++++++++ ...er.ts => use-search-params-persistence.ts} | 131 +++----- .../use-search-params-synchronizer.test.tsx | 305 ------------------ packages/core/src/index.ts | 4 +- packages/core/src/types/collection.ts | 25 +- 9 files changed, 458 insertions(+), 626 deletions(-) delete mode 100644 packages/core/src/hooks/use-collection-state.ts rename packages/core/src/hooks/{use-collection-variables-synchronizer.test.ts => use-collection-variables-persistence.test.ts} (55%) create mode 100644 packages/core/src/hooks/use-search-params-persistence.test.tsx rename packages/core/src/hooks/{use-search-params-synchronizer.ts => use-search-params-persistence.ts} (57%) delete mode 100644 packages/core/src/hooks/use-search-params-synchronizer.test.tsx diff --git a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx index 6b7b5182..702bbae1 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -6,6 +6,7 @@ import { createColumnHelper, Layout, type RowAction, + useSearchParamsPersistence, } from "@tailor-platform/app-shell"; import { useState } from "react"; import { type Product, useProductsQuery } from "./mock-data"; @@ -140,9 +141,11 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { + const persistence = useSearchParamsPersistence({ prefix: "dt1" }); const { variables, control } = useCollectionVariables({ params: { pageSize: 5 }, tableMetadata: productMetadata, + persistence, }); const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); diff --git a/packages/core/src/hooks/use-collection-state.ts b/packages/core/src/hooks/use-collection-state.ts deleted file mode 100644 index ff7df869..00000000 --- a/packages/core/src/hooks/use-collection-state.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { useEffect } from "react"; -import type { - CollectionSnapshot, - CollectionStateSynchronizer, - Filter, - SortState, -} from "@/types/collection"; - -// ============================================================================= -// State & Action types -// ============================================================================= - -export type ChangeSource = "init" | "user" | "sync"; - -export interface CollectionState { - filters: Filter[]; - sortStates: SortState[]; - pageSize: number; - source: ChangeSource; -} - -export type CollectionAction = - | { - type: "ADD_FILTER"; - field: string; - operator: string; - value: unknown; - caseSensitive?: boolean; - } - | { type: "SET_FILTERS"; filters: Filter[] } - | { type: "REMOVE_FILTER"; field: string } - | { type: "CLEAR_FILTERS" } - | { type: "SET_SORT"; field: string; direction?: "Asc" | "Desc" } - | { type: "CLEAR_SORT" } - | { type: "SET_PAGE_SIZE"; pageSize: number } - | { type: "SYNC"; snapshot: CollectionSnapshot | undefined }; - -// ============================================================================= -// Reducer -// ============================================================================= - -export function collectionReducer( - state: CollectionState, - action: CollectionAction, -): CollectionState { - switch (action.type) { - case "SYNC": { - if (!action.snapshot) return state; - return { - ...state, - ...(action.snapshot.filters != null && { - filters: action.snapshot.filters, - }), - ...(action.snapshot.sort != null && { - sortStates: action.snapshot.sort, - }), - ...(action.snapshot.pageSize != null && { - pageSize: action.snapshot.pageSize, - }), - source: "sync", - }; - } - case "ADD_FILTER": { - const { field, operator, value, caseSensitive } = action; - const newFilter: Filter = { - field, - operator: operator as Filter["operator"], - value, - caseSensitive, - }; - const existing = state.filters.findIndex((f) => f.field === field); - const filters = - existing >= 0 - ? state.filters.map((f, i) => (i === existing ? newFilter : f)) - : [...state.filters, newFilter]; - return { ...state, filters, source: "user" }; - } - case "SET_FILTERS": - return { ...state, filters: action.filters, source: "user" }; - case "REMOVE_FILTER": - return { - ...state, - filters: state.filters.filter((f) => f.field !== action.field), - source: "user", - }; - case "CLEAR_FILTERS": - return { ...state, filters: [], source: "user" }; - case "SET_SORT": { - const { field, direction } = action; - if (direction === undefined) { - return { - ...state, - sortStates: state.sortStates.filter((s) => s.field !== field), - source: "user", - }; - } - const filtered = state.sortStates.filter((s) => s.field !== field); - return { - ...state, - sortStates: [...filtered, { field, direction }], - source: "user", - }; - } - case "CLEAR_SORT": - return { ...state, sortStates: [], source: "user" }; - case "SET_PAGE_SIZE": - return { ...state, pageSize: action.pageSize, source: "user" }; - default: - return state; - } -} - -// ============================================================================= -// Synchronizer Bridge Hook -// ============================================================================= - -/** - * Connects a synchronizer to the collection reducer state. - * - * - Subscribes to external changes and dispatches `SYNC` actions. - * - Writes state back to the synchronizer when changes originate from user actions. - */ -export function useSynchronizerBridge( - synchronizer: CollectionStateSynchronizer | undefined, - state: CollectionState, - dispatch: React.Dispatch, -): void { - // Subscribe: external changes → dispatch - useEffect(() => { - if (!synchronizer) return; - return synchronizer.subscribe((snapshot) => { - dispatch({ type: "SYNC", snapshot }); - }); - }, [synchronizer, dispatch]); - - // Write-back: only when source is "user" - useEffect(() => { - if (state.source !== "user") return; - synchronizer?.write({ - filters: state.filters, - sort: state.sortStates, - pageSize: state.pageSize, - }); - }, [state.filters, state.sortStates, state.pageSize, state.source, synchronizer]); -} diff --git a/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts b/packages/core/src/hooks/use-collection-variables-persistence.test.ts similarity index 55% rename from packages/core/src/hooks/use-collection-variables-synchronizer.test.ts rename to packages/core/src/hooks/use-collection-variables-persistence.test.ts index f31e608d..8eabfe1a 100644 --- a/packages/core/src/hooks/use-collection-variables-synchronizer.test.ts +++ b/packages/core/src/hooks/use-collection-variables-persistence.test.ts @@ -1,34 +1,19 @@ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import type { CollectionSnapshot, CollectionStateSynchronizer } from "@/types/collection"; +import type { CollectionSnapshot, CollectionStatePersistence } from "@/types/collection"; import { useCollectionVariables } from "./use-collection-variables"; -describe("useCollectionVariables with synchronizer", () => { - function createMockSynchronizer(initial?: CollectionSnapshot) { - const subscribers: Array<(snapshot: CollectionSnapshot | undefined) => void> = []; +describe("useCollectionVariables with persistence", () => { + function createMockPersistence(initial?: CollectionSnapshot) { return { - subscribe: vi.fn((onChange) => { - subscribers.push(onChange); - // BehaviorSubject: emit immediately - onChange(initial); - return () => { - const idx = subscribers.indexOf(onChange); - if (idx >= 0) subscribers.splice(idx, 1); - }; - }), + read: vi.fn(() => initial), write: vi.fn(), - // Test helper to simulate external change - emit(snapshot: CollectionSnapshot | undefined) { - for (const cb of subscribers) cb(snapshot); - }, - } satisfies CollectionStateSynchronizer & { - emit: (s: CollectionSnapshot | undefined) => void; - }; + } satisfies CollectionStatePersistence; } - describe("subscribe (initial hydration)", () => { - it("uses synchronizer initial state over params defaults", () => { - const synchronizer = createMockSynchronizer({ + describe("read (initial hydration)", () => { + it("uses persistence initial state over params defaults", () => { + const persistence = createMockPersistence({ filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], sort: [{ field: "name", direction: "Asc" }], pageSize: 50, @@ -37,11 +22,11 @@ describe("useCollectionVariables with synchronizer", () => { const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 }, - synchronizer, + persistence, }), ); - expect(synchronizer.subscribe).toHaveBeenCalledOnce(); + expect(persistence.read).toHaveBeenCalledOnce(); expect(result.current.control.filters).toEqual([ { field: "status", operator: "eq", value: "ACTIVE" }, ]); @@ -49,8 +34,8 @@ describe("useCollectionVariables with synchronizer", () => { expect(result.current.control.pageSize).toBe(50); }); - it("falls back to params when synchronizer emits undefined", () => { - const synchronizer = createMockSynchronizer(undefined); + it("falls back to params when persistence returns undefined", () => { + const persistence = createMockPersistence(undefined); const { result } = renderHook(() => useCollectionVariables({ @@ -58,7 +43,7 @@ describe("useCollectionVariables with synchronizer", () => { pageSize: 30, initialSort: [{ field: "createdAt", direction: "Desc" }], }, - synchronizer, + persistence, }), ); @@ -69,8 +54,8 @@ describe("useCollectionVariables with synchronizer", () => { expect(result.current.control.filters).toEqual([]); }); - it("partially overrides params (only pageSize from synchronizer)", () => { - const synchronizer = createMockSynchronizer({ + it("partially overrides params (only pageSize from persistence)", () => { + const persistence = createMockPersistence({ pageSize: 100, }); @@ -80,44 +65,19 @@ describe("useCollectionVariables with synchronizer", () => { pageSize: 20, initialSort: [{ field: "name", direction: "Asc" }], }, - synchronizer, + persistence, }), ); expect(result.current.control.pageSize).toBe(100); - // Sort remains from params since synchronizer didn't provide it + // Sort remains from params since persistence didn't provide it expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); }); }); - describe("subscribe (external changes)", () => { - it("updates state when synchronizer emits external change", () => { - const synchronizer = createMockSynchronizer(undefined); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { pageSize: 20 }, - synchronizer, - }), - ); - - act(() => { - synchronizer.emit({ - filters: [{ field: "name", operator: "contains", value: "test" }], - pageSize: 50, - }); - }); - - expect(result.current.control.filters).toEqual([ - { field: "name", operator: "contains", value: "test" }, - ]); - expect(result.current.control.pageSize).toBe(50); - }); - }); - describe("write (state persistence)", () => { it("does not call write on initial mount (skip first write)", () => { - const synchronizer = createMockSynchronizer({ + const persistence = createMockPersistence({ filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], pageSize: 50, }); @@ -125,20 +85,20 @@ describe("useCollectionVariables with synchronizer", () => { renderHook(() => useCollectionVariables({ params: { pageSize: 20 }, - synchronizer, + persistence, }), ); - expect(synchronizer.write).not.toHaveBeenCalled(); + expect(persistence.write).not.toHaveBeenCalled(); }); it("calls write on filter change", () => { - const synchronizer = createMockSynchronizer(undefined); + const persistence = createMockPersistence(undefined); const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 }, - synchronizer, + persistence, }), ); @@ -146,7 +106,7 @@ describe("useCollectionVariables with synchronizer", () => { result.current.control.addFilter("status", "eq", "ACTIVE"); }); - expect(synchronizer.write).toHaveBeenCalledWith( + expect(persistence.write).toHaveBeenCalledWith( expect.objectContaining({ filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], pageSize: 20, @@ -155,12 +115,12 @@ describe("useCollectionVariables with synchronizer", () => { }); it("calls write on sort change", () => { - const synchronizer = createMockSynchronizer(undefined); + const persistence = createMockPersistence(undefined); const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 }, - synchronizer, + persistence, }), ); @@ -168,7 +128,7 @@ describe("useCollectionVariables with synchronizer", () => { result.current.control.setSort("name", "Desc"); }); - expect(synchronizer.write).toHaveBeenCalledWith( + expect(persistence.write).toHaveBeenCalledWith( expect.objectContaining({ sort: [{ field: "name", direction: "Desc" }], pageSize: 20, @@ -177,12 +137,12 @@ describe("useCollectionVariables with synchronizer", () => { }); it("calls write on pageSize change", () => { - const synchronizer = createMockSynchronizer(undefined); + const persistence = createMockPersistence(undefined); const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 }, - synchronizer, + persistence, }), ); @@ -190,14 +150,14 @@ describe("useCollectionVariables with synchronizer", () => { result.current.control.setPageSize(50); }); - expect(synchronizer.write).toHaveBeenCalledWith( + expect(persistence.write).toHaveBeenCalledWith( expect.objectContaining({ pageSize: 50, }), ); }); - it("does not crash when no synchronizer is provided", () => { + it("does not crash when no persistence is provided", () => { const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } })); act(() => { diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index d2fee965..c7234cb5 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useReducer } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import type { BuildQueryVariables, CollectionControl, @@ -6,6 +6,7 @@ import type { Filter, FilterOperator, PaginationVariables, + SortState, TableFieldName, TableMetadata, TableMetadataFilter, @@ -14,7 +15,80 @@ import type { UseCollectionReturn, } from "@/types/collection"; import { useCursorPagination } from "./use-cursor-pagination"; -import { collectionReducer, useSynchronizerBridge } from "./use-collection-state"; + +// ----------------------------------------------------------------------------- +// Reducer +// ----------------------------------------------------------------------------- + +interface CollectionState { + filters: Filter[]; + sortStates: SortState[]; + pageSize: number; +} + +type CollectionAction = + | { + type: "ADD_FILTER"; + field: string; + operator: string; + value: unknown; + caseSensitive?: boolean; + } + | { type: "SET_FILTERS"; filters: Filter[] } + | { type: "REMOVE_FILTER"; field: string } + | { type: "CLEAR_FILTERS" } + | { type: "SET_SORT"; field: string; direction?: "Asc" | "Desc" } + | { type: "CLEAR_SORT" } + | { type: "SET_PAGE_SIZE"; pageSize: number }; + +function collectionReducer(state: CollectionState, action: CollectionAction): CollectionState { + switch (action.type) { + case "ADD_FILTER": { + const { field, operator, value, caseSensitive } = action; + const newFilter: Filter = { + field, + operator: operator as Filter["operator"], + value, + caseSensitive, + }; + const existing = state.filters.findIndex((f) => f.field === field); + const filters = + existing >= 0 + ? state.filters.map((f, i) => (i === existing ? newFilter : f)) + : [...state.filters, newFilter]; + return { ...state, filters }; + } + case "SET_FILTERS": + return { ...state, filters: action.filters }; + case "REMOVE_FILTER": + return { + ...state, + filters: state.filters.filter((f) => f.field !== action.field), + }; + case "CLEAR_FILTERS": + return { ...state, filters: [] }; + case "SET_SORT": { + const { field, direction } = action; + if (direction === undefined) { + return { + ...state, + sortStates: state.sortStates.filter((s) => s.field !== field), + }; + } + const filtered = state.sortStates.filter((s) => s.field !== field); + return { + ...state, + sortStates: [...filtered, { field, direction }], + }; + } + case "CLEAR_SORT": + return { ...state, sortStates: [] }; + case "SET_PAGE_SIZE": + return { ...state, pageSize: action.pageSize }; + default: + return state; + } +} // ----------------------------------------------------------------------------- // Case-insensitive regex conversion helpers @@ -130,25 +204,38 @@ export function useCollectionVariables( export function useCollectionVariables( options: UseCollectionOptions & { tableMetadata?: TableMetadata }, ): unknown { - const { params = {}, synchronizer } = options; + const { params = {}, persistence } = options; const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; // --------------------------------------------------------------------------- // State (reducer-based) // --------------------------------------------------------------------------- - const [state, dispatch] = useReducer(collectionReducer, { - filters: initialFilters, - sortStates: initialSort, - pageSize: initialPageSize, - source: "init", + const [state, dispatch] = useReducer(collectionReducer, undefined, () => { + const snapshot = persistence?.read(); + return { + filters: snapshot?.filters ?? initialFilters, + sortStates: snapshot?.sort ?? initialSort, + pageSize: snapshot?.pageSize ?? initialPageSize, + }; }); const { filters, sortStates, pageSize } = state; // --------------------------------------------------------------------------- - // Synchronizer bridge (subscribe + write-back) + // Persistence write-back (skip initial render) // --------------------------------------------------------------------------- - useSynchronizerBridge(synchronizer, state, dispatch); + const isFirstRenderRef = useRef(true); + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + return; + } + persistence?.write({ + filters: state.filters, + sort: state.sortStates, + pageSize: state.pageSize, + }); + }, [state.filters, state.sortStates, state.pageSize, persistence]); // --------------------------------------------------------------------------- // Cursor pagination (pageSize owned by reducer, passed in) diff --git a/packages/core/src/hooks/use-search-params-persistence.test.tsx b/packages/core/src/hooks/use-search-params-persistence.test.tsx new file mode 100644 index 00000000..d2cc7c6c --- /dev/null +++ b/packages/core/src/hooks/use-search-params-persistence.test.tsx @@ -0,0 +1,264 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useSearchParamsPersistence } from "./use-search-params-persistence"; + +beforeEach(() => { + // Reset URL to root before each test + window.history.replaceState(null, "", "/"); +}); + +/** Helper: read current snapshot */ +function readSnapshot(result: { current: ReturnType }) { + return result.current.read(); +} + +describe("useSearchParamsPersistence", () => { + // --------------------------------------------------------------------------- + // read (initial) + // --------------------------------------------------------------------------- + describe("read (initial)", () => { + it("returns undefined when no search params exist", () => { + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)).toBeUndefined(); + }); + + it("returns pageSize from URL", () => { + window.history.replaceState(null, "", "/?p=50"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)?.pageSize).toBe(50); + }); + + it("returns sort from URL", () => { + window.history.replaceState(null, "", "/?s=name:asc"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Asc" }]); + }); + + it("returns sort desc from URL", () => { + window.history.replaceState(null, "", "/?s=createdAt:desc"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); + }); + + it("returns single-value filter from URL", () => { + window.history.replaceState(null, "", "/?f.status:eq=ACTIVE"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); + }); + + it("returns multi-value filter from URL (repeated params)", () => { + window.history.replaceState(null, "", "/?f.status:in=ACTIVE&f.status:in=PENDING"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, + ]); + }); + + it("returns JSON object filter value (e.g. between)", () => { + const url = `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`; + window.history.replaceState(null, "", url); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "amount", operator: "between", value: { min: 10, max: 100 } }, + ]); + }); + + it("returns all state together", () => { + window.history.replaceState(null, "", "/?p=25&s=name:desc&f.status:eq=ACTIVE"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)).toEqual({ + pageSize: 25, + sort: [{ field: "name", direction: "Desc" }], + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + }); + }); + + it("respects prefix option", () => { + window.history.replaceState(null, "", "/?t1.p=30&p=10"); + const { result } = renderHook(() => useSearchParamsPersistence({ prefix: "t1" })); + + expect(readSnapshot(result)?.pageSize).toBe(30); + }); + + it("ignores invalid pageSize values", () => { + window.history.replaceState(null, "", "/?p=abc"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)).toBeUndefined(); + }); + + it("ignores filter keys without operator", () => { + window.history.replaceState(null, "", "/?f.status=ACTIVE"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + expect(readSnapshot(result)).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // write() + // --------------------------------------------------------------------------- + describe("write", () => { + it("writes pageSize to URL", () => { + const { result } = renderHook(() => useSearchParamsPersistence()); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + expect(readSnapshot(result)?.pageSize).toBe(50); + }); + + it("writes sort to URL", () => { + const { result } = renderHook(() => useSearchParamsPersistence()); + + act(() => { + result.current.write({ + filters: [], + sort: [{ field: "name", direction: "Desc" }], + pageSize: 20, + }); + }); + + expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Desc" }]); + }); + + it("writes single-value filter to URL", () => { + const { result } = renderHook(() => useSearchParamsPersistence()); + + act(() => { + result.current.write({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + sort: [], + pageSize: 20, + }); + }); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); + }); + + it("writes multi-value filter as repeated params", () => { + const { result } = renderHook(() => useSearchParamsPersistence()); + + act(() => { + result.current.write({ + filters: [{ field: "status", operator: "in", value: ["A", "B", "C"] }], + sort: [], + pageSize: 20, + }); + }); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "status", operator: "in", value: ["A", "B", "C"] }, + ]); + }); + + it("writes object filter value as JSON", () => { + const { result } = renderHook(() => useSearchParamsPersistence()); + + act(() => { + result.current.write({ + filters: [ + { + field: "amount", + operator: "between", + value: { min: 1, max: 99 }, + }, + ], + sort: [], + pageSize: 20, + }); + }); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "amount", operator: "between", value: { min: 1, max: 99 } }, + ]); + }); + + it("clears old filters when writing new state", () => { + window.history.replaceState(null, "", "/?f.status:eq=OLD"); + const { result } = renderHook(() => useSearchParamsPersistence()); + + act(() => { + result.current.write({ + filters: [{ field: "name", operator: "contains", value: "test" }], + sort: [], + pageSize: 20, + }); + }); + + expect(readSnapshot(result)?.filters).toEqual([ + { field: "name", operator: "contains", value: "test" }, + ]); + }); + + it("uses prefix when writing", () => { + const { result } = renderHook(() => useSearchParamsPersistence({ prefix: "t1" })); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 40 }); + }); + + expect(readSnapshot(result)?.pageSize).toBe(40); + }); + }); + + // --------------------------------------------------------------------------- + // debounce + // --------------------------------------------------------------------------- + describe("debounce", () => { + it("debounces write when debounceMs is set", () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useSearchParamsPersistence({ debounceMs: 100 })); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + // Not yet written + expect(readSnapshot(result)).toBeUndefined(); + + act(() => { + vi.advanceTimersByTime(100); + }); + + // Now written + expect(readSnapshot(result)?.pageSize).toBe(50); + + vi.useRealTimers(); + }); + + it("only applies the last write within debounce window", () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useSearchParamsPersistence({ debounceMs: 100 })); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 10 }); + result.current.write({ filters: [], sort: [], pageSize: 30 }); + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(readSnapshot(result)?.pageSize).toBe(50); + + vi.useRealTimers(); + }); + }); +}); diff --git a/packages/core/src/hooks/use-search-params-synchronizer.ts b/packages/core/src/hooks/use-search-params-persistence.ts similarity index 57% rename from packages/core/src/hooks/use-search-params-synchronizer.ts rename to packages/core/src/hooks/use-search-params-persistence.ts index bb3a655c..2b09a1cd 100644 --- a/packages/core/src/hooks/use-search-params-synchronizer.ts +++ b/packages/core/src/hooks/use-search-params-persistence.ts @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useSearchParams } from "react-router"; +import { useCallback, useMemo, useRef } from "react"; import type { CollectionSnapshot, - CollectionStateSynchronizer, + CollectionStatePersistence, Filter, SortState, } from "@/types/collection"; @@ -11,7 +10,7 @@ const KEY_PAGE_SIZE = "p"; const KEY_SORT = "s"; const FILTER_PREFIX = "f."; -export interface UseSearchParamsSynchronizerOptions { +export interface UseSearchParamsPersistenceOptions { /** Key prefix to avoid collisions when multiple tables share a page. */ prefix?: string; /** Debounce interval in ms for URL writes. Default: no debounce. */ @@ -19,7 +18,10 @@ export interface UseSearchParamsSynchronizerOptions { } /** - * Synchronizer hook that persists collection state to URL search params. + * Persistence hook that stores collection state in URL search params. + * + * - `read()` parses current `window.location.search` on mount. + * - `write()` updates URL via `window.history.replaceState`. * * URL format: * - Page size: `p=20` @@ -28,98 +30,71 @@ export interface UseSearchParamsSynchronizerOptions { * * @example * ```tsx - * const synchronizer = useSearchParamsSynchronizer(); + * const persistence = useSearchParamsPersistence(); * const { variables, control } = useCollectionVariables({ * params: { pageSize: 20 }, - * synchronizer, + * persistence, * }); * ``` */ -export function useSearchParamsSynchronizer( - options: UseSearchParamsSynchronizerOptions = {}, -): CollectionStateSynchronizer { +export function useSearchParamsPersistence( + options: UseSearchParamsPersistenceOptions = {}, +): CollectionStatePersistence { const { prefix = "", debounceMs } = options; - const [searchParams, setSearchParams] = useSearchParams(); const timerRef = useRef | null>(null); - const onChangeRef = useRef<((snapshot: CollectionSnapshot | undefined) => void) | null>(null); - const isInternalWriteRef = useRef(false); const prefixedKey = useCallback((key: string) => (prefix ? `${prefix}.${key}` : key), [prefix]); - // Notify subscriber when searchParams change externally (e.g. popstate) - useEffect(() => { - if (isInternalWriteRef.current) { - isInternalWriteRef.current = false; - return; - } - if (onChangeRef.current) { - onChangeRef.current(readFromParams(searchParams, prefixedKey)); - } - }, [searchParams, prefixedKey]); - - const subscribe = useCallback( - (onChange: (snapshot: CollectionSnapshot | undefined) => void): (() => void) => { - onChangeRef.current = onChange; - // Immediately emit current state (BehaviorSubject style) - onChange(readFromParams(searchParams, prefixedKey)); - return () => { - onChangeRef.current = null; - }; - }, - [searchParams, prefixedKey], - ); + const read = useCallback((): CollectionSnapshot | undefined => { + return readFromParams(new URLSearchParams(window.location.search), prefixedKey); + }, [prefixedKey]); const write = useCallback( (state: Required): void => { const doWrite = () => { - isInternalWriteRef.current = true; + const params = new URLSearchParams(window.location.search); const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); const sortKey = prefixedKey(KEY_SORT); const filterPrefix = prefixedKey(FILTER_PREFIX); - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - - // Page size - if (state.pageSize) { - next.set(pageSizeKey, String(state.pageSize)); - } else { - next.delete(pageSizeKey); - } - - // Sort - if (state.sort && state.sort.length > 0) { - const { field, direction } = state.sort[0]; - next.set(sortKey, `${field}:${direction === "Desc" ? "desc" : "asc"}`); - } else { - next.delete(sortKey); - } - - // Clear existing filters with this prefix - for (const key of Array.from(next.keys())) { - if (key.startsWith(filterPrefix)) next.delete(key); - } - - // Write filters - if (state.filters) { - for (const filter of state.filters) { - const key = `${filterPrefix}${filter.field}:${filter.operator}`; - if (Array.isArray(filter.value)) { - for (const v of filter.value) { - const encoded = stringifyPrimitive(v); - if (encoded !== "") next.append(key, encoded); - } - } else if (filter.value != null && filter.value !== "") { - next.set(key, encodeFilterValue(filter.value)); - } + // Page size + if (state.pageSize) { + params.set(pageSizeKey, String(state.pageSize)); + } else { + params.delete(pageSizeKey); + } + + // Sort + if (state.sort && state.sort.length > 0) { + const { field, direction } = state.sort[0]; + params.set(sortKey, `${field}:${direction === "Desc" ? "desc" : "asc"}`); + } else { + params.delete(sortKey); + } + + // Clear existing filters with this prefix + for (const key of Array.from(params.keys())) { + if (key.startsWith(filterPrefix)) params.delete(key); + } + + // Write filters + if (state.filters) { + for (const filter of state.filters) { + const key = `${filterPrefix}${filter.field}:${filter.operator}`; + if (Array.isArray(filter.value)) { + for (const v of filter.value) { + const encoded = stringifyPrimitive(v); + if (encoded !== "") params.append(key, encoded); } + } else if (filter.value != null && filter.value !== "") { + params.set(key, encodeFilterValue(filter.value)); } + } + } - return next; - }, - { replace: true }, - ); + const search = params.toString(); + const url = search ? `${window.location.pathname}?${search}` : window.location.pathname; + window.history.replaceState(null, "", url); }; if (debounceMs != null && debounceMs > 0) { @@ -129,10 +104,10 @@ export function useSearchParamsSynchronizer( doWrite(); } }, - [prefixedKey, setSearchParams, debounceMs], + [prefixedKey, debounceMs], ); - return { subscribe, write }; + return useMemo(() => ({ read, write }), [read, write]); } // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-search-params-synchronizer.test.tsx b/packages/core/src/hooks/use-search-params-synchronizer.test.tsx deleted file mode 100644 index e01356d5..00000000 --- a/packages/core/src/hooks/use-search-params-synchronizer.test.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { MemoryRouter } from "react-router"; -import type { ReactNode } from "react"; -import type { CollectionSnapshot } from "@/types/collection"; -import { useSearchParamsSynchronizer } from "./use-search-params-synchronizer"; - -function createWrapper(initialEntries: string[] = ["/"]) { - return ({ children }: { children: ReactNode }) => ( - {children} - ); -} - -/** Helper: subscribe and capture the immediately-emitted snapshot */ -function getInitialSnapshot(result: { current: ReturnType }) { - let snapshot: CollectionSnapshot | undefined; - act(() => { - const unsub = result.current.subscribe((s) => { - snapshot = s; - }); - unsub(); - }); - return snapshot; -} - -describe("useSearchParamsSynchronizer", () => { - // --------------------------------------------------------------------------- - // subscribe (initial emit) - // --------------------------------------------------------------------------- - describe("subscribe (initial emit)", () => { - it("emits undefined when no search params exist", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/"]), - }); - - expect(getInitialSnapshot(result)).toBeUndefined(); - }); - - it("emits pageSize from URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?p=50"]), - }); - - expect(getInitialSnapshot(result)?.pageSize).toBe(50); - }); - - it("emits sort from URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?s=name:asc"]), - }); - - expect(getInitialSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Asc" }]); - }); - - it("emits sort desc from URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?s=createdAt:desc"]), - }); - - expect(getInitialSnapshot(result)?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); - }); - - it("emits single-value filter from URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?f.status:eq=ACTIVE"]), - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "status", operator: "eq", value: "ACTIVE" }, - ]); - }); - - it("emits multi-value filter from URL (repeated params)", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?f.status:in=ACTIVE&f.status:in=PENDING"]), - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, - ]); - }); - - it("emits JSON object filter value (e.g. between)", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper([ - `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`, - ]), - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "amount", operator: "between", value: { min: 10, max: 100 } }, - ]); - }); - - it("emits all state together", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?p=25&s=name:desc&f.status:eq=ACTIVE"]), - }); - - expect(getInitialSnapshot(result)).toEqual({ - pageSize: 25, - sort: [{ field: "name", direction: "Desc" }], - filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], - }); - }); - - it("respects prefix option", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer({ prefix: "t1" }), { - wrapper: createWrapper(["/?t1.p=30&p=10"]), - }); - - expect(getInitialSnapshot(result)?.pageSize).toBe(30); - }); - - it("ignores invalid pageSize values", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?p=abc"]), - }); - - expect(getInitialSnapshot(result)).toBeUndefined(); - }); - - it("ignores filter keys without operator", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?f.status=ACTIVE"]), - }); - - expect(getInitialSnapshot(result)).toBeUndefined(); - }); - }); - - // --------------------------------------------------------------------------- - // write() - // --------------------------------------------------------------------------- - describe("write", () => { - it("writes pageSize to URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ filters: [], sort: [], pageSize: 50 }); - }); - - expect(getInitialSnapshot(result)?.pageSize).toBe(50); - }); - - it("writes sort to URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ - filters: [], - sort: [{ field: "name", direction: "Desc" }], - pageSize: 20, - }); - }); - - expect(getInitialSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Desc" }]); - }); - - it("writes single-value filter to URL", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ - filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], - sort: [], - pageSize: 20, - }); - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "status", operator: "eq", value: "ACTIVE" }, - ]); - }); - - it("writes multi-value filter as repeated params", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ - filters: [{ field: "status", operator: "in", value: ["A", "B", "C"] }], - sort: [], - pageSize: 20, - }); - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "status", operator: "in", value: ["A", "B", "C"] }, - ]); - }); - - it("writes object filter value as JSON", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ - filters: [ - { - field: "amount", - operator: "between", - value: { min: 1, max: 99 }, - }, - ], - sort: [], - pageSize: 20, - }); - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "amount", operator: "between", value: { min: 1, max: 99 } }, - ]); - }); - - it("clears old filters when writing new state", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer(), { - wrapper: createWrapper(["/?f.status:eq=OLD"]), - }); - - act(() => { - result.current.write({ - filters: [{ field: "name", operator: "contains", value: "test" }], - sort: [], - pageSize: 20, - }); - }); - - expect(getInitialSnapshot(result)?.filters).toEqual([ - { field: "name", operator: "contains", value: "test" }, - ]); - }); - - it("uses prefix when writing", () => { - const { result } = renderHook(() => useSearchParamsSynchronizer({ prefix: "t1" }), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ filters: [], sort: [], pageSize: 40 }); - }); - - expect(getInitialSnapshot(result)?.pageSize).toBe(40); - }); - }); - - // --------------------------------------------------------------------------- - // debounce - // --------------------------------------------------------------------------- - describe("debounce", () => { - it("debounces write when debounceMs is set", () => { - vi.useFakeTimers(); - - const { result } = renderHook(() => useSearchParamsSynchronizer({ debounceMs: 100 }), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ filters: [], sort: [], pageSize: 50 }); - }); - - // Not yet written - expect(getInitialSnapshot(result)).toBeUndefined(); - - act(() => { - vi.advanceTimersByTime(100); - }); - - // Now written - expect(getInitialSnapshot(result)?.pageSize).toBe(50); - - vi.useRealTimers(); - }); - - it("only applies the last write within debounce window", () => { - vi.useFakeTimers(); - - const { result } = renderHook(() => useSearchParamsSynchronizer({ debounceMs: 100 }), { - wrapper: createWrapper(["/"]), - }); - - act(() => { - result.current.write({ filters: [], sort: [], pageSize: 10 }); - result.current.write({ filters: [], sort: [], pageSize: 30 }); - result.current.write({ filters: [], sort: [], pageSize: 50 }); - }); - - act(() => { - vi.advanceTimersByTime(100); - }); - - expect(getInitialSnapshot(result)?.pageSize).toBe(50); - - vi.useRealTimers(); - }); - }); -}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77e1473b..8714c726 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,7 +157,7 @@ export { type UseCollectionOptions, type UseCollectionReturn, type CollectionSnapshot, - type CollectionStateSynchronizer, + type CollectionStatePersistence, type FieldType, type FieldMetadata, type TableMetadata, @@ -185,7 +185,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { useSearchParamsSynchronizer } from "./hooks/use-search-params-synchronizer"; +export { useSearchParamsPersistence } from "./hooks/use-search-params-persistence"; export { CollectionControlProvider, useCollectionControl, diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index b97a3850..d9cffc7b 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -347,7 +347,7 @@ export type TableOrderableFieldName = : never; // ============================================================================= -// Collection State Synchronizer +// Collection State Persistence // ============================================================================= /** @@ -360,22 +360,15 @@ export interface CollectionSnapshot { } /** - * Pluggable synchronizer that persists collection state to an external store + * Pluggable persistence layer that stores collection state to an external store * (URL search params, localStorage, server, etc.). * - * - `subscribe()` registers a listener for external state changes (e.g. popstate). - * Called immediately with the current snapshot on subscription (BehaviorSubject style). - * - `write()` is called on every state change. Implementations may internally - * debounce, batch, or perform async operations (fire-and-forget). + * - `read()` is called once on mount to hydrate initial state. + * - `write()` is called on every user-initiated state change. */ -export interface CollectionStateSynchronizer { - /** - * Subscribe to external state changes. The callback is invoked immediately - * with the current persisted snapshot (or undefined), then again whenever - * the external store changes (e.g. browser back/forward). - * Returns an unsubscribe function. - */ - subscribe(onChange: (snapshot: CollectionSnapshot | undefined) => void): () => void; +export interface CollectionStatePersistence { + /** Read persisted state. Called once on mount to hydrate initial state. */ + read(): CollectionSnapshot | undefined; /** Write current state. May be async internally — the hook does not await. */ write(state: Required>): void; } @@ -396,8 +389,8 @@ export interface UseCollectionOptions< initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; pageSize?: number; }; - /** Optional synchronizer to persist collection state to an external store. */ - synchronizer?: CollectionStateSynchronizer; + /** Optional persistence layer to store collection state to an external store. */ + persistence?: CollectionStatePersistence; } /** From d79f68956a89d1b73c7e2c74315e57d9960d3984 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 27 May 2026 00:23:15 +0900 Subject: [PATCH 4/7] refactor: revert useReducer, keep useState with persistence Restore original useState pattern for filters/sort/pageSize to minimize diff from main. Persistence read/write is the only addition. --- .../src/hooks/use-collection-variables.ts | 175 ++++++------------ .../core/src/hooks/use-cursor-pagination.ts | 17 +- 2 files changed, 73 insertions(+), 119 deletions(-) diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index c7234cb5..9043a8d3 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { BuildQueryVariables, CollectionControl, @@ -16,80 +16,6 @@ import type { } from "@/types/collection"; import { useCursorPagination } from "./use-cursor-pagination"; -// ----------------------------------------------------------------------------- -// Reducer -// ----------------------------------------------------------------------------- - -interface CollectionState { - filters: Filter[]; - sortStates: SortState[]; - pageSize: number; -} - -type CollectionAction = - | { - type: "ADD_FILTER"; - field: string; - operator: string; - value: unknown; - caseSensitive?: boolean; - } - | { type: "SET_FILTERS"; filters: Filter[] } - | { type: "REMOVE_FILTER"; field: string } - | { type: "CLEAR_FILTERS" } - | { type: "SET_SORT"; field: string; direction?: "Asc" | "Desc" } - | { type: "CLEAR_SORT" } - | { type: "SET_PAGE_SIZE"; pageSize: number }; - -function collectionReducer(state: CollectionState, action: CollectionAction): CollectionState { - switch (action.type) { - case "ADD_FILTER": { - const { field, operator, value, caseSensitive } = action; - const newFilter: Filter = { - field, - operator: operator as Filter["operator"], - value, - caseSensitive, - }; - const existing = state.filters.findIndex((f) => f.field === field); - const filters = - existing >= 0 - ? state.filters.map((f, i) => (i === existing ? newFilter : f)) - : [...state.filters, newFilter]; - return { ...state, filters }; - } - case "SET_FILTERS": - return { ...state, filters: action.filters }; - case "REMOVE_FILTER": - return { - ...state, - filters: state.filters.filter((f) => f.field !== action.field), - }; - case "CLEAR_FILTERS": - return { ...state, filters: [] }; - case "SET_SORT": { - const { field, direction } = action; - if (direction === undefined) { - return { - ...state, - sortStates: state.sortStates.filter((s) => s.field !== field), - }; - } - const filtered = state.sortStates.filter((s) => s.field !== field); - return { - ...state, - sortStates: [...filtered, { field, direction }], - }; - } - case "CLEAR_SORT": - return { ...state, sortStates: [] }; - case "SET_PAGE_SIZE": - return { ...state, pageSize: action.pageSize }; - default: - return state; - } -} - // ----------------------------------------------------------------------------- // Case-insensitive regex conversion helpers // ----------------------------------------------------------------------------- @@ -208,52 +134,48 @@ export function useCollectionVariables( const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; // --------------------------------------------------------------------------- - // State (reducer-based) + // Persistence read (once on mount) // --------------------------------------------------------------------------- - const [state, dispatch] = useReducer(collectionReducer, undefined, () => { - const snapshot = persistence?.read(); - return { - filters: snapshot?.filters ?? initialFilters, - sortStates: snapshot?.sort ?? initialSort, - pageSize: snapshot?.pageSize ?? initialPageSize, - }; - }); - - const { filters, sortStates, pageSize } = state; + const [snapshot] = useState(() => persistence?.read()); // --------------------------------------------------------------------------- - // Persistence write-back (skip initial render) + // State // --------------------------------------------------------------------------- - const isFirstRenderRef = useRef(true); - useEffect(() => { - if (isFirstRenderRef.current) { - isFirstRenderRef.current = false; - return; - } - persistence?.write({ - filters: state.filters, - sort: state.sortStates, - pageSize: state.pageSize, - }); - }, [state.filters, state.sortStates, state.pageSize, persistence]); + const [filters, setFiltersState] = useState(snapshot?.filters ?? initialFilters); + const [sortStates, setSortStates] = useState(snapshot?.sort ?? initialSort); - // --------------------------------------------------------------------------- - // Cursor pagination (pageSize owned by reducer, passed in) - // --------------------------------------------------------------------------- const { + pageSize, paginationVariables, goToNextPage, goToPrevPage, resetPage, goToFirstPage, goToLastPage, + setPageSize: setCursorPageSize, getHasPrevPage, getHasNextPage, resetCount, - } = useCursorPagination(pageSize); + } = useCursorPagination(snapshot?.pageSize ?? initialPageSize); // --------------------------------------------------------------------------- - // Control actions (dispatch user actions + reset pagination) + // Persistence write-back (skip initial render) + // --------------------------------------------------------------------------- + const isFirstRenderRef = useRef(true); + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + return; + } + persistence?.write({ + filters, + sort: sortStates, + pageSize, + }); + }, [filters, sortStates, pageSize, persistence]); + + // --------------------------------------------------------------------------- + // Filter operations // --------------------------------------------------------------------------- const addFilter = useCallback( ( @@ -262,12 +184,20 @@ export function useCollectionVariables( value: unknown, filterOptions?: { caseSensitive?: boolean }, ) => { - dispatch({ - type: "ADD_FILTER", - field, - operator, - value, - caseSensitive: filterOptions?.caseSensitive, + setFiltersState((prev) => { + const existing = prev.findIndex((f) => f.field === field); + const newFilter: Filter = { + field, + operator, + value, + caseSensitive: filterOptions?.caseSensitive, + }; + if (existing >= 0) { + const updated = [...prev]; + updated[existing] = newFilter; + return updated; + } + return [...prev, newFilter]; }); resetPage(); }, @@ -276,7 +206,7 @@ export function useCollectionVariables( const setFilters = useCallback( (newFilters: Filter[]) => { - dispatch({ type: "SET_FILTERS", filters: newFilters }); + setFiltersState(newFilters); resetPage(); }, [resetPage], @@ -284,36 +214,45 @@ export function useCollectionVariables( const removeFilter = useCallback( (field: string) => { - dispatch({ type: "REMOVE_FILTER", field }); + setFiltersState((prev) => prev.filter((f) => f.field !== field)); resetPage(); }, [resetPage], ); const clearFilters = useCallback(() => { - dispatch({ type: "CLEAR_FILTERS" }); + setFiltersState([]); resetPage(); }, [resetPage]); + // --------------------------------------------------------------------------- + // Sort operations + // --------------------------------------------------------------------------- const setSort = useCallback( (field: string, direction?: "Asc" | "Desc") => { - dispatch({ type: "SET_SORT", field, direction }); + setSortStates((prev) => { + if (direction === undefined) { + return prev.filter((s) => s.field !== field); + } + const newState: SortState = { field, direction }; + const filtered = prev.filter((s) => s.field !== field); + return [...filtered, newState]; + }); resetPage(); }, [resetPage], ); const clearSort = useCallback(() => { - dispatch({ type: "CLEAR_SORT" }); + setSortStates([]); resetPage(); }, [resetPage]); const setPageSize = useCallback( (size: number) => { - dispatch({ type: "SET_PAGE_SIZE", pageSize: size }); - resetPage(); + setCursorPageSize(size); }, - [resetPage], + [setCursorPageSize], ); // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-cursor-pagination.ts b/packages/core/src/hooks/use-cursor-pagination.ts index 9a2e6054..4913a726 100644 --- a/packages/core/src/hooks/use-cursor-pagination.ts +++ b/packages/core/src/hooks/use-cursor-pagination.ts @@ -82,6 +82,8 @@ export interface UseCursorPaginationReturn { cursorStack: string[]; /** Current fetch direction — determines whether `first`/`after` or `last`/`before` is sent. */ paginationDirection: "forward" | "backward"; + /** Current page size. */ + pageSize: number; /** Ready-to-spread pagination variables for the GraphQL query. */ paginationVariables: PaginationVariables; /** @@ -114,6 +116,8 @@ export interface UseCursorPaginationReturn { * back to a full `pageSize` fetch. */ goToLastPage: (total?: number | null) => void; + /** Change the page size and reset to the first page. */ + setPageSize: (size: number) => void; /** * Determine whether a previous page exists, given the server's `pageInfo`. * @@ -153,7 +157,8 @@ export interface UseCursorPaginationReturn { * * @see {@link file://./use-collection-variables.ts useCollectionVariables} — the primary consumer. */ -export function useCursorPagination(pageSize: number): UseCursorPaginationReturn { +export function useCursorPagination(initialPageSize: number): UseCursorPaginationReturn { + const [pageSize, setPageSizeState] = useState(initialPageSize); const [cursor, setCursor] = useState(null); const [cursorStack, setCursorStack] = useState([]); const [paginationDirection, setPaginationDirection] = useState<"forward" | "backward">("forward"); @@ -253,6 +258,14 @@ export function useCursorPagination(pageSize: number): UseCursorPaginationReturn [pageSize], ); + const setPageSize = useCallback((size: number) => { + setPageSizeState(size); + setCursor(null); + setCursorStack([]); + setPaginationDirection("forward"); + setLastPageSize(null); + }, []); + // --------------------------------------------------------------------------- // Computed pagination variables // --------------------------------------------------------------------------- @@ -290,12 +303,14 @@ export function useCursorPagination(pageSize: number): UseCursorPaginationReturn cursor, cursorStack, paginationDirection, + pageSize, paginationVariables, goToNextPage, goToPrevPage, resetPage: resetPagination, goToFirstPage, goToLastPage, + setPageSize, getHasPrevPage, getHasNextPage, resetCount, From 4093eceefe427857b93b12ccd12d6da10132c26a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 27 May 2026 10:23:54 +0900 Subject: [PATCH 5/7] refactor(core): rename search params hook to useCollectionURLPersistence - rename useSearchParamsPersistence to useCollectionURLPersistence - rename hook files to use-collection-url-persistence - update exports, tests, and nextjs example usage - keep API unreleased so no deprecation alias --- .../src/modules/pages/data-table-demo.tsx | 7 +- ...> use-collection-url-persistence.test.tsx} | 77 +++++------ ...e.ts => use-collection-url-persistence.ts} | 121 +++++++++--------- packages/core/src/index.ts | 2 +- 4 files changed, 109 insertions(+), 98 deletions(-) rename packages/core/src/hooks/{use-search-params-persistence.test.tsx => use-collection-url-persistence.test.tsx} (70%) rename packages/core/src/hooks/{use-search-params-persistence.ts => use-collection-url-persistence.ts} (58%) diff --git a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx index 702bbae1..b9fc042e 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -6,7 +6,8 @@ import { createColumnHelper, Layout, type RowAction, - useSearchParamsPersistence, + type TableFieldName, + useCollectionURLPersistence, } from "@tailor-platform/app-shell"; import { useState } from "react"; import { type Product, useProductsQuery } from "./mock-data"; @@ -141,7 +142,9 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const persistence = useSearchParamsPersistence({ prefix: "dt1" }); + const persistence = useCollectionURLPersistence>({ + prefix: "dt1", + }); const { variables, control } = useCollectionVariables({ params: { pageSize: 5 }, tableMetadata: productMetadata, diff --git a/packages/core/src/hooks/use-search-params-persistence.test.tsx b/packages/core/src/hooks/use-collection-url-persistence.test.tsx similarity index 70% rename from packages/core/src/hooks/use-search-params-persistence.test.tsx rename to packages/core/src/hooks/use-collection-url-persistence.test.tsx index d2cc7c6c..f3a05873 100644 --- a/packages/core/src/hooks/use-search-params-persistence.test.tsx +++ b/packages/core/src/hooks/use-collection-url-persistence.test.tsx @@ -1,52 +1,64 @@ import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { useSearchParamsPersistence } from "./use-search-params-persistence"; +import { MemoryRouter } from "react-router"; +import { useCollectionURLPersistence } from "./use-collection-url-persistence"; beforeEach(() => { - // Reset URL to root before each test - window.history.replaceState(null, "", "/"); + vi.useRealTimers(); }); +function createWrapper(initialEntry: string = "/") { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function renderPersistence( + options?: Parameters[0], + initialEntry: string = "/", +) { + return renderHook(() => useCollectionURLPersistence(options), { + wrapper: createWrapper(initialEntry), + }); +} + /** Helper: read current snapshot */ -function readSnapshot(result: { current: ReturnType }) { +function readSnapshot(result: { current: ReturnType }) { return result.current.read(); } -describe("useSearchParamsPersistence", () => { +describe("useCollectionURLPersistence", () => { // --------------------------------------------------------------------------- // read (initial) // --------------------------------------------------------------------------- describe("read (initial)", () => { it("returns undefined when no search params exist", () => { - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(); expect(readSnapshot(result)).toBeUndefined(); }); it("returns pageSize from URL", () => { - window.history.replaceState(null, "", "/?p=50"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?p=50"); expect(readSnapshot(result)?.pageSize).toBe(50); }); it("returns sort from URL", () => { - window.history.replaceState(null, "", "/?s=name:asc"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?s=name:asc"); expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Asc" }]); }); it("returns sort desc from URL", () => { - window.history.replaceState(null, "", "/?s=createdAt:desc"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?s=createdAt:desc"); expect(readSnapshot(result)?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); }); it("returns single-value filter from URL", () => { - window.history.replaceState(null, "", "/?f.status:eq=ACTIVE"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?f.status:eq=ACTIVE"); expect(readSnapshot(result)?.filters).toEqual([ { field: "status", operator: "eq", value: "ACTIVE" }, @@ -54,8 +66,7 @@ describe("useSearchParamsPersistence", () => { }); it("returns multi-value filter from URL (repeated params)", () => { - window.history.replaceState(null, "", "/?f.status:in=ACTIVE&f.status:in=PENDING"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?f.status:in=ACTIVE&f.status:in=PENDING"); expect(readSnapshot(result)?.filters).toEqual([ { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, @@ -64,8 +75,7 @@ describe("useSearchParamsPersistence", () => { it("returns JSON object filter value (e.g. between)", () => { const url = `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`; - window.history.replaceState(null, "", url); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, url); expect(readSnapshot(result)?.filters).toEqual([ { field: "amount", operator: "between", value: { min: 10, max: 100 } }, @@ -73,8 +83,7 @@ describe("useSearchParamsPersistence", () => { }); it("returns all state together", () => { - window.history.replaceState(null, "", "/?p=25&s=name:desc&f.status:eq=ACTIVE"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?p=25&s=name:desc&f.status:eq=ACTIVE"); expect(readSnapshot(result)).toEqual({ pageSize: 25, @@ -84,22 +93,19 @@ describe("useSearchParamsPersistence", () => { }); it("respects prefix option", () => { - window.history.replaceState(null, "", "/?t1.p=30&p=10"); - const { result } = renderHook(() => useSearchParamsPersistence({ prefix: "t1" })); + const { result } = renderPersistence({ prefix: "t1" }, "/?t1.p=30&p=10"); expect(readSnapshot(result)?.pageSize).toBe(30); }); it("ignores invalid pageSize values", () => { - window.history.replaceState(null, "", "/?p=abc"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?p=abc"); expect(readSnapshot(result)).toBeUndefined(); }); it("ignores filter keys without operator", () => { - window.history.replaceState(null, "", "/?f.status=ACTIVE"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?f.status=ACTIVE"); expect(readSnapshot(result)).toBeUndefined(); }); @@ -110,7 +116,7 @@ describe("useSearchParamsPersistence", () => { // --------------------------------------------------------------------------- describe("write", () => { it("writes pageSize to URL", () => { - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(); act(() => { result.current.write({ filters: [], sort: [], pageSize: 50 }); @@ -120,7 +126,7 @@ describe("useSearchParamsPersistence", () => { }); it("writes sort to URL", () => { - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(); act(() => { result.current.write({ @@ -134,7 +140,7 @@ describe("useSearchParamsPersistence", () => { }); it("writes single-value filter to URL", () => { - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(); act(() => { result.current.write({ @@ -150,7 +156,7 @@ describe("useSearchParamsPersistence", () => { }); it("writes multi-value filter as repeated params", () => { - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(); act(() => { result.current.write({ @@ -166,7 +172,7 @@ describe("useSearchParamsPersistence", () => { }); it("writes object filter value as JSON", () => { - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(); act(() => { result.current.write({ @@ -188,8 +194,7 @@ describe("useSearchParamsPersistence", () => { }); it("clears old filters when writing new state", () => { - window.history.replaceState(null, "", "/?f.status:eq=OLD"); - const { result } = renderHook(() => useSearchParamsPersistence()); + const { result } = renderPersistence(undefined, "/?f.status:eq=OLD"); act(() => { result.current.write({ @@ -205,7 +210,7 @@ describe("useSearchParamsPersistence", () => { }); it("uses prefix when writing", () => { - const { result } = renderHook(() => useSearchParamsPersistence({ prefix: "t1" })); + const { result } = renderPersistence({ prefix: "t1" }); act(() => { result.current.write({ filters: [], sort: [], pageSize: 40 }); @@ -222,7 +227,7 @@ describe("useSearchParamsPersistence", () => { it("debounces write when debounceMs is set", () => { vi.useFakeTimers(); - const { result } = renderHook(() => useSearchParamsPersistence({ debounceMs: 100 })); + const { result } = renderPersistence({ debounceMs: 100 }); act(() => { result.current.write({ filters: [], sort: [], pageSize: 50 }); @@ -244,7 +249,7 @@ describe("useSearchParamsPersistence", () => { it("only applies the last write within debounce window", () => { vi.useFakeTimers(); - const { result } = renderHook(() => useSearchParamsPersistence({ debounceMs: 100 })); + const { result } = renderPersistence({ debounceMs: 100 }); act(() => { result.current.write({ filters: [], sort: [], pageSize: 10 }); diff --git a/packages/core/src/hooks/use-search-params-persistence.ts b/packages/core/src/hooks/use-collection-url-persistence.ts similarity index 58% rename from packages/core/src/hooks/use-search-params-persistence.ts rename to packages/core/src/hooks/use-collection-url-persistence.ts index 2b09a1cd..8d3a324c 100644 --- a/packages/core/src/hooks/use-search-params-persistence.ts +++ b/packages/core/src/hooks/use-collection-url-persistence.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef } from "react"; +import { useSearchParams } from "react-router"; import type { CollectionSnapshot, CollectionStatePersistence, @@ -10,7 +11,7 @@ const KEY_PAGE_SIZE = "p"; const KEY_SORT = "s"; const FILTER_PREFIX = "f."; -export interface UseSearchParamsPersistenceOptions { +export interface UseCollectionURLPersistenceOptions { /** Key prefix to avoid collisions when multiple tables share a page. */ prefix?: string; /** Debounce interval in ms for URL writes. Default: no debounce. */ @@ -20,8 +21,8 @@ export interface UseSearchParamsPersistenceOptions { /** * Persistence hook that stores collection state in URL search params. * - * - `read()` parses current `window.location.search` on mount. - * - `write()` updates URL via `window.history.replaceState`. + * - `read()` parses current route search params. + * - `write()` updates search params via React Router `useSearchParams`. * * URL format: * - Page size: `p=20` @@ -30,71 +31,73 @@ export interface UseSearchParamsPersistenceOptions { * * @example * ```tsx - * const persistence = useSearchParamsPersistence(); + * const persistence = useCollectionURLPersistence(); * const { variables, control } = useCollectionVariables({ * params: { pageSize: 20 }, * persistence, * }); * ``` */ -export function useSearchParamsPersistence( - options: UseSearchParamsPersistenceOptions = {}, -): CollectionStatePersistence { +export function useCollectionURLPersistence( + options: UseCollectionURLPersistenceOptions = {}, +): CollectionStatePersistence { const { prefix = "", debounceMs } = options; const timerRef = useRef | null>(null); - + const [searchParams, setSearchParams] = useSearchParams(); const prefixedKey = useCallback((key: string) => (prefix ? `${prefix}.${key}` : key), [prefix]); - - const read = useCallback((): CollectionSnapshot | undefined => { - return readFromParams(new URLSearchParams(window.location.search), prefixedKey); - }, [prefixedKey]); + const read = useCallback((): CollectionSnapshot | undefined => { + return readFromParams(searchParams, prefixedKey); + }, [searchParams, prefixedKey]); const write = useCallback( - (state: Required): void => { + (state: Required>): void => { const doWrite = () => { - const params = new URLSearchParams(window.location.search); - const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); - const sortKey = prefixedKey(KEY_SORT); - const filterPrefix = prefixedKey(FILTER_PREFIX); - - // Page size - if (state.pageSize) { - params.set(pageSizeKey, String(state.pageSize)); - } else { - params.delete(pageSizeKey); - } - - // Sort - if (state.sort && state.sort.length > 0) { - const { field, direction } = state.sort[0]; - params.set(sortKey, `${field}:${direction === "Desc" ? "desc" : "asc"}`); - } else { - params.delete(sortKey); - } - - // Clear existing filters with this prefix - for (const key of Array.from(params.keys())) { - if (key.startsWith(filterPrefix)) params.delete(key); - } - - // Write filters - if (state.filters) { - for (const filter of state.filters) { - const key = `${filterPrefix}${filter.field}:${filter.operator}`; - if (Array.isArray(filter.value)) { - for (const v of filter.value) { - const encoded = stringifyPrimitive(v); - if (encoded !== "") params.append(key, encoded); + setSearchParams( + (currentParams) => { + const params = new URLSearchParams(currentParams); + const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); + const sortKey = prefixedKey(KEY_SORT); + const filterPrefix = prefixedKey(FILTER_PREFIX); + + // Page size + if (state.pageSize) { + params.set(pageSizeKey, String(state.pageSize)); + } else { + params.delete(pageSizeKey); + } + + // Sort + if (state.sort && state.sort.length > 0) { + const { field, direction } = state.sort[0]; + params.set(sortKey, `${field}:${direction === "Desc" ? "desc" : "asc"}`); + } else { + params.delete(sortKey); + } + + // Clear existing filters with this prefix + for (const key of Array.from(params.keys())) { + if (key.startsWith(filterPrefix)) params.delete(key); + } + + // Write filters + if (state.filters) { + for (const filter of state.filters) { + const key = `${filterPrefix}${filter.field}:${filter.operator}`; + if (Array.isArray(filter.value)) { + for (const v of filter.value) { + const encoded = stringifyPrimitive(v); + if (encoded !== "") params.append(key, encoded); + } + } else if (filter.value != null && filter.value !== "") { + params.set(key, encodeFilterValue(filter.value)); + } } - } else if (filter.value != null && filter.value !== "") { - params.set(key, encodeFilterValue(filter.value)); } - } - } - const search = params.toString(); - const url = search ? `${window.location.pathname}?${search}` : window.location.pathname; - window.history.replaceState(null, "", url); + return params; + }, + { replace: true }, + ); }; if (debounceMs != null && debounceMs > 0) { @@ -104,7 +107,7 @@ export function useSearchParamsPersistence( doWrite(); } }, - [prefixedKey, debounceMs], + [prefixedKey, debounceMs, setSearchParams], ); return useMemo(() => ({ read, write }), [read, write]); @@ -114,16 +117,16 @@ export function useSearchParamsPersistence( // Helpers // --------------------------------------------------------------------------- -function readFromParams( +function readFromParams( searchParams: URLSearchParams, prefixedKey: (key: string) => string, -): CollectionSnapshot | undefined { +): CollectionSnapshot | undefined { const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); const sortKey = prefixedKey(KEY_SORT); const filterPrefix = prefixedKey(FILTER_PREFIX); let hasAny = false; - const snapshot: CollectionSnapshot = {}; + const snapshot: CollectionSnapshot = {}; // Page size const pageSize = searchParams.get(pageSizeKey); @@ -152,7 +155,7 @@ function readFromParams( if (key.startsWith(filterPrefix)) filterKeys.add(key); } if (filterKeys.size > 0) { - const filters: Filter[] = []; + const filters: Filter[] = []; for (const key of filterKeys) { const values = searchParams.getAll(key).filter((v) => v !== ""); if (values.length === 0) continue; @@ -160,7 +163,7 @@ function readFromParams( const [field, operator] = remainder.split(":"); if (!field || !operator) continue; filters.push({ - field, + field: field as TFieldName, operator: operator as Filter["operator"], value: values.length === 1 ? parseFilterValue(values[0]) : values.map(parseFilterValue), }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8714c726..e9df6640 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -185,7 +185,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { useSearchParamsPersistence } from "./hooks/use-search-params-persistence"; +export { useCollectionURLPersistence } from "./hooks/use-collection-url-persistence"; export { CollectionControlProvider, useCollectionControl, From 0b510f6d2561953cff0b2bbe98cf099a3992befa Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 27 May 2026 10:37:18 +0900 Subject: [PATCH 6/7] refactor(core): minimize diff with main in collection hooks - remove unnecessary setPageSize wrapper in use-collection-variables - restore cursor pagination tests removed by unrelated changes --- .../src/hooks/use-collection-variables.ts | 9 +------ .../src/hooks/use-cursor-pagination.test.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 9043a8d3..f66d0ce5 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -152,7 +152,7 @@ export function useCollectionVariables( resetPage, goToFirstPage, goToLastPage, - setPageSize: setCursorPageSize, + setPageSize, getHasPrevPage, getHasNextPage, resetCount, @@ -248,13 +248,6 @@ export function useCollectionVariables( resetPage(); }, [resetPage]); - const setPageSize = useCallback( - (size: number) => { - setCursorPageSize(size); - }, - [setCursorPageSize], - ); - // --------------------------------------------------------------------------- // Build collection variables (Tailor Platform format) // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-cursor-pagination.test.ts b/packages/core/src/hooks/use-cursor-pagination.test.ts index c5bad3b5..93930068 100644 --- a/packages/core/src/hooks/use-cursor-pagination.test.ts +++ b/packages/core/src/hooks/use-cursor-pagination.test.ts @@ -12,6 +12,7 @@ describe("useCursorPagination", () => { expect(result.current.cursor).toBeNull(); expect(result.current.cursorStack).toEqual([]); expect(result.current.paginationDirection).toBe("forward"); + expect(result.current.pageSize).toBe(20); }); it("returns correct initial paginationVariables", () => { @@ -183,6 +184,24 @@ describe("useCursorPagination", () => { }); }); + // --------------------------------------------------------------------------- + // setPageSize + // --------------------------------------------------------------------------- + describe("setPageSize", () => { + it("changes page size and resets to first page", () => { + const { result } = renderHook(() => useCursorPagination(20)); + + act(() => result.current.goToNextPage({ endCursor: "c1" })); + act(() => result.current.setPageSize(50)); + + expect(result.current.pageSize).toBe(50); + expect(result.current.cursor).toBeNull(); + expect(result.current.cursorStack).toEqual([]); + expect(result.current.paginationDirection).toBe("forward"); + expect(result.current.paginationVariables).toEqual({ first: 50 }); + }); + }); + // --------------------------------------------------------------------------- // paginationVariables shape // --------------------------------------------------------------------------- @@ -280,5 +299,11 @@ describe("useCursorPagination", () => { act(() => result.current.goToLastPage()); expect(result.current.resetCount).toBe(0); }); + + it("does not increment on setPageSize", () => { + const { result } = renderHook(() => useCursorPagination(20)); + act(() => result.current.setPageSize(50)); + expect(result.current.resetCount).toBe(0); + }); }); }); From 5decd691d060d6882c12c094637eac9b09e5ed18 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 27 May 2026 11:21:09 +0900 Subject: [PATCH 7/7] refactor(core): replace persistence interface with params + onChange API - Remove CollectionStatePersistence interface - Add onChange callback to UseCollectionOptions - Make CollectionSnapshot fields required - Rename useCollectionURLPersistence to useCollectionURLState - useCollectionURLState returns read/write accessor (read() for params, write for onChange) - Merge persistence tests into use-collection-variables.test.ts --- .../src/modules/pages/data-table-demo.tsx | 9 +- .../use-collection-url-persistence.test.tsx | 113 ++++++------ .../hooks/use-collection-url-persistence.ts | 78 +++++--- ...e-collection-variables-persistence.test.ts | 170 ------------------ .../hooks/use-collection-variables.test.ts | 118 +++++++++++- .../src/hooks/use-collection-variables.ts | 31 ++-- packages/core/src/index.ts | 7 +- packages/core/src/types/collection.ts | 37 ++-- 8 files changed, 264 insertions(+), 299 deletions(-) delete mode 100644 packages/core/src/hooks/use-collection-variables-persistence.test.ts diff --git a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx index b9fc042e..5ad25638 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -7,7 +7,7 @@ import { Layout, type RowAction, type TableFieldName, - useCollectionURLPersistence, + useCollectionURLState, } from "@tailor-platform/app-shell"; import { useState } from "react"; import { type Product, useProductsQuery } from "./mock-data"; @@ -142,13 +142,14 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const persistence = useCollectionURLPersistence>({ + const urlState = useCollectionURLState>({ prefix: "dt1", }); + const urlParams = urlState.read(); const { variables, control } = useCollectionVariables({ - params: { pageSize: 5 }, + params: { ...urlParams, pageSize: urlParams.pageSize ?? 5 }, tableMetadata: productMetadata, - persistence, + onChange: urlState.write, }); const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); diff --git a/packages/core/src/hooks/use-collection-url-persistence.test.tsx b/packages/core/src/hooks/use-collection-url-persistence.test.tsx index f3a05873..59a90323 100644 --- a/packages/core/src/hooks/use-collection-url-persistence.test.tsx +++ b/packages/core/src/hooks/use-collection-url-persistence.test.tsx @@ -2,7 +2,7 @@ import { renderHook, act } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router"; -import { useCollectionURLPersistence } from "./use-collection-url-persistence"; +import { useCollectionURLState } from "./use-collection-url-persistence"; beforeEach(() => { vi.useRealTimers(); @@ -14,100 +14,97 @@ function createWrapper(initialEntry: string = "/") { }; } -function renderPersistence( - options?: Parameters[0], +function renderURLState( + options?: Parameters[0], initialEntry: string = "/", ) { - return renderHook(() => useCollectionURLPersistence(options), { + return renderHook(() => useCollectionURLState(options), { wrapper: createWrapper(initialEntry), }); } -/** Helper: read current snapshot */ -function readSnapshot(result: { current: ReturnType }) { - return result.current.read(); -} - -describe("useCollectionURLPersistence", () => { +describe("useCollectionURLState", () => { // --------------------------------------------------------------------------- - // read (initial) + // read() // --------------------------------------------------------------------------- - describe("read (initial)", () => { - it("returns undefined when no search params exist", () => { - const { result } = renderPersistence(); + describe("read", () => { + it("returns empty object when no search params exist", () => { + const { result } = renderURLState(); - expect(readSnapshot(result)).toBeUndefined(); + expect(result.current.read()).toEqual({}); }); it("returns pageSize from URL", () => { - const { result } = renderPersistence(undefined, "/?p=50"); + const { result } = renderURLState(undefined, "/?p=50"); - expect(readSnapshot(result)?.pageSize).toBe(50); + expect(result.current.read().pageSize).toBe(50); }); - it("returns sort from URL", () => { - const { result } = renderPersistence(undefined, "/?s=name:asc"); + it("returns sort from URL (initialSort format)", () => { + const { result } = renderURLState(undefined, "/?s=name:asc"); - expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Asc" }]); + expect(result.current.read().initialSort).toEqual([{ field: "name", direction: "Asc" }]); }); it("returns sort desc from URL", () => { - const { result } = renderPersistence(undefined, "/?s=createdAt:desc"); + const { result } = renderURLState(undefined, "/?s=createdAt:desc"); - expect(readSnapshot(result)?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]); + expect(result.current.read().initialSort).toEqual([ + { field: "createdAt", direction: "Desc" }, + ]); }); - it("returns single-value filter from URL", () => { - const { result } = renderPersistence(undefined, "/?f.status:eq=ACTIVE"); + it("returns single-value filter from URL (initialFilters format)", () => { + const { result } = renderURLState(undefined, "/?f.status:eq=ACTIVE"); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "status", operator: "eq", value: "ACTIVE" }, ]); }); it("returns multi-value filter from URL (repeated params)", () => { - const { result } = renderPersistence(undefined, "/?f.status:in=ACTIVE&f.status:in=PENDING"); + const { result } = renderURLState(undefined, "/?f.status:in=ACTIVE&f.status:in=PENDING"); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, ]); }); it("returns JSON object filter value (e.g. between)", () => { const url = `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`; - const { result } = renderPersistence(undefined, url); + const { result } = renderURLState(undefined, url); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "amount", operator: "between", value: { min: 10, max: 100 } }, ]); }); it("returns all state together", () => { - const { result } = renderPersistence(undefined, "/?p=25&s=name:desc&f.status:eq=ACTIVE"); + const { result } = renderURLState(undefined, "/?p=25&s=name:desc&f.status:eq=ACTIVE"); - expect(readSnapshot(result)).toEqual({ + expect(result.current.read()).toEqual({ pageSize: 25, - sort: [{ field: "name", direction: "Desc" }], - filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + initialSort: [{ field: "name", direction: "Desc" }], + initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }], }); }); it("respects prefix option", () => { - const { result } = renderPersistence({ prefix: "t1" }, "/?t1.p=30&p=10"); + const { result } = renderURLState({ prefix: "t1" }, "/?t1.p=30&p=10"); - expect(readSnapshot(result)?.pageSize).toBe(30); + expect(result.current.read().pageSize).toBe(30); }); it("ignores invalid pageSize values", () => { - const { result } = renderPersistence(undefined, "/?p=abc"); + const { result } = renderURLState(undefined, "/?p=abc"); - expect(readSnapshot(result)).toBeUndefined(); + expect(result.current.read()).toEqual({}); }); it("ignores filter keys without operator", () => { - const { result } = renderPersistence(undefined, "/?f.status=ACTIVE"); + const { result } = renderURLState(undefined, "/?f.status=ACTIVE"); - expect(readSnapshot(result)).toBeUndefined(); + expect(result.current.read()).toEqual({}); }); }); @@ -116,17 +113,17 @@ describe("useCollectionURLPersistence", () => { // --------------------------------------------------------------------------- describe("write", () => { it("writes pageSize to URL", () => { - const { result } = renderPersistence(); + const { result } = renderURLState(); act(() => { result.current.write({ filters: [], sort: [], pageSize: 50 }); }); - expect(readSnapshot(result)?.pageSize).toBe(50); + expect(result.current.read().pageSize).toBe(50); }); it("writes sort to URL", () => { - const { result } = renderPersistence(); + const { result } = renderURLState(); act(() => { result.current.write({ @@ -136,11 +133,11 @@ describe("useCollectionURLPersistence", () => { }); }); - expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Desc" }]); + expect(result.current.read().initialSort).toEqual([{ field: "name", direction: "Desc" }]); }); it("writes single-value filter to URL", () => { - const { result } = renderPersistence(); + const { result } = renderURLState(); act(() => { result.current.write({ @@ -150,13 +147,13 @@ describe("useCollectionURLPersistence", () => { }); }); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "status", operator: "eq", value: "ACTIVE" }, ]); }); it("writes multi-value filter as repeated params", () => { - const { result } = renderPersistence(); + const { result } = renderURLState(); act(() => { result.current.write({ @@ -166,13 +163,13 @@ describe("useCollectionURLPersistence", () => { }); }); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "status", operator: "in", value: ["A", "B", "C"] }, ]); }); it("writes object filter value as JSON", () => { - const { result } = renderPersistence(); + const { result } = renderURLState(); act(() => { result.current.write({ @@ -188,13 +185,13 @@ describe("useCollectionURLPersistence", () => { }); }); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "amount", operator: "between", value: { min: 1, max: 99 } }, ]); }); it("clears old filters when writing new state", () => { - const { result } = renderPersistence(undefined, "/?f.status:eq=OLD"); + const { result } = renderURLState(undefined, "/?f.status:eq=OLD"); act(() => { result.current.write({ @@ -204,19 +201,19 @@ describe("useCollectionURLPersistence", () => { }); }); - expect(readSnapshot(result)?.filters).toEqual([ + expect(result.current.read().initialFilters).toEqual([ { field: "name", operator: "contains", value: "test" }, ]); }); it("uses prefix when writing", () => { - const { result } = renderPersistence({ prefix: "t1" }); + const { result } = renderURLState({ prefix: "t1" }); act(() => { result.current.write({ filters: [], sort: [], pageSize: 40 }); }); - expect(readSnapshot(result)?.pageSize).toBe(40); + expect(result.current.read().pageSize).toBe(40); }); }); @@ -227,21 +224,21 @@ describe("useCollectionURLPersistence", () => { it("debounces write when debounceMs is set", () => { vi.useFakeTimers(); - const { result } = renderPersistence({ debounceMs: 100 }); + const { result } = renderURLState({ debounceMs: 100 }); act(() => { result.current.write({ filters: [], sort: [], pageSize: 50 }); }); // Not yet written - expect(readSnapshot(result)).toBeUndefined(); + expect(result.current.read()).toEqual({}); act(() => { vi.advanceTimersByTime(100); }); // Now written - expect(readSnapshot(result)?.pageSize).toBe(50); + expect(result.current.read().pageSize).toBe(50); vi.useRealTimers(); }); @@ -249,7 +246,7 @@ describe("useCollectionURLPersistence", () => { it("only applies the last write within debounce window", () => { vi.useFakeTimers(); - const { result } = renderPersistence({ debounceMs: 100 }); + const { result } = renderURLState({ debounceMs: 100 }); act(() => { result.current.write({ filters: [], sort: [], pageSize: 10 }); @@ -261,7 +258,7 @@ describe("useCollectionURLPersistence", () => { vi.advanceTimersByTime(100); }); - expect(readSnapshot(result)?.pageSize).toBe(50); + expect(result.current.read().pageSize).toBe(50); vi.useRealTimers(); }); diff --git a/packages/core/src/hooks/use-collection-url-persistence.ts b/packages/core/src/hooks/use-collection-url-persistence.ts index 8d3a324c..51988a19 100644 --- a/packages/core/src/hooks/use-collection-url-persistence.ts +++ b/packages/core/src/hooks/use-collection-url-persistence.ts @@ -1,17 +1,12 @@ import { useCallback, useMemo, useRef } from "react"; import { useSearchParams } from "react-router"; -import type { - CollectionSnapshot, - CollectionStatePersistence, - Filter, - SortState, -} from "@/types/collection"; +import type { CollectionSnapshot, Filter, SortState } from "@/types/collection"; const KEY_PAGE_SIZE = "p"; const KEY_SORT = "s"; const FILTER_PREFIX = "f."; -export interface UseCollectionURLPersistenceOptions { +export interface UseCollectionURLStateOptions { /** Key prefix to avoid collisions when multiple tables share a page. */ prefix?: string; /** Debounce interval in ms for URL writes. Default: no debounce. */ @@ -19,10 +14,33 @@ export interface UseCollectionURLPersistenceOptions { } /** - * Persistence hook that stores collection state in URL search params. + * Accessor object returned by `useCollectionURLState`. * - * - `read()` parses current route search params. - * - `write()` updates search params via React Router `useSearchParams`. + * - `read()` returns params-compatible initial state parsed from the URL. + * - `write()` encodes collection state into URL search params. + * + * Designed to be wired directly into `useCollectionVariables`: + * ```tsx + * const urlState = useCollectionURLState(); + * const { variables, control } = useCollectionVariables({ + * params: urlState.read(), + * onChange: urlState.write, + * }); + * ``` + */ +export interface CollectionURLStateAccessor { + /** Parse current URL search params into initial state for `params`. */ + read(): { + initialFilters?: Filter[]; + initialSort?: SortState[]; + pageSize?: number; + }; + /** Encode collection state into URL search params. Suitable for `onChange`. */ + write: (state: CollectionSnapshot) => void; +} + +/** + * Hook that provides read/write access to collection state stored in URL search params. * * URL format: * - Page size: `p=20` @@ -31,26 +49,37 @@ export interface UseCollectionURLPersistenceOptions { * * @example * ```tsx - * const persistence = useCollectionURLPersistence(); + * const urlState = useCollectionURLState(); * const { variables, control } = useCollectionVariables({ - * params: { pageSize: 20 }, - * persistence, + * params: urlState.read(), + * onChange: urlState.write, * }); * ``` */ -export function useCollectionURLPersistence( - options: UseCollectionURLPersistenceOptions = {}, -): CollectionStatePersistence { +export function useCollectionURLState( + options: UseCollectionURLStateOptions = {}, +): CollectionURLStateAccessor { const { prefix = "", debounceMs } = options; const timerRef = useRef | null>(null); const [searchParams, setSearchParams] = useSearchParams(); const prefixedKey = useCallback((key: string) => (prefix ? `${prefix}.${key}` : key), [prefix]); - const read = useCallback((): CollectionSnapshot | undefined => { - return readFromParams(searchParams, prefixedKey); + + const read = useCallback((): { + initialFilters?: Filter[]; + initialSort?: SortState[]; + pageSize?: number; + } => { + const snapshot = readFromParams(searchParams, prefixedKey); + if (!snapshot) return {}; + return { + initialFilters: snapshot.filters, + initialSort: snapshot.sort, + pageSize: snapshot.pageSize, + }; }, [searchParams, prefixedKey]); const write = useCallback( - (state: Required>): void => { + (state: CollectionSnapshot): void => { const doWrite = () => { setSearchParams( (currentParams) => { @@ -117,16 +146,23 @@ export function useCollectionURLPersistence( // Helpers // --------------------------------------------------------------------------- +/** Partial snapshot used internally for URL parsing (fields may be absent). */ +interface ParsedSnapshot { + filters?: Filter[]; + sort?: SortState[]; + pageSize?: number; +} + function readFromParams( searchParams: URLSearchParams, prefixedKey: (key: string) => string, -): CollectionSnapshot | undefined { +): ParsedSnapshot | undefined { const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); const sortKey = prefixedKey(KEY_SORT); const filterPrefix = prefixedKey(FILTER_PREFIX); let hasAny = false; - const snapshot: CollectionSnapshot = {}; + const snapshot: ParsedSnapshot = {}; // Page size const pageSize = searchParams.get(pageSizeKey); diff --git a/packages/core/src/hooks/use-collection-variables-persistence.test.ts b/packages/core/src/hooks/use-collection-variables-persistence.test.ts deleted file mode 100644 index 8eabfe1a..00000000 --- a/packages/core/src/hooks/use-collection-variables-persistence.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import type { CollectionSnapshot, CollectionStatePersistence } from "@/types/collection"; -import { useCollectionVariables } from "./use-collection-variables"; - -describe("useCollectionVariables with persistence", () => { - function createMockPersistence(initial?: CollectionSnapshot) { - return { - read: vi.fn(() => initial), - write: vi.fn(), - } satisfies CollectionStatePersistence; - } - - describe("read (initial hydration)", () => { - it("uses persistence initial state over params defaults", () => { - const persistence = createMockPersistence({ - filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], - sort: [{ field: "name", direction: "Asc" }], - pageSize: 50, - }); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { pageSize: 20 }, - persistence, - }), - ); - - expect(persistence.read).toHaveBeenCalledOnce(); - expect(result.current.control.filters).toEqual([ - { field: "status", operator: "eq", value: "ACTIVE" }, - ]); - expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); - expect(result.current.control.pageSize).toBe(50); - }); - - it("falls back to params when persistence returns undefined", () => { - const persistence = createMockPersistence(undefined); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { - pageSize: 30, - initialSort: [{ field: "createdAt", direction: "Desc" }], - }, - persistence, - }), - ); - - expect(result.current.control.pageSize).toBe(30); - expect(result.current.control.sortStates).toEqual([ - { field: "createdAt", direction: "Desc" }, - ]); - expect(result.current.control.filters).toEqual([]); - }); - - it("partially overrides params (only pageSize from persistence)", () => { - const persistence = createMockPersistence({ - pageSize: 100, - }); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { - pageSize: 20, - initialSort: [{ field: "name", direction: "Asc" }], - }, - persistence, - }), - ); - - expect(result.current.control.pageSize).toBe(100); - // Sort remains from params since persistence didn't provide it - expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); - }); - }); - - describe("write (state persistence)", () => { - it("does not call write on initial mount (skip first write)", () => { - const persistence = createMockPersistence({ - filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], - pageSize: 50, - }); - - renderHook(() => - useCollectionVariables({ - params: { pageSize: 20 }, - persistence, - }), - ); - - expect(persistence.write).not.toHaveBeenCalled(); - }); - - it("calls write on filter change", () => { - const persistence = createMockPersistence(undefined); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { pageSize: 20 }, - persistence, - }), - ); - - act(() => { - result.current.control.addFilter("status", "eq", "ACTIVE"); - }); - - expect(persistence.write).toHaveBeenCalledWith( - expect.objectContaining({ - filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], - pageSize: 20, - }), - ); - }); - - it("calls write on sort change", () => { - const persistence = createMockPersistence(undefined); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { pageSize: 20 }, - persistence, - }), - ); - - act(() => { - result.current.control.setSort("name", "Desc"); - }); - - expect(persistence.write).toHaveBeenCalledWith( - expect.objectContaining({ - sort: [{ field: "name", direction: "Desc" }], - pageSize: 20, - }), - ); - }); - - it("calls write on pageSize change", () => { - const persistence = createMockPersistence(undefined); - - const { result } = renderHook(() => - useCollectionVariables({ - params: { pageSize: 20 }, - persistence, - }), - ); - - act(() => { - result.current.control.setPageSize(50); - }); - - expect(persistence.write).toHaveBeenCalledWith( - expect.objectContaining({ - pageSize: 50, - }), - ); - }); - - it("does not crash when no persistence is provided", () => { - const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } })); - - act(() => { - result.current.control.addFilter("status", "eq", "ACTIVE"); - }); - - expect(result.current.control.filters).toHaveLength(1); - }); - }); -}); diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index 8d0b5ae7..341eb427 100644 --- a/packages/core/src/hooks/use-collection-variables.test.ts +++ b/packages/core/src/hooks/use-collection-variables.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { TableMetadataMap } from "@/types/collection"; import { useCollectionVariables } from "./use-collection-variables"; @@ -626,4 +626,120 @@ describe("useCollectionVariables", () => { expect(result.current.control.sortStates).toEqual([{ field: "dueDate", direction: "Desc" }]); }); }); + + // --------------------------------------------------------------------------- + // onChange + // --------------------------------------------------------------------------- + describe("onChange", () => { + it("does not call onChange on initial mount", () => { + const onChange = vi.fn(); + + renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("calls onChange on filter change", () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + pageSize: 20, + }), + ); + }); + + it("calls onChange on sort change", () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + act(() => { + result.current.control.setSort("name", "Desc"); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + sort: [{ field: "name", direction: "Desc" }], + pageSize: 20, + }), + ); + }); + + it("calls onChange on pageSize change", () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + act(() => { + result.current.control.setPageSize(50); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 50, + }), + ); + }); + + it("does not crash when onChange is not provided", () => { + const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } })); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(result.current.control.filters).toHaveLength(1); + }); + + it("uses latest onChange reference (no stale closure)", () => { + const onChange1 = vi.fn(); + const onChange2 = vi.fn(); + + const { result, rerender } = renderHook( + ({ onChange }) => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + { initialProps: { onChange: onChange1 } }, + ); + + rerender({ onChange: onChange2 }); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(onChange1).not.toHaveBeenCalled(); + expect(onChange2).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index f66d0ce5..65152e6e 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -80,7 +80,7 @@ function toCaseInsensitiveRegex(operator: FilterOperator, value: string): string * ``` */ export function useCollectionVariables( - options: UseCollectionOptions, TableMetadataFilter> & { + options: UseCollectionOptions> & { tableMetadata: TTable; }, ): UseCollectionReturn< @@ -127,22 +127,18 @@ export function useCollectionVariables( // incompatible via CollectionControl's contravariant TFieldName) are assignable // to it — every type is assignable to `unknown`. Callers always see the narrower // type from the overload signatures above, never `unknown`. -export function useCollectionVariables( - options: UseCollectionOptions & { tableMetadata?: TableMetadata }, -): unknown { - const { params = {}, persistence } = options; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCollectionVariables(options: any): unknown { + const { params = {}, onChange } = options as UseCollectionOptions & { + tableMetadata?: TableMetadata; + }; const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; - // --------------------------------------------------------------------------- - // Persistence read (once on mount) - // --------------------------------------------------------------------------- - const [snapshot] = useState(() => persistence?.read()); - // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- - const [filters, setFiltersState] = useState(snapshot?.filters ?? initialFilters); - const [sortStates, setSortStates] = useState(snapshot?.sort ?? initialSort); + const [filters, setFiltersState] = useState(initialFilters); + const [sortStates, setSortStates] = useState(initialSort); const { pageSize, @@ -156,23 +152,26 @@ export function useCollectionVariables( getHasPrevPage, getHasNextPage, resetCount, - } = useCursorPagination(snapshot?.pageSize ?? initialPageSize); + } = useCursorPagination(initialPageSize); // --------------------------------------------------------------------------- - // Persistence write-back (skip initial render) + // onChange notification (skip initial render) // --------------------------------------------------------------------------- const isFirstRenderRef = useRef(true); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + useEffect(() => { if (isFirstRenderRef.current) { isFirstRenderRef.current = false; return; } - persistence?.write({ + onChangeRef.current?.({ filters, sort: sortStates, pageSize, }); - }, [filters, sortStates, pageSize, persistence]); + }, [filters, sortStates, pageSize]); // --------------------------------------------------------------------------- // Filter operations diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e9df6640..e905a405 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,7 +157,6 @@ export { type UseCollectionOptions, type UseCollectionReturn, type CollectionSnapshot, - type CollectionStatePersistence, type FieldType, type FieldMetadata, type TableMetadata, @@ -185,7 +184,11 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { useCollectionURLPersistence } from "./hooks/use-collection-url-persistence"; +export { + useCollectionURLState, + type CollectionURLStateAccessor, + type UseCollectionURLStateOptions, +} from "./hooks/use-collection-url-persistence"; export { CollectionControlProvider, useCollectionControl, diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index d9cffc7b..686ceb38 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -347,30 +347,16 @@ export type TableOrderableFieldName = : never; // ============================================================================= -// Collection State Persistence +// Collection State Snapshot // ============================================================================= /** - * Snapshot of collection state for persistence. + * Snapshot of collection state passed to `onChange`. */ export interface CollectionSnapshot { - filters?: Filter[]; - sort?: SortState[]; - pageSize?: number; -} - -/** - * Pluggable persistence layer that stores collection state to an external store - * (URL search params, localStorage, server, etc.). - * - * - `read()` is called once on mount to hydrate initial state. - * - `write()` is called on every user-initiated state change. - */ -export interface CollectionStatePersistence { - /** Read persisted state. Called once on mount to hydrate initial state. */ - read(): CollectionSnapshot | undefined; - /** Write current state. May be async internally — the hook does not await. */ - write(state: Required>): void; + filters: Filter[]; + sort: SortState[]; + pageSize: number; } // ============================================================================= @@ -380,17 +366,14 @@ export interface CollectionStatePersistence /** * Options for `useCollectionVariables` hook. */ -export interface UseCollectionOptions< - TFieldName extends string = string, - TFilter extends Filter = Filter, -> { +export interface UseCollectionOptions { params?: { - initialFilters?: TFilter[]; - initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; + initialFilters?: Filter[]; + initialSort?: SortState[]; pageSize?: number; }; - /** Optional persistence layer to store collection state to an external store. */ - persistence?: CollectionStatePersistence; + /** Called on every user-initiated state change (filter, sort, pageSize). */ + onChange?: (state: CollectionSnapshot) => void; } /**