Skip to content

Commit cd6bfa8

Browse files
committed
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
1 parent 5322e14 commit cd6bfa8

6 files changed

Lines changed: 708 additions & 5 deletions
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { renderHook, act } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import type { CollectionStateSynchronizer } from "@/types/collection";
4+
import { useCollectionVariables } from "./use-collection-variables";
5+
6+
describe("useCollectionVariables with synchronizer", () => {
7+
function createMockSynchronizer(initial?: ReturnType<CollectionStateSynchronizer["read"]>) {
8+
return {
9+
read: vi.fn(() => initial),
10+
write: vi.fn(),
11+
} satisfies CollectionStateSynchronizer;
12+
}
13+
14+
describe("read (initial hydration)", () => {
15+
it("uses synchronizer initial state over params defaults", () => {
16+
const synchronizer = createMockSynchronizer({
17+
filters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
18+
sort: [{ field: "name", direction: "Asc" }],
19+
pageSize: 50,
20+
});
21+
22+
const { result } = renderHook(() =>
23+
useCollectionVariables({
24+
params: { pageSize: 20 },
25+
synchronizer,
26+
}),
27+
);
28+
29+
expect(synchronizer.read).toHaveBeenCalledOnce();
30+
expect(result.current.control.filters).toEqual([
31+
{ field: "status", operator: "eq", value: "ACTIVE" },
32+
]);
33+
expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]);
34+
expect(result.current.control.pageSize).toBe(50);
35+
});
36+
37+
it("falls back to params when synchronizer returns undefined", () => {
38+
const synchronizer = createMockSynchronizer(undefined);
39+
40+
const { result } = renderHook(() =>
41+
useCollectionVariables({
42+
params: {
43+
pageSize: 30,
44+
initialSort: [{ field: "createdAt", direction: "Desc" }],
45+
},
46+
synchronizer,
47+
}),
48+
);
49+
50+
expect(result.current.control.pageSize).toBe(30);
51+
expect(result.current.control.sortStates).toEqual([
52+
{ field: "createdAt", direction: "Desc" },
53+
]);
54+
expect(result.current.control.filters).toEqual([]);
55+
});
56+
57+
it("partially overrides params (only pageSize from synchronizer)", () => {
58+
const synchronizer = createMockSynchronizer({
59+
pageSize: 100,
60+
});
61+
62+
const { result } = renderHook(() =>
63+
useCollectionVariables({
64+
params: {
65+
pageSize: 20,
66+
initialSort: [{ field: "name", direction: "Asc" }],
67+
},
68+
synchronizer,
69+
}),
70+
);
71+
72+
expect(result.current.control.pageSize).toBe(100);
73+
// Sort falls back to params
74+
expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]);
75+
});
76+
});
77+
78+
describe("write (state persistence)", () => {
79+
it("calls write on filter change", () => {
80+
const synchronizer = createMockSynchronizer(undefined);
81+
82+
const { result } = renderHook(() =>
83+
useCollectionVariables({
84+
params: { pageSize: 20 },
85+
synchronizer,
86+
}),
87+
);
88+
89+
act(() => {
90+
result.current.control.addFilter("status", "eq", "ACTIVE");
91+
});
92+
93+
expect(synchronizer.write).toHaveBeenCalledWith(
94+
expect.objectContaining({
95+
filters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
96+
pageSize: 20,
97+
}),
98+
);
99+
});
100+
101+
it("calls write on sort change", () => {
102+
const synchronizer = createMockSynchronizer(undefined);
103+
104+
const { result } = renderHook(() =>
105+
useCollectionVariables({
106+
params: { pageSize: 20 },
107+
synchronizer,
108+
}),
109+
);
110+
111+
act(() => {
112+
result.current.control.setSort("name", "Desc");
113+
});
114+
115+
expect(synchronizer.write).toHaveBeenCalledWith(
116+
expect.objectContaining({
117+
sort: [{ field: "name", direction: "Desc" }],
118+
pageSize: 20,
119+
}),
120+
);
121+
});
122+
123+
it("calls write on pageSize change", () => {
124+
const synchronizer = createMockSynchronizer(undefined);
125+
126+
const { result } = renderHook(() =>
127+
useCollectionVariables({
128+
params: { pageSize: 20 },
129+
synchronizer,
130+
}),
131+
);
132+
133+
act(() => {
134+
result.current.control.setPageSize(50);
135+
});
136+
137+
expect(synchronizer.write).toHaveBeenCalledWith(
138+
expect.objectContaining({
139+
pageSize: 50,
140+
}),
141+
);
142+
});
143+
144+
it("does not call write when no synchronizer is provided", () => {
145+
const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } }));
146+
147+
act(() => {
148+
result.current.control.addFilter("status", "eq", "ACTIVE");
149+
});
150+
151+
// No error thrown — just verifying it doesn't crash
152+
expect(result.current.control.filters).toHaveLength(1);
153+
});
154+
});
155+
});

packages/core/src/hooks/use-collection-variables.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import type {
33
BuildQueryVariables,
44
CollectionControl,
@@ -130,14 +130,22 @@ export function useCollectionVariables(
130130
export function useCollectionVariables(
131131
options: UseCollectionOptions & { tableMetadata?: TableMetadata },
132132
): unknown {
133-
const { params = {} } = options;
133+
const { params = {}, synchronizer } = options;
134134
const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params;
135135

136+
// ---------------------------------------------------------------------------
137+
// Hydrate initial state from synchronizer (read once on mount)
138+
// ---------------------------------------------------------------------------
139+
const syncInitial = useRef(() => synchronizer?.read()).current();
140+
const resolvedInitialFilters = syncInitial?.filters ?? initialFilters;
141+
const resolvedInitialSort = syncInitial?.sort ?? initialSort;
142+
const resolvedInitialPageSize = syncInitial?.pageSize ?? initialPageSize;
143+
136144
// ---------------------------------------------------------------------------
137145
// State
138146
// ---------------------------------------------------------------------------
139-
const [filters, setFiltersState] = useState<Filter[]>(initialFilters);
140-
const [sortStates, setSortStates] = useState<SortState[]>(initialSort);
147+
const [filters, setFiltersState] = useState<Filter[]>(resolvedInitialFilters);
148+
const [sortStates, setSortStates] = useState<SortState[]>(resolvedInitialSort);
141149

142150
const {
143151
pageSize,
@@ -151,7 +159,21 @@ export function useCollectionVariables(
151159
getHasPrevPage,
152160
getHasNextPage,
153161
resetCount,
154-
} = useCursorPagination(initialPageSize);
162+
} = useCursorPagination(resolvedInitialPageSize);
163+
164+
// ---------------------------------------------------------------------------
165+
// Synchronizer write-back
166+
// ---------------------------------------------------------------------------
167+
const synchronizerRef = useRef(synchronizer);
168+
synchronizerRef.current = synchronizer;
169+
170+
useEffect(() => {
171+
synchronizerRef.current?.write({
172+
filters,
173+
sort: sortStates,
174+
pageSize,
175+
});
176+
}, [filters, sortStates, pageSize]);
155177

156178
// ---------------------------------------------------------------------------
157179
// Filter operations

0 commit comments

Comments
 (0)