-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathuseEventBatcher.ts
More file actions
175 lines (155 loc) · 5.09 KB
/
Copy pathuseEventBatcher.ts
File metadata and controls
175 lines (155 loc) · 5.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
import { useCallback, useEffect, useRef } from 'react';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import get from 'lodash.get';
import type { WatchState } from '../../types/watch';
// Re-use the payload shapes already defined in useResources.
export type AddPayload = {
data: any;
key: string;
connection: string;
id: string;
namespace: string;
};
export type UpdatePayload = {
data: any;
key: string;
connection: string;
id: string;
namespace: string;
};
export type DeletePayload = {
data: any;
key: string;
connection: string;
id: string;
namespace: string;
};
export type ResourceEvent =
| { type: 'ADD'; payload: AddPayload }
| { type: 'UPDATE'; payload: UpdatePayload }
| { type: 'DELETE'; payload: DeletePayload };
/**
* Apply a batch of resource events to a list query cache entry.
* Extracted for testability — this is the core batching logic.
*
* Returns a new plain object (immutable-style) without relying on Immer,
* avoiding issues with Wails binding class instances that Immer cannot draft.
*/
export function applyBatch(
oldData: any,
events: ResourceEvent[],
idAccessor: string,
): any {
const base = oldData ?? { result: [], success: true, totalCount: 0 };
// Shallow-copy the result array so we never mutate the cached version.
const result = [...(base.result ?? [])];
for (const event of events) {
const eventId = get(event.payload.data, idAccessor);
switch (event.type) {
case 'ADD': {
const idx = result.findIndex((item: any) => get(item, idAccessor) === eventId);
if (idx === -1) {
result.push(event.payload.data);
}
break;
}
case 'UPDATE': {
const idx = result.findIndex((item: any) => get(item, idAccessor) === eventId);
if (idx !== -1) {
result[idx] = event.payload.data;
}
break;
}
case 'DELETE': {
const idx = result.findIndex((item: any) => get(item, idAccessor) === eventId);
if (idx !== -1) {
result.splice(idx, 1);
}
break;
}
}
}
return { ...base, result };
}
/**
* Adaptive two-mode event batcher for resource watch events.
*
* **Mode 1 — Initial Sync (SYNCING state):** Uses `setTimeout` with a larger
* window (default 500ms) to batch the initial ADD flood into 1-2 flushes.
* Satisfies REQ-BATCH-2.
*
* **Mode 2 — Live Updates (all other states):** Uses `requestAnimationFrame`
* to align cache updates with the browser repaint cycle (~16ms), providing
* the lowest-latency batching for live updates. Satisfies REQ-BATCH-1.
*
* Mode transitions happen automatically when `watchState` changes.
* Pending timers flush normally; subsequent events use the new scheduling.
*/
export function useEventBatcher(
queryClient: QueryClient,
queryKey: QueryKey,
getResourceKey: (id: string, ns: string) => QueryKey,
idAccessor: string | undefined,
watchState: WatchState,
options?: { syncWindowMs?: number },
) {
const syncWindowMs = options?.syncWindowMs ?? 500;
const bufferRef = useRef<ResourceEvent[]>([]);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafRef = useRef<number | null>(null);
// Track watchState in a ref so flush callback always reads the latest
// value without needing to be re-created on every state change.
const stateRef = useRef(watchState);
stateRef.current = watchState;
const flush = useCallback(() => {
const events = bufferRef.current;
bufferRef.current = [];
timerRef.current = null;
rafRef.current = null;
if (events.length === 0 || !idAccessor) return;
// Single cache update for the list query.
queryClient.setQueryData(queryKey, (oldData: any) =>
applyBatch(oldData, events, idAccessor),
);
// Update individual resource caches.
for (const event of events) {
if (event.type === 'ADD' || event.type === 'UPDATE') {
queryClient.setQueryData(
getResourceKey(event.payload.id, event.payload.namespace),
{ result: event.payload.data },
);
}
}
}, [queryClient, queryKey, getResourceKey, idAccessor]);
const enqueue = useCallback(
(event: ResourceEvent) => {
bufferRef.current.push(event);
// Already have a pending flush scheduled — just buffer.
if (timerRef.current !== null || rafRef.current !== null) {
return;
}
// SYNCING (1) → use setTimeout with larger window for initial sync batching.
// All other states → use requestAnimationFrame for lowest-latency live updates.
if (stateRef.current === 1) {
timerRef.current = setTimeout(flush, syncWindowMs);
} else {
rafRef.current = requestAnimationFrame(flush);
}
},
[flush, syncWindowMs],
);
// Cancel pending timer/RAF on unmount.
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, []);
return enqueue;
}