@chromahq/react 1.0.12 → 1.0.13

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/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ interface Props {
19
19
  retryAfter?: number;
20
20
  maxRetries?: number;
21
21
  pingInterval?: number;
22
+ maxRetryCooldown?: number;
22
23
  onConnectionChange?: (status: ConnectionStatus) => void;
23
24
  onError?: (error: Error) => void;
24
25
  }
@@ -30,22 +31,42 @@ declare const BridgeProvider: React.FC<Props>;
30
31
  */
31
32
  declare const useBridge: () => BridgeContextValue;
32
33
 
33
- /**
34
- * Custom hook to send a query to the bridge and return the response.
35
- * @param key
36
- * @param payload
37
- * @returns { data: Res | undefined, loading: boolean, error: unknown }
38
- */
39
- declare function useBridgeQuery<Res = unknown>(key: string, payload?: any): {
34
+ interface UseBridgeQueryOptions {
35
+ /** Whether to automatically refetch when bridge reconnects. Default: true */
36
+ refetchOnReconnect?: boolean;
37
+ /** Whether to skip the query entirely. Default: false */
38
+ skip?: boolean;
39
+ /** Timeout duration in ms. Default: 10000 */
40
+ timeout?: number;
41
+ }
42
+ interface UseBridgeQueryResult<Res> {
40
43
  data: Res | undefined;
41
44
  loading: boolean;
42
45
  error: unknown;
43
- };
46
+ refetch: () => Promise<void>;
47
+ }
48
+ /**
49
+ * Custom hook to send a query to the bridge and return the response.
50
+ * Automatically waits for bridge connection and retries on reconnect.
51
+ * @param key The message key to send
52
+ * @param payload Optional payload to send with the message
53
+ * @param options Query options
54
+ * @returns { data, loading, error, refetch }
55
+ */
56
+ declare function useBridgeQuery<Res = unknown>(key: string, payload?: any, options?: UseBridgeQueryOptions): UseBridgeQueryResult<Res>;
44
57
 
45
- declare const useConnectionStatus: () => {
46
- status: ("connecting" | "connected" | "disconnected" | "error" | "reconnecting") | undefined;
58
+ interface ConnectionStatusResult {
59
+ /** Current connection status: 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting' */
60
+ status: 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting' | undefined;
61
+ /** Whether the bridge port is connected */
62
+ isConnected: boolean;
63
+ /** Whether the service worker is responding to pings */
47
64
  isServiceWorkerAlive: boolean;
65
+ /** Manually trigger a reconnection */
48
66
  reconnect: (() => void) | undefined;
49
- };
67
+ /** Any connection error */
68
+ error: Error | null | undefined;
69
+ }
70
+ declare const useConnectionStatus: () => ConnectionStatusResult;
50
71
 
51
72
  export { BridgeProvider, useBridge, useBridgeQuery, useConnectionStatus };
package/dist/index.js CHANGED
@@ -7,6 +7,8 @@ const BridgeProvider = ({
7
7
  retryAfter = 1e3,
8
8
  maxRetries = 10,
9
9
  pingInterval = 5e3,
10
+ maxRetryCooldown = 3e4,
11
+ // Reset retry count after 30s of max retries
10
12
  onConnectionChange,
11
13
  onError
12
14
  }) => {
@@ -15,6 +17,8 @@ const BridgeProvider = ({
15
17
  const [error, setError] = useState(null);
16
18
  const [isServiceWorkerAlive, setIsServiceWorkerAlive] = useState(false);
17
19
  const portRef = useRef(null);
20
+ const maxRetryCooldownRef = useRef(null);
21
+ const consecutivePingFailuresRef = useRef(0);
18
22
  const pendingRef = useRef(
19
23
  /* @__PURE__ */ new Map()
20
24
  );
@@ -156,8 +160,26 @@ const BridgeProvider = ({
156
160
  cleanup();
157
161
  if (retryCountRef.current < maxRetries) {
158
162
  retryCountRef.current++;
159
- const delay = retryAfter * Math.pow(2, retryCountRef.current - 1);
163
+ const delay = Math.min(retryAfter * Math.pow(2, retryCountRef.current - 1), 3e4);
164
+ console.log(
165
+ `[Bridge] Reconnecting in ${delay}ms (attempt ${retryCountRef.current}/${maxRetries})`
166
+ );
167
+ updateStatus("reconnecting");
160
168
  reconnectTimeoutRef.current = setTimeout(connect, delay);
169
+ } else {
170
+ console.warn(
171
+ `[Bridge] Max retries (${maxRetries}) reached. Will retry after ${maxRetryCooldown}ms cooldown.`
172
+ );
173
+ if (maxRetryCooldownRef.current) {
174
+ clearTimeout(maxRetryCooldownRef.current);
175
+ }
176
+ maxRetryCooldownRef.current = setTimeout(() => {
177
+ console.log(
178
+ "[Bridge] Cooldown complete, resetting retry count and attempting reconnection..."
179
+ );
180
+ retryCountRef.current = 0;
181
+ connect();
182
+ }, maxRetryCooldown);
161
183
  }
162
184
  });
163
185
  const bridgeInstance = {
@@ -172,22 +194,32 @@ const BridgeProvider = ({
172
194
  if (pendingRef.current.has(id)) {
173
195
  pendingRef.current.delete(id);
174
196
  consecutiveTimeoutsRef.current++;
175
- if (consecutiveTimeoutsRef.current >= 2) {
197
+ const errorMessage = `Request timed out after ${timeoutDuration}ms for key: ${key} (id: ${id})`;
198
+ console.warn(`[Bridge] ${errorMessage}`);
199
+ if (consecutiveTimeoutsRef.current >= 2 && status !== "reconnecting") {
176
200
  console.warn(
177
- "[Bridge] Multiple timeouts detected, service worker may be unresponsive. Reconnecting..."
201
+ `[Bridge] ${consecutiveTimeoutsRef.current} consecutive timeouts detected, service worker may be unresponsive. Reconnecting...`
178
202
  );
179
203
  setIsServiceWorkerAlive(false);
180
204
  updateStatus("reconnecting");
181
- cleanup();
205
+ pendingRef.current.forEach(
206
+ ({ reject: pendingReject, timeout: pendingTimeout }, pendingId) => {
207
+ if (pendingId !== id) {
208
+ clearTimeout(pendingTimeout);
209
+ pendingReject(new Error("Bridge reconnecting due to consecutive timeouts"));
210
+ }
211
+ }
212
+ );
213
+ pendingRef.current.clear();
182
214
  retryCountRef.current = 0;
183
215
  isConnectingRef.current = false;
184
- setTimeout(connect, 500);
216
+ consecutiveTimeoutsRef.current = 0;
217
+ setTimeout(() => {
218
+ cleanup();
219
+ connect();
220
+ }, 100);
185
221
  }
186
- reject(
187
- new Error(
188
- `Request timed out after ${timeoutDuration} ms for key: ${key} with id: ${id}`
189
- )
190
- );
222
+ reject(new Error(errorMessage));
191
223
  }
192
224
  }, timeoutDuration);
193
225
  pendingRef.current.set(id, { resolve, reject, timeout });
@@ -260,7 +292,9 @@ const BridgeProvider = ({
260
292
  return false;
261
293
  }
262
294
  },
263
- isConnected: true
295
+ get isConnected() {
296
+ return portRef.current !== null && status === "connected";
297
+ }
264
298
  };
265
299
  setBridge(bridgeInstance);
266
300
  updateStatus("connected");
@@ -272,12 +306,27 @@ const BridgeProvider = ({
272
306
  if (pingIntervalRef.current) {
273
307
  clearInterval(pingIntervalRef.current);
274
308
  }
309
+ consecutivePingFailuresRef.current = 0;
275
310
  pingIntervalRef.current = setInterval(async () => {
276
311
  if (bridgeInstance && portRef.current) {
277
312
  const alive = await bridgeInstance.ping();
278
313
  setIsServiceWorkerAlive(alive);
279
314
  if (!alive) {
280
- console.warn("[Bridge] Service worker ping failed, may be unresponsive");
315
+ consecutivePingFailuresRef.current++;
316
+ console.warn(
317
+ `[Bridge] Service worker ping failed (${consecutivePingFailuresRef.current} consecutive failures)`
318
+ );
319
+ if (consecutivePingFailuresRef.current >= 2) {
320
+ console.warn("[Bridge] Auto-reconnecting due to unresponsive service worker...");
321
+ consecutivePingFailuresRef.current = 0;
322
+ retryCountRef.current = 0;
323
+ isConnectingRef.current = false;
324
+ updateStatus("reconnecting");
325
+ cleanup();
326
+ connect();
327
+ }
328
+ } else {
329
+ consecutivePingFailuresRef.current = 0;
281
330
  }
282
331
  }
283
332
  }, pingInterval);
@@ -299,8 +348,36 @@ const BridgeProvider = ({
299
348
  }, [connect, updateStatus]);
300
349
  useEffect(() => {
301
350
  connect();
302
- return cleanup;
303
- }, [connect, cleanup]);
351
+ const handleVisibilityChange = () => {
352
+ if (document.visibilityState === "visible") {
353
+ if (status === "disconnected" || status === "error") {
354
+ console.log("[Bridge] Tab became visible, attempting reconnection...");
355
+ retryCountRef.current = 0;
356
+ connect();
357
+ } else if (status === "connected" && bridge) {
358
+ bridge.ping().then((alive) => {
359
+ if (!alive) {
360
+ console.warn(
361
+ "[Bridge] Tab became visible but service worker unresponsive, reconnecting..."
362
+ );
363
+ retryCountRef.current = 0;
364
+ isConnectingRef.current = false;
365
+ cleanup();
366
+ connect();
367
+ }
368
+ });
369
+ }
370
+ }
371
+ };
372
+ document.addEventListener("visibilitychange", handleVisibilityChange);
373
+ return () => {
374
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
375
+ if (maxRetryCooldownRef.current) {
376
+ clearTimeout(maxRetryCooldownRef.current);
377
+ }
378
+ cleanup();
379
+ };
380
+ }, [connect, cleanup, status, bridge]);
304
381
  const contextValue = useMemo(
305
382
  () => ({
306
383
  bridge,
@@ -322,25 +399,141 @@ const useBridge = () => {
322
399
  return context;
323
400
  };
324
401
 
325
- function useBridgeQuery(key, payload) {
326
- const { bridge } = useBridge();
402
+ function useBridgeQuery(key, payload, options = {}) {
403
+ const { refetchOnReconnect = true, skip = false, timeout } = options;
404
+ const { bridge, status } = useBridge();
327
405
  const [data, setData] = useState();
328
- const [loading, setLoading] = useState(true);
406
+ const [loading, setLoading] = useState(!skip);
329
407
  const [error, setError] = useState();
330
- useEffect(() => {
331
- let mounted = true;
408
+ const prevStatusRef = useRef(status);
409
+ const fetchIdRef = useRef(0);
410
+ const executeQuery = useCallback(async () => {
411
+ if (skip) {
412
+ setLoading(false);
413
+ return;
414
+ }
415
+ if (!bridge || !bridge.isConnected) {
416
+ return;
417
+ }
418
+ const currentFetchId = ++fetchIdRef.current;
332
419
  setLoading(true);
333
- if (!bridge) {
334
- setError(new Error("Bridge is not initialized"));
420
+ setError(void 0);
421
+ try {
422
+ const res = await bridge.send(key, payload, timeout);
423
+ if (currentFetchId === fetchIdRef.current) {
424
+ setData(res);
425
+ setError(void 0);
426
+ }
427
+ } catch (e) {
428
+ if (currentFetchId === fetchIdRef.current) {
429
+ setError(e);
430
+ }
431
+ } finally {
432
+ if (currentFetchId === fetchIdRef.current) {
433
+ setLoading(false);
434
+ }
435
+ }
436
+ }, [bridge, key, payload, skip, timeout]);
437
+ useEffect(() => {
438
+ if (skip) {
335
439
  setLoading(false);
336
440
  return;
337
441
  }
338
- bridge.send(key, payload).then((res) => mounted && setData(res)).catch((e) => mounted && setError(e)).finally(() => mounted && setLoading(false));
339
- return () => {
340
- mounted = false;
341
- };
342
- }, [key, JSON.stringify(payload)]);
343
- return { data, loading, error };
442
+ if (status === "connected" && bridge?.isConnected) {
443
+ executeQuery();
444
+ } else if (status === "connecting") {
445
+ setLoading(true);
446
+ } else if (status === "disconnected" || status === "error") {
447
+ setLoading(false);
448
+ }
449
+ }, [status, bridge?.isConnected, key, JSON.stringify(payload), skip, executeQuery]);
450
+ useEffect(() => {
451
+ const wasDisconnected = prevStatusRef.current === "disconnected" || prevStatusRef.current === "error" || prevStatusRef.current === "reconnecting";
452
+ const isNowConnected = status === "connected";
453
+ if (refetchOnReconnect && wasDisconnected && isNowConnected && !skip) {
454
+ executeQuery();
455
+ }
456
+ prevStatusRef.current = status;
457
+ }, [status, refetchOnReconnect, skip, executeQuery]);
458
+ const refetch = useCallback(async () => {
459
+ await executeQuery();
460
+ }, [executeQuery]);
461
+ return { data, loading, error, refetch };
462
+ }
463
+
464
+ function useBridgeMutation(key, options = {}) {
465
+ const { timeout, onSuccess, onError, retries = 0, retryDelay = 1e3 } = options;
466
+ const { bridge, status } = useBridge();
467
+ const [data, setData] = useState();
468
+ const [loading, setLoading] = useState(false);
469
+ const [error, setError] = useState();
470
+ const mutationIdRef = useRef(0);
471
+ const mutate = useCallback(
472
+ async (payload) => {
473
+ if (!bridge) {
474
+ const err = new Error("Bridge is not initialized");
475
+ setError(err);
476
+ onError?.(err);
477
+ throw err;
478
+ }
479
+ if (!bridge.isConnected) {
480
+ const err = new Error("Bridge is not connected");
481
+ setError(err);
482
+ onError?.(err);
483
+ throw err;
484
+ }
485
+ const currentMutationId = ++mutationIdRef.current;
486
+ setLoading(true);
487
+ setError(void 0);
488
+ let lastError;
489
+ let attempts = 0;
490
+ while (attempts <= retries) {
491
+ try {
492
+ const result = await bridge.send(key, payload, timeout);
493
+ if (currentMutationId === mutationIdRef.current) {
494
+ setData(result);
495
+ setError(void 0);
496
+ setLoading(false);
497
+ onSuccess?.(result);
498
+ }
499
+ return result;
500
+ } catch (e) {
501
+ lastError = e instanceof Error ? e : new Error(String(e));
502
+ attempts++;
503
+ if (attempts <= retries && bridge.isConnected) {
504
+ console.warn(
505
+ `[Bridge] Mutation "${key}" failed (attempt ${attempts}/${retries + 1}), retrying in ${retryDelay}ms...`
506
+ );
507
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
508
+ }
509
+ }
510
+ }
511
+ if (currentMutationId === mutationIdRef.current) {
512
+ setError(lastError);
513
+ setLoading(false);
514
+ onError?.(lastError);
515
+ }
516
+ throw lastError;
517
+ },
518
+ [bridge, key, timeout, onSuccess, onError, retries, retryDelay]
519
+ );
520
+ const reset = useCallback(() => {
521
+ setData(void 0);
522
+ setError(void 0);
523
+ setLoading(false);
524
+ }, []);
525
+ return { mutate, data, loading, error, reset };
344
526
  }
345
527
 
346
- export { BridgeProvider, useBridge, useBridgeQuery };
528
+ const useConnectionStatus = () => {
529
+ const context = useContext(BridgeContext);
530
+ return {
531
+ status: context?.status,
532
+ isConnected: context?.status === "connected",
533
+ isServiceWorkerAlive: context?.isServiceWorkerAlive ?? false,
534
+ reconnect: context?.reconnect,
535
+ error: context?.error
536
+ };
537
+ };
538
+
539
+ export { BridgeProvider, useBridge, useBridgeMutation, useBridgeQuery, useConnectionStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromahq/react",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "React bindings for the Chroma Chrome extension framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",