@go-go-scope/adapter-react 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/index.d.mts +289 -0
- package/dist/index.mjs +262 -0
- package/package.json +50 -0
- package/src/index.ts +636 -0
- package/tests/react.test.tsx +383 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +8 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks for go-go-scope
|
|
3
|
+
*
|
|
4
|
+
* Provides React hooks integration with go-go-scope's structured concurrency.
|
|
5
|
+
* All hooks automatically clean up when components unmount.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { useScope, useTask, useChannel } from "@go-go-scope/adapter-react";
|
|
10
|
+
*
|
|
11
|
+
* function UserProfile({ userId }: { userId: string }) {
|
|
12
|
+
* // Auto-disposing scope
|
|
13
|
+
* const scope = useScope({ name: "UserProfile" });
|
|
14
|
+
*
|
|
15
|
+
* // Reactive task with states
|
|
16
|
+
* const { data, error, isLoading, execute } = useTask(
|
|
17
|
+
* async () => fetchUser(userId),
|
|
18
|
+
* { immediate: true }
|
|
19
|
+
* );
|
|
20
|
+
*
|
|
21
|
+
* if (isLoading) return <div>Loading...</div>;
|
|
22
|
+
* if (error) return <div>Error: {error.message}</div>;
|
|
23
|
+
* return <div>Hello, {data?.name}</div>;
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
scope,
|
|
30
|
+
BroadcastChannel,
|
|
31
|
+
type Scope,
|
|
32
|
+
type Channel,
|
|
33
|
+
type Result,
|
|
34
|
+
} from "go-go-scope";
|
|
35
|
+
import {
|
|
36
|
+
useCallback,
|
|
37
|
+
useEffect,
|
|
38
|
+
useRef,
|
|
39
|
+
useState,
|
|
40
|
+
} from "react";
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// useScope Hook
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Options for useScope hook
|
|
48
|
+
*/
|
|
49
|
+
export interface UseScopeOptions {
|
|
50
|
+
/** Scope name for debugging */
|
|
51
|
+
name?: string;
|
|
52
|
+
/** Timeout in milliseconds */
|
|
53
|
+
timeout?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* React hook that creates a reactive scope.
|
|
58
|
+
* The scope automatically disposes when the component unmounts.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* function MyComponent() {
|
|
63
|
+
* const s = useScope({ name: "MyComponent" });
|
|
64
|
+
*
|
|
65
|
+
* const handleClick = async () => {
|
|
66
|
+
* const [err, result] = await s.task(() => fetchData());
|
|
67
|
+
* // Handle result...
|
|
68
|
+
* };
|
|
69
|
+
*
|
|
70
|
+
* return <button onClick={handleClick}>Fetch</button>;
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function useScope(options: UseScopeOptions = {}): Scope<Record<string, unknown>> {
|
|
75
|
+
const scopeRef = useRef<Scope<Record<string, unknown>> | null>(null);
|
|
76
|
+
|
|
77
|
+
if (!scopeRef.current) {
|
|
78
|
+
scopeRef.current = scope({
|
|
79
|
+
name: options.name,
|
|
80
|
+
timeout: options.timeout,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
return () => {
|
|
86
|
+
scopeRef.current?.[Symbol.asyncDispose]().catch(() => {});
|
|
87
|
+
};
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
return scopeRef.current;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// useTask Hook
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Options for useTask hook
|
|
99
|
+
*/
|
|
100
|
+
export interface UseTaskOptions<T> {
|
|
101
|
+
/** Task name for debugging */
|
|
102
|
+
name?: string;
|
|
103
|
+
/** Whether to execute immediately */
|
|
104
|
+
immediate?: boolean;
|
|
105
|
+
/** Timeout in milliseconds */
|
|
106
|
+
timeout?: number;
|
|
107
|
+
/** Retry options */
|
|
108
|
+
retry?: { maxRetries?: number; delay?: number };
|
|
109
|
+
/** Initial data value */
|
|
110
|
+
initialData?: T;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* State returned by useTask hook
|
|
115
|
+
*/
|
|
116
|
+
export interface TaskState<T> {
|
|
117
|
+
/** Current data (undefined if not loaded) */
|
|
118
|
+
data: T | undefined;
|
|
119
|
+
/** Error if task failed */
|
|
120
|
+
error: Error | undefined;
|
|
121
|
+
/** Whether task is currently running */
|
|
122
|
+
isLoading: boolean;
|
|
123
|
+
/** Whether task has been executed */
|
|
124
|
+
isReady: boolean;
|
|
125
|
+
/** Execute the task */
|
|
126
|
+
execute: () => Promise<Result<Error, T>>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* React hook for executing tasks with structured concurrency.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```tsx
|
|
134
|
+
* function UserProfile({ userId }: { userId: string }) {
|
|
135
|
+
* const { data, error, isLoading } = useTask(
|
|
136
|
+
* async () => fetchUser(userId),
|
|
137
|
+
* { immediate: true }
|
|
138
|
+
* );
|
|
139
|
+
*
|
|
140
|
+
* if (isLoading) return <div>Loading...</div>;
|
|
141
|
+
* if (error) return <div>Error: {error.message}</div>;
|
|
142
|
+
* return <div>{data?.name}</div>;
|
|
143
|
+
* }
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export function useTask<T>(
|
|
147
|
+
factory: (signal: AbortSignal) => Promise<T>,
|
|
148
|
+
options: UseTaskOptions<T> = {},
|
|
149
|
+
): TaskState<T> {
|
|
150
|
+
const s = useScope({ name: options.name ?? "useTask" });
|
|
151
|
+
|
|
152
|
+
const [data, setData] = useState<T | undefined>(options.initialData);
|
|
153
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
154
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
155
|
+
const [isReady, setIsReady] = useState(false);
|
|
156
|
+
|
|
157
|
+
const execute = useCallback(async (): Promise<Result<Error, T>> => {
|
|
158
|
+
setIsLoading(true);
|
|
159
|
+
setError(undefined);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const [err, result] = await s.task(
|
|
163
|
+
async ({ signal }) => await factory(signal),
|
|
164
|
+
{
|
|
165
|
+
timeout: options.timeout,
|
|
166
|
+
retry: options.retry,
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (err) {
|
|
171
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
172
|
+
setData(undefined);
|
|
173
|
+
return [err as Error, undefined];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setData(result);
|
|
177
|
+
setIsReady(true);
|
|
178
|
+
return [undefined, result];
|
|
179
|
+
} finally {
|
|
180
|
+
setIsLoading(false);
|
|
181
|
+
}
|
|
182
|
+
}, [s, factory, options.timeout, options.retry]);
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (options.immediate) {
|
|
186
|
+
execute();
|
|
187
|
+
}
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
data,
|
|
192
|
+
error,
|
|
193
|
+
isLoading,
|
|
194
|
+
isReady,
|
|
195
|
+
execute,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// useParallel Hook
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Options for useParallel hook
|
|
205
|
+
*/
|
|
206
|
+
export interface UseParallelOptions {
|
|
207
|
+
/** Concurrency limit */
|
|
208
|
+
concurrency?: number;
|
|
209
|
+
/** Whether to execute immediately */
|
|
210
|
+
immediate?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* State returned by useParallel hook
|
|
215
|
+
*/
|
|
216
|
+
export interface ParallelState<T> {
|
|
217
|
+
/** Results from all tasks */
|
|
218
|
+
results: (T | undefined)[];
|
|
219
|
+
/** Errors from failed tasks */
|
|
220
|
+
errors: (Error | undefined)[];
|
|
221
|
+
/** Whether any task is running */
|
|
222
|
+
isLoading: boolean;
|
|
223
|
+
/** Progress percentage (0-100) */
|
|
224
|
+
progress: number;
|
|
225
|
+
/** Execute all tasks */
|
|
226
|
+
execute: () => Promise<Result<Error, T>[]>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* React hook for executing tasks in parallel.
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```tsx
|
|
234
|
+
* function Dashboard() {
|
|
235
|
+
* const factories = [
|
|
236
|
+
* () => fetchUsers(),
|
|
237
|
+
* () => fetchPosts(),
|
|
238
|
+
* () => fetchComments(),
|
|
239
|
+
* ];
|
|
240
|
+
*
|
|
241
|
+
* const { results, isLoading, progress } = useParallel(factories, {
|
|
242
|
+
* concurrency: 2,
|
|
243
|
+
* immediate: true
|
|
244
|
+
* });
|
|
245
|
+
*
|
|
246
|
+
* if (isLoading) return <progress value={progress} max="100" />;
|
|
247
|
+
*
|
|
248
|
+
* const [users, posts, comments] = results;
|
|
249
|
+
* return <DashboardView {...{ users, posts, comments }} />;
|
|
250
|
+
* }
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
export function useParallel<T>(
|
|
254
|
+
factories: (() => Promise<T>)[],
|
|
255
|
+
options: UseParallelOptions = {},
|
|
256
|
+
): ParallelState<T> {
|
|
257
|
+
const s = useScope({ name: "useParallel" });
|
|
258
|
+
|
|
259
|
+
const [results, setResults] = useState<(T | undefined)[]>([]);
|
|
260
|
+
const [errors, setErrors] = useState<(Error | undefined)[]>([]);
|
|
261
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
262
|
+
const [progress, setProgress] = useState(0);
|
|
263
|
+
|
|
264
|
+
const execute = useCallback(async (): Promise<Result<Error, T>[]> => {
|
|
265
|
+
setIsLoading(true);
|
|
266
|
+
setProgress(0);
|
|
267
|
+
setResults(new Array(factories.length).fill(undefined));
|
|
268
|
+
setErrors(new Array(factories.length).fill(undefined));
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const taskFactories = factories.map((factory, index) => {
|
|
272
|
+
return async () => {
|
|
273
|
+
const [err, result] = await s.task(async () => await factory());
|
|
274
|
+
|
|
275
|
+
if (err) {
|
|
276
|
+
setErrors((prev) => {
|
|
277
|
+
const next = [...prev];
|
|
278
|
+
next[index] = err instanceof Error ? err : new Error(String(err));
|
|
279
|
+
return next;
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
setResults((prev) => {
|
|
283
|
+
const next = [...prev];
|
|
284
|
+
next[index] = result;
|
|
285
|
+
return next;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
setProgress(Math.round(((index + 1) / factories.length) * 100));
|
|
290
|
+
return [err, result] as Result<Error, T>;
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return (await s.parallel(taskFactories, {
|
|
295
|
+
concurrency: options.concurrency,
|
|
296
|
+
})) as Result<Error, T>[];
|
|
297
|
+
} finally {
|
|
298
|
+
setIsLoading(false);
|
|
299
|
+
}
|
|
300
|
+
}, [s, factories, options.concurrency]);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (options.immediate) {
|
|
304
|
+
execute();
|
|
305
|
+
}
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
results,
|
|
310
|
+
errors,
|
|
311
|
+
isLoading,
|
|
312
|
+
progress,
|
|
313
|
+
execute,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ============================================================================
|
|
318
|
+
// useChannel Hook
|
|
319
|
+
// ============================================================================
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Options for useChannel hook
|
|
323
|
+
*/
|
|
324
|
+
export interface UseChannelOptions {
|
|
325
|
+
/** Buffer size */
|
|
326
|
+
bufferSize?: number;
|
|
327
|
+
/** Maximum history to keep */
|
|
328
|
+
historySize?: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* State returned by useChannel hook
|
|
333
|
+
*/
|
|
334
|
+
export interface ChannelState<T> {
|
|
335
|
+
/** Latest received value */
|
|
336
|
+
latest: T | undefined;
|
|
337
|
+
/** History of values */
|
|
338
|
+
history: readonly T[];
|
|
339
|
+
/** Whether channel is closed */
|
|
340
|
+
isClosed: boolean;
|
|
341
|
+
/** Send a value */
|
|
342
|
+
send: (value: T) => Promise<void>;
|
|
343
|
+
/** Close the channel */
|
|
344
|
+
close: () => void;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* React hook for Go-style channel communication.
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* ```tsx
|
|
352
|
+
* function Chat() {
|
|
353
|
+
* const ch = useChannel<string>();
|
|
354
|
+
*
|
|
355
|
+
* const handleSubmit = async (message: string) => {
|
|
356
|
+
* await ch.send(message);
|
|
357
|
+
* };
|
|
358
|
+
*
|
|
359
|
+
* return (
|
|
360
|
+
* <div>
|
|
361
|
+
* <p>Latest: {ch.latest}</p>
|
|
362
|
+
* <ul>
|
|
363
|
+
* {ch.history.map((msg, i) => <li key={i}>{msg}</li>)}
|
|
364
|
+
* </ul>
|
|
365
|
+
* </div>
|
|
366
|
+
* );
|
|
367
|
+
* }
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
export function useChannel<T>(options: UseChannelOptions = {}): ChannelState<T> {
|
|
371
|
+
const s = useScope({ name: "useChannel" });
|
|
372
|
+
const chRef = useRef<Channel<T> | null>(null);
|
|
373
|
+
|
|
374
|
+
if (!chRef.current) {
|
|
375
|
+
chRef.current = s.channel<T>(options.bufferSize ?? 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const [latest, setLatest] = useState<T | undefined>(undefined);
|
|
379
|
+
const [history, setHistory] = useState<T[]>([]);
|
|
380
|
+
const [isClosed, setIsClosed] = useState(false);
|
|
381
|
+
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
const ch = chRef.current!;
|
|
384
|
+
|
|
385
|
+
const receiveLoop = async () => {
|
|
386
|
+
for await (const value of ch) {
|
|
387
|
+
setLatest(value);
|
|
388
|
+
setHistory((prev) => [...prev.slice(-(options.historySize ?? 100)), value]);
|
|
389
|
+
}
|
|
390
|
+
setIsClosed(true);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
receiveLoop();
|
|
394
|
+
|
|
395
|
+
return () => {
|
|
396
|
+
ch.close();
|
|
397
|
+
};
|
|
398
|
+
}, [options.historySize]);
|
|
399
|
+
|
|
400
|
+
const send = useCallback(
|
|
401
|
+
async (value: T): Promise<void> => {
|
|
402
|
+
await chRef.current!.send(value);
|
|
403
|
+
},
|
|
404
|
+
[],
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const close = useCallback((): void => {
|
|
408
|
+
chRef.current!.close();
|
|
409
|
+
}, []);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
latest,
|
|
413
|
+
history,
|
|
414
|
+
isClosed,
|
|
415
|
+
send,
|
|
416
|
+
close,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// useBroadcast Hook
|
|
422
|
+
// ============================================================================
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* State returned by useBroadcast hook
|
|
426
|
+
*/
|
|
427
|
+
export interface BroadcastState<T> {
|
|
428
|
+
/** Latest broadcasted value */
|
|
429
|
+
latest: T | undefined;
|
|
430
|
+
/** Subscribe to broadcasts */
|
|
431
|
+
subscribe: (callback: (value: T) => void) => { unsubscribe: () => void };
|
|
432
|
+
/** Broadcast a value */
|
|
433
|
+
broadcast: (value: T) => void;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* React hook for pub/sub broadcast channels.
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* ```tsx
|
|
441
|
+
* function EventBus() {
|
|
442
|
+
* const bus = useBroadcast<string>();
|
|
443
|
+
*
|
|
444
|
+
* useEffect(() => {
|
|
445
|
+
* const sub = bus.subscribe((msg) => console.log(msg));
|
|
446
|
+
* return () => sub.unsubscribe();
|
|
447
|
+
* }, []);
|
|
448
|
+
*
|
|
449
|
+
* return <button onClick={() => bus.broadcast("Hello!")}>Send</button>;
|
|
450
|
+
* }
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
export function useBroadcast<T>(): BroadcastState<T> {
|
|
454
|
+
const [latest, setLatest] = useState<T | undefined>(undefined);
|
|
455
|
+
const bcRef = useRef<BroadcastChannel<T> | null>(null);
|
|
456
|
+
const listenersRef = useRef<Set<(value: T) => void>>(new Set());
|
|
457
|
+
|
|
458
|
+
if (!bcRef.current) {
|
|
459
|
+
bcRef.current = new BroadcastChannel<T>();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
const bc = bcRef.current!;
|
|
464
|
+
|
|
465
|
+
const receiveLoop = async () => {
|
|
466
|
+
for await (const value of bc.subscribe()) {
|
|
467
|
+
setLatest(value);
|
|
468
|
+
listenersRef.current.forEach((listener) => listener(value));
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
receiveLoop();
|
|
473
|
+
|
|
474
|
+
return () => {
|
|
475
|
+
bc.close();
|
|
476
|
+
};
|
|
477
|
+
}, []);
|
|
478
|
+
|
|
479
|
+
const subscribe = useCallback((callback: (value: T) => void) => {
|
|
480
|
+
listenersRef.current.add(callback);
|
|
481
|
+
return {
|
|
482
|
+
unsubscribe: () => {
|
|
483
|
+
listenersRef.current.delete(callback);
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}, []);
|
|
487
|
+
|
|
488
|
+
const broadcastFn = useCallback((value: T): void => {
|
|
489
|
+
bcRef.current!.send(value).catch(() => {});
|
|
490
|
+
setLatest(value);
|
|
491
|
+
}, []);
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
latest,
|
|
495
|
+
subscribe,
|
|
496
|
+
broadcast: broadcastFn,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ============================================================================
|
|
501
|
+
// usePolling Hook
|
|
502
|
+
// ============================================================================
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Options for usePolling hook
|
|
506
|
+
*/
|
|
507
|
+
export interface UsePollingOptions {
|
|
508
|
+
/** Interval in milliseconds */
|
|
509
|
+
interval: number;
|
|
510
|
+
/** Whether to start immediately */
|
|
511
|
+
immediate?: boolean;
|
|
512
|
+
/** Continue when tab is hidden */
|
|
513
|
+
continueOnHidden?: boolean;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* State returned by usePolling hook
|
|
518
|
+
*/
|
|
519
|
+
export interface PollingState<T> {
|
|
520
|
+
/** Latest data */
|
|
521
|
+
data: T | undefined;
|
|
522
|
+
/** Error if polling failed */
|
|
523
|
+
error: Error | undefined;
|
|
524
|
+
/** Whether currently polling */
|
|
525
|
+
isPolling: boolean;
|
|
526
|
+
/** Number of polls completed */
|
|
527
|
+
pollCount: number;
|
|
528
|
+
/** Start polling */
|
|
529
|
+
start: () => void;
|
|
530
|
+
/** Stop polling */
|
|
531
|
+
stop: () => void;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* React hook for polling data at intervals.
|
|
536
|
+
*
|
|
537
|
+
* @example
|
|
538
|
+
* ```tsx
|
|
539
|
+
* function LiveData() {
|
|
540
|
+
* const { data, isPolling, stop } = usePolling(
|
|
541
|
+
* async () => fetchLatestData(),
|
|
542
|
+
* { interval: 5000, immediate: true }
|
|
543
|
+
* );
|
|
544
|
+
*
|
|
545
|
+
* return (
|
|
546
|
+
* <div>
|
|
547
|
+
* <p>{data}</p>
|
|
548
|
+
* {isPolling && <button onClick={stop}>Stop</button>}
|
|
549
|
+
* </div>
|
|
550
|
+
* );
|
|
551
|
+
* }
|
|
552
|
+
* ```
|
|
553
|
+
*/
|
|
554
|
+
export function usePolling<T>(
|
|
555
|
+
factory: () => Promise<T>,
|
|
556
|
+
options: UsePollingOptions,
|
|
557
|
+
): PollingState<T> {
|
|
558
|
+
const s = useScope({ name: "usePolling" });
|
|
559
|
+
|
|
560
|
+
const [data, setData] = useState<T | undefined>(undefined);
|
|
561
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
562
|
+
const [isPolling, setIsPolling] = useState(false);
|
|
563
|
+
const [pollCount, setPollCount] = useState(0);
|
|
564
|
+
const pollerRef = useRef<ReturnType<typeof s.poll> | null>(null);
|
|
565
|
+
|
|
566
|
+
const start = useCallback((): void => {
|
|
567
|
+
if (!isPolling && !pollerRef.current) {
|
|
568
|
+
pollerRef.current = s.poll(
|
|
569
|
+
async () => {
|
|
570
|
+
try {
|
|
571
|
+
const value = await factory();
|
|
572
|
+
return { success: true, value } as { success: true; value: T };
|
|
573
|
+
} catch (e) {
|
|
574
|
+
return { success: false, error: e } as { success: false; error: unknown };
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
(result) => {
|
|
578
|
+
if (result.success) {
|
|
579
|
+
setData(result.value);
|
|
580
|
+
setPollCount((c) => c + 1);
|
|
581
|
+
} else {
|
|
582
|
+
setError(result.error instanceof Error ? result.error : new Error(String(result.error)));
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
{ interval: options.interval },
|
|
586
|
+
);
|
|
587
|
+
setIsPolling(true);
|
|
588
|
+
}
|
|
589
|
+
}, [s, factory, options.interval, isPolling]);
|
|
590
|
+
|
|
591
|
+
const stop = useCallback((): void => {
|
|
592
|
+
if (isPolling && pollerRef.current) {
|
|
593
|
+
pollerRef.current.stop();
|
|
594
|
+
setIsPolling(false);
|
|
595
|
+
pollerRef.current = null;
|
|
596
|
+
}
|
|
597
|
+
}, [isPolling]);
|
|
598
|
+
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (options.immediate !== false) {
|
|
601
|
+
start();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return () => {
|
|
605
|
+
stop();
|
|
606
|
+
};
|
|
607
|
+
}, []);
|
|
608
|
+
|
|
609
|
+
// Handle visibility change
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
if (!options.continueOnHidden && typeof document !== "undefined") {
|
|
612
|
+
const handler = () => {
|
|
613
|
+
if (document.hidden) {
|
|
614
|
+
stop();
|
|
615
|
+
} else if (options.immediate !== false) {
|
|
616
|
+
start();
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
document.addEventListener("visibilitychange", handler);
|
|
621
|
+
return () => document.removeEventListener("visibilitychange", handler);
|
|
622
|
+
}
|
|
623
|
+
}, [options.continueOnHidden, options.immediate, start, stop]);
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
data,
|
|
627
|
+
error,
|
|
628
|
+
isPolling,
|
|
629
|
+
pollCount,
|
|
630
|
+
start,
|
|
631
|
+
stop,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Re-export types
|
|
636
|
+
export type { Result, Scope, Channel, BroadcastChannel } from "go-go-scope";
|