@chromahq/react 1.0.44 → 1.0.46

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
@@ -36,6 +36,23 @@ interface Bridge {
36
36
  * @param durationMs - How long to pause health checks in milliseconds
37
37
  */
38
38
  pauseHealthChecks: (durationMs: number) => void;
39
+ /**
40
+ * Ensure the service worker is connected and responsive before performing a heavy operation.
41
+ * This performs a quick ping and returns true if successful.
42
+ * On Windows/Brave, use this before crypto operations to verify the SW hasn't silently restarted.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const ready = await bridge.ensureConnected();
47
+ * if (!ready) {
48
+ * showToast('Connection lost. Please try again.');
49
+ * return;
50
+ * }
51
+ * bridge.pauseHealthChecks(30000);
52
+ * await bridge.send('unlock', { password });
53
+ * ```
54
+ */
55
+ ensureConnected: () => Promise<boolean>;
39
56
  }
40
57
  interface BridgeContextValue {
41
58
  bridge: Bridge | null;
package/dist/index.js CHANGED
@@ -86,8 +86,22 @@ const CONFIG = {
86
86
  SW_RESTART_MAX_DELAY: 5e3,
87
87
  // Threshold for counting timeouts toward reconnection (only count fast timeouts as failures)
88
88
  // Requests with timeout > this value are considered intentional long operations
89
- TIMEOUT_FAILURE_THRESHOLD_MS: 3e4
89
+ TIMEOUT_FAILURE_THRESHOLD_MS: 3e4,
90
90
  // Only count timeouts < 30s as potential SW issues
91
+ // Request queue settings for transparent retry
92
+ REQUEST_QUEUE_MAX_SIZE: 50,
93
+ // Maximum queued requests during disconnection
94
+ REQUEST_MAX_RETRIES: 3,
95
+ // Max retries per request before giving up
96
+ REQUEST_RETRY_BASE_DELAY: 200,
97
+ // Base delay for request retry backoff
98
+ REQUEST_RETRY_MAX_DELAY: 2e3,
99
+ // Max delay for request retry backoff
100
+ QUEUE_DRAIN_DELAY: 50,
101
+ // Delay between processing queued requests
102
+ // Optimistic health - don't surface unhealthy state immediately
103
+ HEALTH_GRACE_PERIOD_MS: 3e3
104
+ // Wait this long before showing unhealthy
91
105
  };
92
106
  const calculateBackoffDelay = (attempt, baseDelay, maxDelay) => Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
93
107
  const clearTimeoutSafe = (ref) => {
@@ -111,17 +125,74 @@ const BridgeContext = createContext(null);
111
125
  function createBridgeInstance(deps) {
112
126
  const {
113
127
  portRef,
128
+ portDisconnectedRef,
114
129
  pendingRequestsRef,
130
+ requestQueueRef,
131
+ activeIdempotencyKeysRef,
115
132
  eventListenersRef,
116
133
  messageIdRef,
117
134
  isConnectedRef,
135
+ isReconnectingRef,
118
136
  consecutiveTimeoutsRef,
119
137
  reconnectionGracePeriodRef,
120
138
  healthPausedUntilRef,
121
139
  defaultTimeout,
122
140
  timeoutFailureThreshold,
123
- onReconnectNeeded
124
- } = deps;
141
+ onReconnectNeeded} = deps;
142
+ const isPortValid = () => {
143
+ if (!portRef.current) return false;
144
+ if (portDisconnectedRef.current) return false;
145
+ if (!isConnectedRef.current) return false;
146
+ return true;
147
+ };
148
+ const generateIdempotencyKey = (key, payload) => {
149
+ const isWriteOperation = !key.startsWith("get") && !key.startsWith("fetch") && key !== "__ping__";
150
+ if (!isWriteOperation) return "";
151
+ try {
152
+ return `${key}:${JSON.stringify(payload)}`;
153
+ } catch {
154
+ return `${key}:${Date.now()}`;
155
+ }
156
+ };
157
+ const queueRequest = (id, key, payload, timeoutDuration, resolve, reject, idempotencyKey) => {
158
+ if (key === "__ping__" || key === "__bridge_diagnostics__") {
159
+ return false;
160
+ }
161
+ if (requestQueueRef.current.length >= CONFIG.REQUEST_QUEUE_MAX_SIZE) {
162
+ {
163
+ console.warn("[Bridge] Request queue full, rejecting request");
164
+ }
165
+ return false;
166
+ }
167
+ if (idempotencyKey && activeIdempotencyKeysRef.current.has(idempotencyKey)) {
168
+ {
169
+ console.log(`[Bridge] Duplicate request detected, skipping: ${key}`);
170
+ }
171
+ return true;
172
+ }
173
+ if (idempotencyKey) {
174
+ activeIdempotencyKeysRef.current.add(idempotencyKey);
175
+ }
176
+ const queuedRequest = {
177
+ id,
178
+ key,
179
+ payload,
180
+ timeoutDuration,
181
+ resolve,
182
+ reject,
183
+ retryCount: 0,
184
+ maxRetries: CONFIG.REQUEST_MAX_RETRIES,
185
+ queuedAt: Date.now(),
186
+ idempotencyKey
187
+ };
188
+ requestQueueRef.current.push(queuedRequest);
189
+ {
190
+ console.log(
191
+ `[Bridge] Request queued: ${key} (queue size: ${requestQueueRef.current.length})`
192
+ );
193
+ }
194
+ return true;
195
+ };
125
196
  const rejectAllPending = (message) => {
126
197
  pendingRequestsRef.current.forEach(({ reject, timeout, timeoutDuration }, id) => {
127
198
  if (timeoutDuration <= timeoutFailureThreshold) {
@@ -134,19 +205,29 @@ function createBridgeInstance(deps) {
134
205
  const send = (key, payload, timeoutDuration = defaultTimeout) => {
135
206
  return new Promise((resolve, reject) => {
136
207
  const id = `msg_${++messageIdRef.current}`;
208
+ const idempotencyKey = generateIdempotencyKey(key, payload);
137
209
  const finalizePendingWithError = (error) => {
138
210
  const pending = pendingRequestsRef.current.get(id);
139
211
  if (!pending) return;
140
212
  clearTimeout(pending.timeout);
141
213
  pendingRequestsRef.current.delete(id);
214
+ if (idempotencyKey) {
215
+ activeIdempotencyKeysRef.current.delete(idempotencyKey);
216
+ }
142
217
  pending.reject(error instanceof Error ? error : new Error(error));
143
218
  };
144
- const triggerRuntimeFallback = (reason) => {
219
+ const triggerRuntimeFallback = (reason, retryCount = 0) => {
220
+ const MAX_FALLBACK_RETRIES = 2;
221
+ const FALLBACK_RETRY_DELAY = 300;
145
222
  {
146
- console.warn(`[Bridge] Falling back to runtime.sendMessage (${reason})`);
223
+ console.warn(
224
+ `[Bridge] Falling back to runtime.sendMessage (${reason})${retryCount > 0 ? ` [retry ${retryCount}]` : ""}`
225
+ );
147
226
  }
148
227
  recordDiagnostics.fallbackSent(reason);
149
- onReconnectNeeded();
228
+ if (retryCount === 0) {
229
+ onReconnectNeeded();
230
+ }
150
231
  if (!chrome?.runtime?.sendMessage) {
151
232
  const message = "chrome.runtime.sendMessage not available";
152
233
  recordDiagnostics.fallbackError(message);
@@ -159,13 +240,24 @@ function createBridgeInstance(deps) {
159
240
  id,
160
241
  key,
161
242
  payload,
162
- metadata: { transport: "direct", fallbackReason: reason },
243
+ metadata: { transport: "direct", fallbackReason: reason, retryCount },
163
244
  [DIRECT_MESSAGE_FLAG]: true
164
245
  },
165
246
  (response) => {
166
247
  const runtimeError = consumeRuntimeError();
167
248
  const pending = pendingRequestsRef.current.get(id);
168
249
  if (!pending) return;
250
+ if (runtimeError?.includes("Receiving end does not exist") && retryCount < MAX_FALLBACK_RETRIES) {
251
+ if (BRIDGE_ENABLE_LOGS) {
252
+ console.warn(
253
+ `[Bridge] SW not ready, retrying fallback in ${FALLBACK_RETRY_DELAY}ms...`
254
+ );
255
+ }
256
+ setTimeout(() => {
257
+ triggerRuntimeFallback(reason, retryCount + 1);
258
+ }, FALLBACK_RETRY_DELAY);
259
+ return;
260
+ }
169
261
  if (runtimeError) {
170
262
  recordDiagnostics.fallbackError(runtimeError);
171
263
  clearTimeout(pending.timeout);
@@ -228,8 +320,28 @@ function createBridgeInstance(deps) {
228
320
  timeout,
229
321
  timeoutDuration
230
322
  });
231
- if (!portRef.current) {
232
- triggerRuntimeFallback("port-unavailable");
323
+ if (!isPortValid()) {
324
+ if (isReconnectingRef.current) {
325
+ clearTimeout(timeout);
326
+ pendingRequestsRef.current.delete(id);
327
+ const queued = queueRequest(
328
+ id,
329
+ key,
330
+ payload,
331
+ timeoutDuration,
332
+ resolve,
333
+ reject,
334
+ idempotencyKey
335
+ );
336
+ if (queued) {
337
+ {
338
+ console.log(`[Bridge] Request queued during reconnection: ${key}`);
339
+ }
340
+ return;
341
+ }
342
+ }
343
+ const reason = !portRef.current ? "port-unavailable" : portDisconnectedRef.current ? "port-disconnected" : "port-not-connected";
344
+ triggerRuntimeFallback(reason);
233
345
  return;
234
346
  }
235
347
  try {
@@ -237,6 +349,23 @@ function createBridgeInstance(deps) {
237
349
  setTimeout(() => {
238
350
  const errorMessage = consumeRuntimeError();
239
351
  if (errorMessage && pendingRequestsRef.current.has(id)) {
352
+ if (isReconnectingRef.current) {
353
+ const pending = pendingRequestsRef.current.get(id);
354
+ if (pending) {
355
+ clearTimeout(pending.timeout);
356
+ pendingRequestsRef.current.delete(id);
357
+ const queued = queueRequest(
358
+ id,
359
+ key,
360
+ payload,
361
+ timeoutDuration,
362
+ pending.resolve,
363
+ pending.reject,
364
+ idempotencyKey
365
+ );
366
+ if (queued) return;
367
+ }
368
+ }
240
369
  finalizePendingWithError(errorMessage);
241
370
  }
242
371
  }, 0);
@@ -304,6 +433,25 @@ function createBridgeInstance(deps) {
304
433
  {
305
434
  console.log(`[Bridge] Health checks paused for ${Math.round(durationMs / 1e3)}s`);
306
435
  }
436
+ },
437
+ ensureConnected: async () => {
438
+ if (!isPortValid()) {
439
+ {
440
+ console.warn("[Bridge] ensureConnected: Port invalid, triggering reconnect");
441
+ }
442
+ onReconnectNeeded();
443
+ return false;
444
+ }
445
+ const QUICK_PING_TIMEOUT = 5e3;
446
+ try {
447
+ await send("__ping__", void 0, QUICK_PING_TIMEOUT);
448
+ return true;
449
+ } catch {
450
+ {
451
+ console.warn("[Bridge] ensureConnected: Ping failed, SW may be unresponsive");
452
+ }
453
+ return false;
454
+ }
307
455
  }
308
456
  };
309
457
  return bridge;
@@ -387,8 +535,13 @@ const BridgeProvider = ({
387
535
  const [error, setError] = useState(null);
388
536
  const [isServiceWorkerAlive, setIsServiceWorkerAlive] = useState(false);
389
537
  const portRef = useRef(null);
538
+ const portDisconnectedRef = useRef(false);
390
539
  const isConnectedRef = useRef(false);
391
540
  const isConnectingRef = useRef(false);
541
+ const isReconnectingRef = useRef(false);
542
+ const requestQueueRef = useRef([]);
543
+ const activeIdempotencyKeysRef = useRef(/* @__PURE__ */ new Set());
544
+ const queueDrainTimeoutRef = useRef(null);
392
545
  const retryCountRef = useRef(0);
393
546
  const reconnectTimeoutRef = useRef(null);
394
547
  const maxRetryCooldownRef = useRef(null);
@@ -433,7 +586,7 @@ const BridgeProvider = ({
433
586
  },
434
587
  [onError, updateStatus]
435
588
  );
436
- const cleanup = useCallback((emitDisconnect = true) => {
589
+ const cleanup = useCallback((emitDisconnect = true, preserveQueue = false) => {
437
590
  const wasConnected = isConnectedRef.current || portRef.current !== null;
438
591
  if (emitDisconnect && wasConnected) {
439
592
  eventListenersRef.current.get("bridge:disconnected")?.forEach((handler) => {
@@ -448,6 +601,7 @@ const BridgeProvider = ({
448
601
  }
449
602
  clearTimeoutSafe(reconnectTimeoutRef);
450
603
  clearTimeoutSafe(triggerReconnectTimeoutRef);
604
+ clearTimeoutSafe(queueDrainTimeoutRef);
451
605
  clearIntervalSafe(errorCheckIntervalRef);
452
606
  clearIntervalSafe(pingIntervalRef);
453
607
  if (portRef.current) {
@@ -457,11 +611,40 @@ const BridgeProvider = ({
457
611
  }
458
612
  portRef.current = null;
459
613
  }
460
- pendingRequestsRef.current.forEach(({ reject, timeout }) => {
461
- clearTimeout(timeout);
462
- reject(new Error("Bridge disconnected"));
463
- });
464
- pendingRequestsRef.current.clear();
614
+ portDisconnectedRef.current = false;
615
+ if (preserveQueue) {
616
+ pendingRequestsRef.current.forEach(({ resolve, reject, timeout, timeoutDuration }, id) => {
617
+ clearTimeout(timeout);
618
+ const key = id.split("_")[0] || "unknown";
619
+ requestQueueRef.current.push({
620
+ id,
621
+ key,
622
+ payload: void 0,
623
+ // We don't have the payload anymore, but the request is already in flight
624
+ timeoutDuration,
625
+ resolve,
626
+ reject,
627
+ retryCount: 0,
628
+ maxRetries: CONFIG.REQUEST_MAX_RETRIES,
629
+ queuedAt: Date.now()
630
+ });
631
+ });
632
+ pendingRequestsRef.current.clear();
633
+ } else {
634
+ pendingRequestsRef.current.forEach(({ reject, timeout }) => {
635
+ clearTimeout(timeout);
636
+ reject(new Error("Bridge disconnected"));
637
+ });
638
+ pendingRequestsRef.current.clear();
639
+ requestQueueRef.current.forEach(({ reject, idempotencyKey }) => {
640
+ if (idempotencyKey) {
641
+ activeIdempotencyKeysRef.current.delete(idempotencyKey);
642
+ }
643
+ reject(new Error("Bridge disconnected"));
644
+ });
645
+ requestQueueRef.current = [];
646
+ activeIdempotencyKeysRef.current.clear();
647
+ }
465
648
  if (!emitDisconnect) {
466
649
  eventListenersRef.current.clear();
467
650
  }
@@ -499,6 +682,86 @@ const BridgeProvider = ({
499
682
  console.warn("[Bridge] Received response for unknown/expired request:", message.id);
500
683
  }
501
684
  }, []);
685
+ const drainRequestQueue = useCallback(() => {
686
+ if (requestQueueRef.current.length === 0) {
687
+ {
688
+ console.log("[Bridge] Request queue empty, nothing to drain");
689
+ }
690
+ return;
691
+ }
692
+ if (!bridgeRef.current || !isConnectedRef.current) {
693
+ {
694
+ console.log("[Bridge] Cannot drain queue - not connected");
695
+ }
696
+ return;
697
+ }
698
+ {
699
+ console.log(`[Bridge] Draining request queue (${requestQueueRef.current.length} requests)`);
700
+ }
701
+ const processNextRequest = () => {
702
+ if (!isMountedRef.current || !isConnectedRef.current) return;
703
+ const request = requestQueueRef.current.shift();
704
+ if (!request) {
705
+ {
706
+ console.log("[Bridge] Request queue drained successfully");
707
+ }
708
+ return;
709
+ }
710
+ const queuedDuration = Date.now() - request.queuedAt;
711
+ if (queuedDuration > request.timeoutDuration) {
712
+ {
713
+ console.warn(`[Bridge] Queued request expired: ${request.key}`);
714
+ }
715
+ if (request.idempotencyKey) {
716
+ activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
717
+ }
718
+ request.reject(new Error(`Request expired while queued: ${request.key}`));
719
+ queueDrainTimeoutRef.current = setTimeout(processNextRequest, CONFIG.QUEUE_DRAIN_DELAY);
720
+ return;
721
+ }
722
+ {
723
+ console.log(
724
+ `[Bridge] Re-sending queued request: ${request.key} (retry ${request.retryCount + 1}/${request.maxRetries})`
725
+ );
726
+ }
727
+ bridgeRef.current.send(request.key, request.payload, request.timeoutDuration - queuedDuration).then((data) => {
728
+ if (request.idempotencyKey) {
729
+ activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
730
+ }
731
+ request.resolve(data);
732
+ }).catch((error2) => {
733
+ request.retryCount++;
734
+ if (request.retryCount < request.maxRetries && isConnectedRef.current) {
735
+ const retryDelay = calculateBackoffDelay(
736
+ request.retryCount,
737
+ CONFIG.REQUEST_RETRY_BASE_DELAY,
738
+ CONFIG.REQUEST_RETRY_MAX_DELAY
739
+ );
740
+ {
741
+ console.log(
742
+ `[Bridge] Request failed, re-queuing: ${request.key} (retry in ${retryDelay}ms)`
743
+ );
744
+ }
745
+ setTimeout(() => {
746
+ if (isMountedRef.current) {
747
+ requestQueueRef.current.unshift(request);
748
+ processNextRequest();
749
+ }
750
+ }, retryDelay);
751
+ return;
752
+ }
753
+ if (request.idempotencyKey) {
754
+ activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
755
+ }
756
+ request.reject(error2);
757
+ }).finally(() => {
758
+ if (isMountedRef.current) {
759
+ queueDrainTimeoutRef.current = setTimeout(processNextRequest, CONFIG.QUEUE_DRAIN_DELAY);
760
+ }
761
+ });
762
+ };
763
+ processNextRequest();
764
+ }, []);
502
765
  const scheduleReconnect = useCallback(
503
766
  (connectFn) => {
504
767
  if (!isMountedRef.current) return;
@@ -546,6 +809,7 @@ const BridgeProvider = ({
546
809
  }
547
810
  return;
548
811
  }
812
+ isReconnectingRef.current = true;
549
813
  swRestartRetryCountRef.current++;
550
814
  const delay = calculateBackoffDelay(
551
815
  swRestartRetryCountRef.current,
@@ -578,7 +842,7 @@ const BridgeProvider = ({
578
842
  return;
579
843
  }
580
844
  isConnectingRef.current = true;
581
- cleanup(false);
845
+ cleanup(false, true);
582
846
  if (!chrome?.runtime?.connect) {
583
847
  handleError(new Error("Chrome runtime not available"));
584
848
  isConnectingRef.current = false;
@@ -620,6 +884,7 @@ const BridgeProvider = ({
620
884
  if (BRIDGE_ENABLE_LOGS) {
621
885
  console.warn("[Bridge] *** onDisconnect FIRED ***");
622
886
  }
887
+ portDisconnectedRef.current = true;
623
888
  isConnectingRef.current = false;
624
889
  const disconnectError = consumeRuntimeError();
625
890
  recordDiagnostics.portDisconnect(disconnectError);
@@ -628,7 +893,7 @@ const BridgeProvider = ({
628
893
  console.warn("[Bridge] isMounted:", isMountedRef.current);
629
894
  }
630
895
  updateStatus("disconnected");
631
- cleanup();
896
+ cleanup(true, true);
632
897
  if (isMountedRef.current) {
633
898
  if (BRIDGE_ENABLE_LOGS) {
634
899
  console.log("[Bridge] Scheduling SW restart reconnect...");
@@ -644,28 +909,36 @@ const BridgeProvider = ({
644
909
  if (!isMountedRef.current) return;
645
910
  setIsServiceWorkerAlive(false);
646
911
  updateStatus("reconnecting");
912
+ isReconnectingRef.current = true;
647
913
  retryCountRef.current = 0;
648
914
  isConnectingRef.current = false;
649
915
  clearTimeoutSafe(triggerReconnectTimeoutRef);
650
916
  triggerReconnectTimeoutRef.current = setTimeout(() => {
651
917
  if (!isMountedRef.current) return;
652
- cleanup(false);
918
+ cleanup(false, true);
653
919
  connect();
654
920
  }, CONFIG.RECONNECT_DELAY);
655
921
  };
656
922
  const bridgeInstance = createBridgeInstance({
657
923
  portRef,
924
+ portDisconnectedRef,
658
925
  pendingRequestsRef,
926
+ requestQueueRef,
927
+ activeIdempotencyKeysRef,
659
928
  eventListenersRef,
660
929
  messageIdRef,
661
930
  isConnectedRef,
931
+ isReconnectingRef,
662
932
  consecutiveTimeoutsRef,
663
933
  reconnectionGracePeriodRef,
664
934
  healthPausedUntilRef,
665
935
  defaultTimeout,
666
936
  timeoutFailureThreshold,
667
- onReconnectNeeded: triggerReconnect
937
+ onReconnectNeeded: triggerReconnect,
938
+ drainRequestQueue
668
939
  });
940
+ portDisconnectedRef.current = false;
941
+ isReconnectingRef.current = false;
669
942
  setBridge(bridgeInstance);
670
943
  isConnectedRef.current = true;
671
944
  updateStatus("connected");
@@ -675,6 +948,11 @@ const BridgeProvider = ({
675
948
  swRestartRetryCountRef.current = 0;
676
949
  consecutiveTimeoutsRef.current = 0;
677
950
  isConnectingRef.current = false;
951
+ setTimeout(() => {
952
+ if (isMountedRef.current && isConnectedRef.current) {
953
+ drainRequestQueue();
954
+ }
955
+ }, 200);
678
956
  reconnectionGracePeriodRef.current = true;
679
957
  setTimeout(() => {
680
958
  reconnectionGracePeriodRef.current = false;
@@ -723,6 +1001,7 @@ const BridgeProvider = ({
723
1001
  handleMessage,
724
1002
  scheduleReconnect,
725
1003
  scheduleSwRestartReconnect,
1004
+ drainRequestQueue,
726
1005
  defaultTimeout,
727
1006
  updateStatus,
728
1007
  pingInterval
@@ -980,15 +1259,19 @@ function broadcastHealthChange(status, isHealthy) {
980
1259
  });
981
1260
  }
982
1261
  const ServiceWorkerHealthContext = createContext(null);
1262
+ const HEALTH_GRACE_PERIOD_MS = 3e3;
983
1263
  const ServiceWorkerHealthProvider = ({
984
1264
  children,
985
1265
  onHealthChange
986
1266
  }) => {
987
1267
  const bridgeContext = useContext(BridgeContext);
988
1268
  const [lastHealthyAt, setLastHealthyAt] = useState(null);
1269
+ const [unhealthyStartedAt, setUnhealthyStartedAt] = useState(null);
1270
+ const [graceExpired, setGraceExpired] = useState(false);
1271
+ const graceTimeoutRef = useRef(null);
989
1272
  const bridgeStatus = bridgeContext?.status;
990
1273
  const isServiceWorkerAlive = bridgeContext?.isServiceWorkerAlive ?? false;
991
- const derivedStatus = useMemo(() => {
1274
+ const rawStatus = useMemo(() => {
992
1275
  if (!bridgeStatus) return "unknown";
993
1276
  if (bridgeStatus === "connected" && isServiceWorkerAlive) {
994
1277
  return "healthy";
@@ -998,14 +1281,41 @@ const ServiceWorkerHealthProvider = ({
998
1281
  }
999
1282
  return "unhealthy";
1000
1283
  }, [bridgeStatus, isServiceWorkerAlive]);
1284
+ useEffect(() => {
1285
+ if (rawStatus === "healthy") {
1286
+ setUnhealthyStartedAt(null);
1287
+ setGraceExpired(false);
1288
+ if (graceTimeoutRef.current) {
1289
+ clearTimeout(graceTimeoutRef.current);
1290
+ graceTimeoutRef.current = null;
1291
+ }
1292
+ } else if (rawStatus !== "healthy" && !unhealthyStartedAt) {
1293
+ setUnhealthyStartedAt(Date.now());
1294
+ graceTimeoutRef.current = setTimeout(() => {
1295
+ setGraceExpired(true);
1296
+ }, HEALTH_GRACE_PERIOD_MS);
1297
+ }
1298
+ return () => {
1299
+ if (graceTimeoutRef.current) {
1300
+ clearTimeout(graceTimeoutRef.current);
1301
+ }
1302
+ };
1303
+ }, [rawStatus, unhealthyStartedAt]);
1304
+ const derivedStatus = useMemo(() => {
1305
+ if (rawStatus === "healthy") return "healthy";
1306
+ if (!graceExpired && lastHealthyAt) {
1307
+ return "healthy";
1308
+ }
1309
+ return rawStatus;
1310
+ }, [rawStatus, graceExpired, lastHealthyAt]);
1001
1311
  const isHealthy = derivedStatus === "healthy";
1002
1312
  const isRecovering = derivedStatus === "recovering";
1003
1313
  const isLoading = derivedStatus === "recovering" || derivedStatus === "unknown";
1004
1314
  useEffect(() => {
1005
- if (isHealthy) {
1315
+ if (rawStatus === "healthy") {
1006
1316
  setLastHealthyAt(Date.now());
1007
1317
  }
1008
- }, [isHealthy]);
1318
+ }, [rawStatus]);
1009
1319
  useEffect(() => {
1010
1320
  broadcastHealthChange(derivedStatus, isHealthy);
1011
1321
  onHealthChange?.(derivedStatus, isHealthy);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromahq/react",
3
- "version": "1.0.44",
3
+ "version": "1.0.46",
4
4
  "description": "React bindings for the Chroma Chrome extension framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",