Skip to content

Commit 12ba145

Browse files
committed
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
1 parent cd6bfa8 commit 12ba145

8 files changed

Lines changed: 398 additions & 214 deletions
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { useEffect } from "react";
2+
import type {
3+
CollectionSnapshot,
4+
CollectionStateSynchronizer,
5+
Filter,
6+
SortState,
7+
} from "@/types/collection";
8+
9+
// =============================================================================
10+
// State & Action types
11+
// =============================================================================
12+
13+
export type ChangeSource = "init" | "user" | "sync";
14+
15+
export interface CollectionState {
16+
filters: Filter[];
17+
sortStates: SortState[];
18+
pageSize: number;
19+
source: ChangeSource;
20+
}
21+
22+
export type CollectionAction =
23+
| {
24+
type: "ADD_FILTER";
25+
field: string;
26+
operator: string;
27+
value: unknown;
28+
caseSensitive?: boolean;
29+
}
30+
| { type: "SET_FILTERS"; filters: Filter[] }
31+
| { type: "REMOVE_FILTER"; field: string }
32+
| { type: "CLEAR_FILTERS" }
33+
| { type: "SET_SORT"; field: string; direction?: "Asc" | "Desc" }
34+
| { type: "CLEAR_SORT" }
35+
| { type: "SET_PAGE_SIZE"; pageSize: number }
36+
| { type: "SYNC"; snapshot: CollectionSnapshot | undefined };
37+
38+
// =============================================================================
39+
// Reducer
40+
// =============================================================================
41+
42+
export function collectionReducer(
43+
state: CollectionState,
44+
action: CollectionAction,
45+
): CollectionState {
46+
switch (action.type) {
47+
case "SYNC": {
48+
if (!action.snapshot) return state;
49+
return {
50+
...state,
51+
...(action.snapshot.filters != null && {
52+
filters: action.snapshot.filters,
53+
}),
54+
...(action.snapshot.sort != null && {
55+
sortStates: action.snapshot.sort,
56+
}),
57+
...(action.snapshot.pageSize != null && {
58+
pageSize: action.snapshot.pageSize,
59+
}),
60+
source: "sync",
61+
};
62+
}
63+
case "ADD_FILTER": {
64+
const { field, operator, value, caseSensitive } = action;
65+
const newFilter: Filter = {
66+
field,
67+
operator: operator as Filter["operator"],
68+
value,
69+
caseSensitive,
70+
};
71+
const existing = state.filters.findIndex((f) => f.field === field);
72+
const filters =
73+
existing >= 0
74+
? state.filters.map((f, i) => (i === existing ? newFilter : f))
75+
: [...state.filters, newFilter];
76+
return { ...state, filters, source: "user" };
77+
}
78+
case "SET_FILTERS":
79+
return { ...state, filters: action.filters, source: "user" };
80+
case "REMOVE_FILTER":
81+
return {
82+
...state,
83+
filters: state.filters.filter((f) => f.field !== action.field),
84+
source: "user",
85+
};
86+
case "CLEAR_FILTERS":
87+
return { ...state, filters: [], source: "user" };
88+
case "SET_SORT": {
89+
const { field, direction } = action;
90+
if (direction === undefined) {
91+
return {
92+
...state,
93+
sortStates: state.sortStates.filter((s) => s.field !== field),
94+
source: "user",
95+
};
96+
}
97+
const filtered = state.sortStates.filter((s) => s.field !== field);
98+
return {
99+
...state,
100+
sortStates: [...filtered, { field, direction }],
101+
source: "user",
102+
};
103+
}
104+
case "CLEAR_SORT":
105+
return { ...state, sortStates: [], source: "user" };
106+
case "SET_PAGE_SIZE":
107+
return { ...state, pageSize: action.pageSize, source: "user" };
108+
default:
109+
return state;
110+
}
111+
}
112+
113+
// =============================================================================
114+
// Synchronizer Bridge Hook
115+
// =============================================================================
116+
117+
/**
118+
* Connects a synchronizer to the collection reducer state.
119+
*
120+
* - Subscribes to external changes and dispatches `SYNC` actions.
121+
* - Writes state back to the synchronizer when changes originate from user actions.
122+
*/
123+
export function useSynchronizerBridge(
124+
synchronizer: CollectionStateSynchronizer | undefined,
125+
state: CollectionState,
126+
dispatch: React.Dispatch<CollectionAction>,
127+
): void {
128+
// Subscribe: external changes → dispatch
129+
useEffect(() => {
130+
if (!synchronizer) return;
131+
return synchronizer.subscribe((snapshot) => {
132+
dispatch({ type: "SYNC", snapshot });
133+
});
134+
}, [synchronizer, dispatch]);
135+
136+
// Write-back: only when source is "user"
137+
useEffect(() => {
138+
if (state.source !== "user") return;
139+
synchronizer?.write({
140+
filters: state.filters,
141+
sort: state.sortStates,
142+
pageSize: state.pageSize,
143+
});
144+
}, [state.filters, state.sortStates, state.pageSize, state.source, synchronizer]);
145+
}

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

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
import { renderHook, act } from "@testing-library/react";
22
import { describe, it, expect, vi } from "vitest";
3-
import type { CollectionStateSynchronizer } from "@/types/collection";
3+
import type { CollectionSnapshot, CollectionStateSynchronizer } from "@/types/collection";
44
import { useCollectionVariables } from "./use-collection-variables";
55

66
describe("useCollectionVariables with synchronizer", () => {
7-
function createMockSynchronizer(initial?: ReturnType<CollectionStateSynchronizer["read"]>) {
7+
function createMockSynchronizer(initial?: CollectionSnapshot) {
8+
const subscribers: Array<(snapshot: CollectionSnapshot | undefined) => void> = [];
89
return {
9-
read: vi.fn(() => initial),
10+
subscribe: vi.fn((onChange) => {
11+
subscribers.push(onChange);
12+
// BehaviorSubject: emit immediately
13+
onChange(initial);
14+
return () => {
15+
const idx = subscribers.indexOf(onChange);
16+
if (idx >= 0) subscribers.splice(idx, 1);
17+
};
18+
}),
1019
write: vi.fn(),
11-
} satisfies CollectionStateSynchronizer;
20+
// Test helper to simulate external change
21+
emit(snapshot: CollectionSnapshot | undefined) {
22+
for (const cb of subscribers) cb(snapshot);
23+
},
24+
} satisfies CollectionStateSynchronizer & {
25+
emit: (s: CollectionSnapshot | undefined) => void;
26+
};
1227
}
1328

14-
describe("read (initial hydration)", () => {
29+
describe("subscribe (initial hydration)", () => {
1530
it("uses synchronizer initial state over params defaults", () => {
1631
const synchronizer = createMockSynchronizer({
1732
filters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
@@ -26,15 +41,15 @@ describe("useCollectionVariables with synchronizer", () => {
2641
}),
2742
);
2843

29-
expect(synchronizer.read).toHaveBeenCalledOnce();
44+
expect(synchronizer.subscribe).toHaveBeenCalledOnce();
3045
expect(result.current.control.filters).toEqual([
3146
{ field: "status", operator: "eq", value: "ACTIVE" },
3247
]);
3348
expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]);
3449
expect(result.current.control.pageSize).toBe(50);
3550
});
3651

37-
it("falls back to params when synchronizer returns undefined", () => {
52+
it("falls back to params when synchronizer emits undefined", () => {
3853
const synchronizer = createMockSynchronizer(undefined);
3954

4055
const { result } = renderHook(() =>
@@ -70,12 +85,53 @@ describe("useCollectionVariables with synchronizer", () => {
7085
);
7186

7287
expect(result.current.control.pageSize).toBe(100);
73-
// Sort falls back to params
88+
// Sort remains from params since synchronizer didn't provide it
7489
expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]);
7590
});
7691
});
7792

93+
describe("subscribe (external changes)", () => {
94+
it("updates state when synchronizer emits external change", () => {
95+
const synchronizer = createMockSynchronizer(undefined);
96+
97+
const { result } = renderHook(() =>
98+
useCollectionVariables({
99+
params: { pageSize: 20 },
100+
synchronizer,
101+
}),
102+
);
103+
104+
act(() => {
105+
synchronizer.emit({
106+
filters: [{ field: "name", operator: "contains", value: "test" }],
107+
pageSize: 50,
108+
});
109+
});
110+
111+
expect(result.current.control.filters).toEqual([
112+
{ field: "name", operator: "contains", value: "test" },
113+
]);
114+
expect(result.current.control.pageSize).toBe(50);
115+
});
116+
});
117+
78118
describe("write (state persistence)", () => {
119+
it("does not call write on initial mount (skip first write)", () => {
120+
const synchronizer = createMockSynchronizer({
121+
filters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
122+
pageSize: 50,
123+
});
124+
125+
renderHook(() =>
126+
useCollectionVariables({
127+
params: { pageSize: 20 },
128+
synchronizer,
129+
}),
130+
);
131+
132+
expect(synchronizer.write).not.toHaveBeenCalled();
133+
});
134+
79135
it("calls write on filter change", () => {
80136
const synchronizer = createMockSynchronizer(undefined);
81137

@@ -141,14 +197,13 @@ describe("useCollectionVariables with synchronizer", () => {
141197
);
142198
});
143199

144-
it("does not call write when no synchronizer is provided", () => {
200+
it("does not crash when no synchronizer is provided", () => {
145201
const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } }));
146202

147203
act(() => {
148204
result.current.control.addFilter("status", "eq", "ACTIVE");
149205
});
150206

151-
// No error thrown — just verifying it doesn't crash
152207
expect(result.current.control.filters).toHaveLength(1);
153208
});
154209
});

0 commit comments

Comments
 (0)