Skip to content

Commit da3f72e

Browse files
authored
feat: Add FDv2 State Debouncer (#1148)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Low Risk** > Purely additive code and tests; no existing behavior is modified, and the new manager is not referenced elsewhere yet. > > **Overview** > Adds a new `StateDebounceManager` utility to the SDK client datasource layer to **debounce/coalesce** network availability, app lifecycle, and requested connection-mode changes into a single reconciliation callback after a configurable window (default `1000ms`). > > Includes comprehensive Jest coverage for timer reset/coalescing behavior, multi-dimension accumulation, custom debounce durations, and `close()` semantics (cancels pending work and makes setters no-ops). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e58b6ad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b714741 commit da3f72e

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import {
2+
createStateDebounceManager,
3+
DEFAULT_DEBOUNCE_MS,
4+
PendingState,
5+
} from '../../src/datasource/StateDebounceManager';
6+
7+
beforeEach(() => {
8+
jest.useFakeTimers();
9+
});
10+
11+
afterEach(() => {
12+
jest.useRealTimers();
13+
});
14+
15+
const defaultInitialState: PendingState = {
16+
networkState: 'available',
17+
lifecycleState: 'foreground',
18+
requestedMode: 'streaming',
19+
};
20+
21+
function makeManager(
22+
onReconcile: jest.Mock = jest.fn(),
23+
initialState: PendingState = defaultInitialState,
24+
debounceMs?: number,
25+
) {
26+
const manager = createStateDebounceManager({
27+
initialState,
28+
onReconcile,
29+
debounceMs,
30+
});
31+
return { manager, onReconcile };
32+
}
33+
34+
it('fires callback after debounce window when network state changes', () => {
35+
const { manager, onReconcile } = makeManager();
36+
37+
manager.setNetworkState('unavailable');
38+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 1);
39+
expect(onReconcile).not.toHaveBeenCalled();
40+
41+
jest.advanceTimersByTime(1);
42+
expect(onReconcile).toHaveBeenCalledTimes(1);
43+
expect(onReconcile).toHaveBeenCalledWith({
44+
networkState: 'unavailable',
45+
lifecycleState: 'foreground',
46+
requestedMode: 'streaming',
47+
});
48+
49+
manager.close();
50+
});
51+
52+
it('fires callback after debounce window when lifecycle state changes', () => {
53+
const { manager, onReconcile } = makeManager();
54+
55+
manager.setLifecycleState('background');
56+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
57+
58+
expect(onReconcile).toHaveBeenCalledTimes(1);
59+
expect(onReconcile).toHaveBeenCalledWith({
60+
networkState: 'available',
61+
lifecycleState: 'background',
62+
requestedMode: 'streaming',
63+
});
64+
65+
manager.close();
66+
});
67+
68+
it('fires callback after debounce window when connection mode changes', () => {
69+
const { manager, onReconcile } = makeManager();
70+
71+
manager.setRequestedMode('polling');
72+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
73+
74+
expect(onReconcile).toHaveBeenCalledTimes(1);
75+
expect(onReconcile).toHaveBeenCalledWith({
76+
networkState: 'available',
77+
lifecycleState: 'foreground',
78+
requestedMode: 'polling',
79+
});
80+
81+
manager.close();
82+
});
83+
84+
it('resets the timer when a new event arrives within the debounce window', () => {
85+
const { manager, onReconcile } = makeManager();
86+
87+
manager.setNetworkState('unavailable');
88+
jest.advanceTimersByTime(500);
89+
expect(onReconcile).not.toHaveBeenCalled();
90+
91+
// Second event resets the timer
92+
manager.setLifecycleState('background');
93+
jest.advanceTimersByTime(500);
94+
// 1000ms total elapsed, but only 500ms since last event — should not fire yet
95+
expect(onReconcile).not.toHaveBeenCalled();
96+
97+
jest.advanceTimersByTime(500);
98+
// Now 1000ms since last event — should fire
99+
expect(onReconcile).toHaveBeenCalledTimes(1);
100+
expect(onReconcile).toHaveBeenCalledWith({
101+
networkState: 'unavailable',
102+
lifecycleState: 'background',
103+
requestedMode: 'streaming',
104+
});
105+
106+
manager.close();
107+
});
108+
109+
it('coalesces multiple rapid changes to the final state', () => {
110+
const { manager, onReconcile } = makeManager();
111+
112+
manager.setNetworkState('unavailable');
113+
manager.setNetworkState('available');
114+
manager.setNetworkState('unavailable');
115+
manager.setNetworkState('available');
116+
117+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
118+
119+
expect(onReconcile).toHaveBeenCalledTimes(1);
120+
expect(onReconcile).toHaveBeenCalledWith({
121+
networkState: 'available',
122+
lifecycleState: 'foreground',
123+
requestedMode: 'streaming',
124+
});
125+
126+
manager.close();
127+
});
128+
129+
it('delivers combined state from all dimensions changed in one window', () => {
130+
const { manager, onReconcile } = makeManager();
131+
132+
manager.setNetworkState('unavailable');
133+
manager.setLifecycleState('background');
134+
manager.setRequestedMode('offline');
135+
136+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
137+
138+
expect(onReconcile).toHaveBeenCalledTimes(1);
139+
expect(onReconcile).toHaveBeenCalledWith({
140+
networkState: 'unavailable',
141+
lifecycleState: 'background',
142+
requestedMode: 'offline',
143+
});
144+
145+
manager.close();
146+
});
147+
148+
it('does not fire callback if close() is called before timer fires', () => {
149+
const { manager, onReconcile } = makeManager();
150+
151+
manager.setNetworkState('unavailable');
152+
manager.close();
153+
154+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
155+
156+
expect(onReconcile).not.toHaveBeenCalled();
157+
});
158+
159+
it('treats set* methods as no-ops after close()', () => {
160+
const { manager, onReconcile } = makeManager();
161+
162+
manager.close();
163+
manager.setNetworkState('unavailable');
164+
manager.setLifecycleState('background');
165+
manager.setRequestedMode('polling');
166+
167+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS * 2);
168+
169+
expect(onReconcile).not.toHaveBeenCalled();
170+
});
171+
172+
it('respects a custom debounce duration', () => {
173+
const { manager, onReconcile } = makeManager(jest.fn(), defaultInitialState, 2000);
174+
175+
manager.setNetworkState('unavailable');
176+
jest.advanceTimersByTime(1500);
177+
expect(onReconcile).not.toHaveBeenCalled();
178+
179+
jest.advanceTimersByTime(500);
180+
expect(onReconcile).toHaveBeenCalledTimes(1);
181+
182+
manager.close();
183+
});
184+
185+
it('uses 1 second as the default debounce duration', () => {
186+
const { manager, onReconcile } = makeManager();
187+
188+
manager.setNetworkState('unavailable');
189+
jest.advanceTimersByTime(999);
190+
expect(onReconcile).not.toHaveBeenCalled();
191+
192+
jest.advanceTimersByTime(1);
193+
expect(onReconcile).toHaveBeenCalledTimes(1);
194+
195+
manager.close();
196+
});
197+
198+
it('only updates the dimension that was changed', () => {
199+
const { manager, onReconcile } = makeManager();
200+
201+
manager.setNetworkState('unavailable');
202+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
203+
204+
expect(onReconcile).toHaveBeenCalledWith({
205+
networkState: 'unavailable',
206+
lifecycleState: 'foreground',
207+
requestedMode: 'streaming',
208+
});
209+
210+
manager.close();
211+
});
212+
213+
it('settles on the final value after rapid network flapping', () => {
214+
const { manager, onReconcile } = makeManager();
215+
216+
// Simulate rapid network flapping (spec example 1)
217+
manager.setNetworkState('unavailable');
218+
manager.setNetworkState('available');
219+
manager.setNetworkState('unavailable');
220+
manager.setNetworkState('available');
221+
222+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
223+
224+
expect(onReconcile).toHaveBeenCalledTimes(1);
225+
expect(onReconcile).toHaveBeenCalledWith({
226+
networkState: 'available',
227+
lifecycleState: 'foreground',
228+
requestedMode: 'streaming',
229+
});
230+
231+
manager.close();
232+
});
233+
234+
it('handles multiple dimensions changing within the same debounce window', () => {
235+
const { manager, onReconcile } = makeManager();
236+
237+
// Simulate spec example 3: background + network loss
238+
manager.setLifecycleState('background');
239+
manager.setNetworkState('unavailable');
240+
241+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
242+
243+
expect(onReconcile).toHaveBeenCalledTimes(1);
244+
expect(onReconcile).toHaveBeenCalledWith({
245+
networkState: 'unavailable',
246+
lifecycleState: 'background',
247+
requestedMode: 'streaming',
248+
});
249+
250+
manager.close();
251+
});
252+
253+
it('supports sequential debounce windows', () => {
254+
const { manager, onReconcile } = makeManager();
255+
256+
// First window
257+
manager.setNetworkState('unavailable');
258+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
259+
expect(onReconcile).toHaveBeenCalledTimes(1);
260+
261+
// Second window
262+
manager.setNetworkState('available');
263+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
264+
expect(onReconcile).toHaveBeenCalledTimes(2);
265+
expect(onReconcile).toHaveBeenLastCalledWith({
266+
networkState: 'available',
267+
lifecycleState: 'foreground',
268+
requestedMode: 'streaming',
269+
});
270+
271+
manager.close();
272+
});
273+
274+
it('does not fire callback when no state changes are made', () => {
275+
const { manager, onReconcile } = makeManager();
276+
277+
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS * 5);
278+
279+
expect(onReconcile).not.toHaveBeenCalled();
280+
281+
manager.close();
282+
});
283+
284+
it('can be closed multiple times without error', () => {
285+
const { manager } = makeManager();
286+
287+
expect(() => {
288+
manager.close();
289+
manager.close();
290+
manager.close();
291+
}).not.toThrow();
292+
});

0 commit comments

Comments
 (0)