@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/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";