@chromahq/react 1.0.23 → 1.0.25

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
@@ -76,7 +76,158 @@ interface ConnectionStatusResult {
76
76
  reconnect: (() => void) | undefined;
77
77
  /** Any connection error */
78
78
  error: Error | null | undefined;
79
+ /** Whether the system is fully ready (bridge connected + optional store ready) */
80
+ isReady: boolean;
81
+ /** Whether the system is loading (bridge connecting/reconnecting OR store not ready) */
82
+ isLoading: boolean;
79
83
  }
80
- declare const useConnectionStatus: () => ConnectionStatusResult;
84
+ /**
85
+ * Store interface for readiness checking.
86
+ * Pass the store instance directly (e.g., `appStore`), NOT via useStore() hook.
87
+ */
88
+ interface StoreReadyMethods {
89
+ onReady: (callback: () => void) => () => void;
90
+ isReady: () => boolean;
91
+ }
92
+ /**
93
+ * Hook to get unified connection status.
94
+ *
95
+ * @param store Optional store INSTANCE to include in readiness check.
96
+ * Pass the store directly (e.g., `appStore`), NOT via useStore() hook.
97
+ *
98
+ * @example
99
+ * ```tsx
100
+ * // Correct - pass store instance directly:
101
+ * import { appStore } from './stores/app';
102
+ * const { isLoading } = useConnectionStatus(appStore);
103
+ *
104
+ * // WRONG - don't use useStore() to get the store:
105
+ * const store = useStore(s => s); // This won't work!
106
+ * ```
107
+ */
108
+ declare const useConnectionStatus: (store?: StoreReadyMethods) => ConnectionStatusResult;
109
+
110
+ /**
111
+ * ServiceWorkerHealth - Single source of truth for service worker health status.
112
+ *
113
+ * This module provides a centralized health monitoring system that:
114
+ * 1. Monitors the service worker connection via the bridge
115
+ * 2. Broadcasts status changes to all subscribers (bridge, store, UI)
116
+ * 3. Provides a simple `isHealthy` boolean for UI components
117
+ *
118
+ * Usage:
119
+ * ```tsx
120
+ * // In your app root, wrap with ServiceWorkerHealthProvider:
121
+ * <BridgeProvider>
122
+ * <ServiceWorkerHealthProvider>
123
+ * <App />
124
+ * </ServiceWorkerHealthProvider>
125
+ * </BridgeProvider>
126
+ *
127
+ * // In any component, use the hook:
128
+ * const { isHealthy, isRecovering } = useServiceWorkerHealth();
129
+ *
130
+ * if (!isHealthy) {
131
+ * return <Spinner message="Reconnecting..." />;
132
+ * }
133
+ * ```
134
+ */
135
+
136
+ type HealthStatus = 'healthy' | 'unhealthy' | 'recovering' | 'unknown';
137
+ interface ServiceWorkerHealthContextValue {
138
+ /** Current health status */
139
+ status: HealthStatus;
140
+ /** Simple boolean: true when SW is connected and responsive */
141
+ isHealthy: boolean;
142
+ /** True when actively trying to reconnect */
143
+ isRecovering: boolean;
144
+ /** True during initial connection or recovery */
145
+ isLoading: boolean;
146
+ /** Timestamp of last successful ping (ms) */
147
+ lastHealthyAt: number | null;
148
+ /** Force a reconnection attempt */
149
+ forceReconnect: () => void;
150
+ }
151
+ interface ServiceWorkerHealthProviderProps {
152
+ children: ReactNode;
153
+ /**
154
+ * Optional callback when health status changes.
155
+ * Useful for stores to listen and react (e.g., pause operations).
156
+ */
157
+ onHealthChange?: (status: HealthStatus, isHealthy: boolean) => void;
158
+ }
159
+ type HealthSubscriber = (status: HealthStatus, isHealthy: boolean) => void;
160
+ /**
161
+ * Subscribe to health status changes from outside React.
162
+ * Returns an unsubscribe function.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * // In BridgeStore
167
+ * const unsubscribe = subscribeToHealth((status, isHealthy) => {
168
+ * if (!isHealthy) {
169
+ * // Pause operations, show loading state
170
+ * } else {
171
+ * // Resume operations
172
+ * }
173
+ * });
174
+ * ```
175
+ */
176
+ declare function subscribeToHealth(callback: HealthSubscriber): () => void;
177
+ /**
178
+ * Get current health status synchronously.
179
+ * Useful for non-React code that needs immediate access.
180
+ */
181
+ declare function getHealthStatus(): {
182
+ status: HealthStatus;
183
+ isHealthy: boolean;
184
+ };
185
+ declare const ServiceWorkerHealthProvider: FC<ServiceWorkerHealthProviderProps>;
186
+ /**
187
+ * Hook to access service worker health status.
188
+ *
189
+ * @returns Health status object with `isHealthy` boolean
190
+ * @throws Error if used outside ServiceWorkerHealthProvider
191
+ *
192
+ * @example
193
+ * ```tsx
194
+ * function App() {
195
+ * const { isHealthy, isRecovering } = useServiceWorkerHealth();
196
+ *
197
+ * if (!isHealthy) {
198
+ * return (
199
+ * <div className="loading-overlay">
200
+ * <Spinner />
201
+ * <p>{isRecovering ? 'Reconnecting...' : 'Connection lost'}</p>
202
+ * </div>
203
+ * );
204
+ * }
205
+ *
206
+ * return <MainApp />;
207
+ * }
208
+ * ```
209
+ */
210
+ declare function useServiceWorkerHealth(): ServiceWorkerHealthContextValue;
211
+ /**
212
+ * Lightweight hook that directly consumes BridgeContext without needing
213
+ * ServiceWorkerHealthProvider. Use this for simple cases where you don't
214
+ * need the global subscription API.
215
+ *
216
+ * @example
217
+ * ```tsx
218
+ * function App() {
219
+ * const { isHealthy } = useServiceWorkerHealthSimple();
220
+ * if (!isHealthy) return <Spinner />;
221
+ * return <MainApp />;
222
+ * }
223
+ * ```
224
+ */
225
+ declare function useServiceWorkerHealthSimple(): {
226
+ isHealthy: boolean;
227
+ isRecovering: boolean;
228
+ isLoading: boolean;
229
+ reconnect: () => void;
230
+ };
81
231
 
82
- export { BridgeProvider, useBridge, useBridgeQuery, useConnectionStatus };
232
+ export { BridgeProvider, ServiceWorkerHealthProvider, getHealthStatus, subscribeToHealth, useBridge, useBridgeQuery, useConnectionStatus, useServiceWorkerHealth, useServiceWorkerHealthSimple };
233
+ export type { HealthStatus, ServiceWorkerHealthContextValue };
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
- import { createContext, useState, useRef, useEffect, useCallback, useMemo, useContext } from 'react';
2
+ import { createContext, useState, useRef, useEffect, useCallback, useMemo, useContext, useSyncExternalStore } from 'react';
3
3
 
4
4
  const BRIDGE_ENABLE_LOGS = true;
5
5
  const CONFIG = {
6
6
  RETRY_AFTER: 1e3,
7
7
  MAX_RETRIES: 10,
8
- PING_INTERVAL: 5e3,
8
+ PING_INTERVAL: 2e3,
9
+ // Check every 2s for faster SW death detection
9
10
  MAX_RETRY_COOLDOWN: 3e4,
10
11
  DEFAULT_TIMEOUT: 1e4,
11
12
  MAX_RETRY_DELAY: 3e4,
@@ -46,6 +47,7 @@ function createBridgeInstance(deps) {
46
47
  messageIdRef,
47
48
  isConnectedRef,
48
49
  consecutiveTimeoutsRef,
50
+ reconnectionGracePeriodRef,
49
51
  defaultTimeout,
50
52
  onReconnectNeeded
51
53
  } = deps;
@@ -66,11 +68,15 @@ function createBridgeInstance(deps) {
66
68
  const timeout = setTimeout(() => {
67
69
  if (!pendingRequestsRef.current.has(id)) return;
68
70
  pendingRequestsRef.current.delete(id);
69
- consecutiveTimeoutsRef.current++;
71
+ if (!reconnectionGracePeriodRef.current) {
72
+ consecutiveTimeoutsRef.current++;
73
+ }
70
74
  {
71
- console.warn(`[Bridge] Request timed out: ${key} (${timeoutDuration}ms)`);
75
+ console.warn(
76
+ `[Bridge] Request timed out: ${key} (${timeoutDuration}ms)${reconnectionGracePeriodRef.current ? " [grace period]" : ""}`
77
+ );
72
78
  }
73
- if (consecutiveTimeoutsRef.current >= CONFIG.CONSECUTIVE_FAILURE_THRESHOLD) {
79
+ if (!reconnectionGracePeriodRef.current && consecutiveTimeoutsRef.current >= CONFIG.CONSECUTIVE_FAILURE_THRESHOLD) {
74
80
  {
75
81
  console.warn(
76
82
  `[Bridge] ${consecutiveTimeoutsRef.current} consecutive timeouts, reconnecting...`
@@ -170,7 +176,8 @@ function startHealthMonitor(deps) {
170
176
  consecutivePingFailuresRef,
171
177
  pingInterval,
172
178
  setIsServiceWorkerAlive,
173
- onReconnectNeeded
179
+ onReconnectNeeded,
180
+ rejectAllPendingRequests
174
181
  } = deps;
175
182
  clearIntervalSafe(pingIntervalRef);
176
183
  consecutivePingFailuresRef.current = 0;
@@ -197,9 +204,12 @@ function startHealthMonitor(deps) {
197
204
  }
198
205
  if (consecutivePingFailuresRef.current >= CONFIG.CONSECUTIVE_FAILURE_THRESHOLD) {
199
206
  {
200
- console.warn("[Bridge] Service worker unresponsive, reconnecting...");
207
+ console.warn(
208
+ "[Bridge] Service worker unresponsive, rejecting pending requests and reconnecting..."
209
+ );
201
210
  }
202
211
  consecutivePingFailuresRef.current = 0;
212
+ rejectAllPendingRequests("Service worker unresponsive");
203
213
  onReconnectNeeded();
204
214
  }
205
215
  }, pingInterval);
@@ -230,6 +240,7 @@ const BridgeProvider = ({
230
240
  const errorCheckIntervalRef = useRef(null);
231
241
  const consecutivePingFailuresRef = useRef(0);
232
242
  const consecutiveTimeoutsRef = useRef(0);
243
+ const reconnectionGracePeriodRef = useRef(false);
233
244
  const pendingRequestsRef = useRef(/* @__PURE__ */ new Map());
234
245
  const eventListenersRef = useRef(/* @__PURE__ */ new Map());
235
246
  const messageIdRef = useRef(0);
@@ -263,7 +274,18 @@ const BridgeProvider = ({
263
274
  },
264
275
  [onError, updateStatus]
265
276
  );
266
- const cleanup = useCallback(() => {
277
+ const cleanup = useCallback((emitDisconnect = true) => {
278
+ if (emitDisconnect) {
279
+ eventListenersRef.current.get("bridge:disconnected")?.forEach((handler) => {
280
+ try {
281
+ handler(void 0);
282
+ } catch (err) {
283
+ {
284
+ console.warn("[Bridge] bridge:disconnected handler error:", err);
285
+ }
286
+ }
287
+ });
288
+ }
267
289
  clearTimeoutSafe(reconnectTimeoutRef);
268
290
  clearTimeoutSafe(triggerReconnectTimeoutRef);
269
291
  clearIntervalSafe(errorCheckIntervalRef);
@@ -280,7 +302,6 @@ const BridgeProvider = ({
280
302
  reject(new Error("Bridge disconnected"));
281
303
  });
282
304
  pendingRequestsRef.current.clear();
283
- eventListenersRef.current.clear();
284
305
  setIsServiceWorkerAlive(false);
285
306
  setBridge(null);
286
307
  isConnectingRef.current = false;
@@ -471,6 +492,7 @@ const BridgeProvider = ({
471
492
  messageIdRef,
472
493
  isConnectedRef,
473
494
  consecutiveTimeoutsRef,
495
+ reconnectionGracePeriodRef,
474
496
  defaultTimeout,
475
497
  onReconnectNeeded: triggerReconnect
476
498
  });
@@ -483,6 +505,13 @@ const BridgeProvider = ({
483
505
  swRestartRetryCountRef.current = 0;
484
506
  consecutiveTimeoutsRef.current = 0;
485
507
  isConnectingRef.current = false;
508
+ reconnectionGracePeriodRef.current = true;
509
+ setTimeout(() => {
510
+ reconnectionGracePeriodRef.current = false;
511
+ if (BRIDGE_ENABLE_LOGS) {
512
+ console.log("[Bridge] Grace period ended, timeout monitoring active");
513
+ }
514
+ }, 3e3);
486
515
  eventListenersRef.current.get("bridge:connected")?.forEach((handler) => {
487
516
  try {
488
517
  handler({ timestamp: Date.now() });
@@ -492,13 +521,22 @@ const BridgeProvider = ({
492
521
  }
493
522
  }
494
523
  });
524
+ const rejectAllPendingRequests = (message) => {
525
+ pendingRequestsRef.current.forEach(({ reject, timeout }) => {
526
+ clearTimeout(timeout);
527
+ reject(new Error(message));
528
+ });
529
+ pendingRequestsRef.current.clear();
530
+ consecutiveTimeoutsRef.current = 0;
531
+ };
495
532
  startHealthMonitor({
496
533
  bridge: bridgeInstance,
497
534
  pingIntervalRef,
498
535
  consecutivePingFailuresRef,
499
536
  pingInterval,
500
537
  setIsServiceWorkerAlive,
501
- onReconnectNeeded: triggerReconnect
538
+ onReconnectNeeded: triggerReconnect,
539
+ rejectAllPendingRequests
502
540
  });
503
541
  } catch (e) {
504
542
  isConnectingRef.current = false;
@@ -557,7 +595,7 @@ const BridgeProvider = ({
557
595
  return () => {
558
596
  document.removeEventListener("visibilitychange", handleVisibilityChange);
559
597
  clearTimeoutSafe(maxRetryCooldownRef);
560
- cleanup();
598
+ cleanup(false);
561
599
  };
562
600
  }, [connect, cleanup]);
563
601
  const contextValue = useMemo(
@@ -701,14 +739,26 @@ function useBridgeMutation(key, options = {}) {
701
739
  return { mutate, data, loading, error, reset };
702
740
  }
703
741
 
704
- const useConnectionStatus = () => {
742
+ const useConnectionStatus = (store) => {
705
743
  const context = useContext(BridgeContext);
744
+ const storeReady = useSyncExternalStore(
745
+ store?.onReady ?? (() => () => {
746
+ }),
747
+ store?.isReady ?? (() => true),
748
+ () => false
749
+ // Server-side fallback
750
+ );
751
+ const bridgeConnected = context?.status === "connected";
752
+ const isReady = bridgeConnected && storeReady;
753
+ const isLoading = !isReady && (context?.status === "connecting" || context?.status === "reconnecting" || bridgeConnected && !storeReady);
706
754
  return {
707
755
  status: context?.status,
708
- isConnected: context?.status === "connected",
756
+ isConnected: bridgeConnected,
709
757
  isServiceWorkerAlive: context?.isServiceWorkerAlive ?? false,
710
758
  reconnect: context?.reconnect,
711
- error: context?.error
759
+ error: context?.error,
760
+ isReady,
761
+ isLoading
712
762
  };
713
763
  };
714
764
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromahq/react",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "React bindings for the Chroma Chrome extension framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",