@chromahq/react 1.0.24 → 1.0.26

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
@@ -81,14 +81,153 @@ interface ConnectionStatusResult {
81
81
  /** Whether the system is loading (bridge connecting/reconnecting OR store not ready) */
82
82
  isLoading: boolean;
83
83
  }
84
+ /**
85
+ * Store interface for readiness checking.
86
+ * Pass the store instance directly (e.g., `appStore`), NOT via useStore() hook.
87
+ */
84
88
  interface StoreReadyMethods {
85
89
  onReady: (callback: () => void) => () => void;
86
90
  isReady: () => boolean;
87
91
  }
88
92
  /**
89
- * Hook to get unified connection status
90
- * @param store Optional store to include in readiness check
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
+ * ```
91
107
  */
92
108
  declare const useConnectionStatus: (store?: StoreReadyMethods) => ConnectionStatusResult;
93
109
 
94
- export { BridgeProvider, useBridge, useBridgeQuery, useConnectionStatus };
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
+ };
231
+
232
+ export { BridgeProvider, ServiceWorkerHealthProvider, getHealthStatus, subscribeToHealth, useBridge, useBridgeQuery, useConnectionStatus, useServiceWorkerHealth, useServiceWorkerHealthSimple };
233
+ export type { HealthStatus, ServiceWorkerHealthContextValue };
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ 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,
@@ -175,7 +176,8 @@ function startHealthMonitor(deps) {
175
176
  consecutivePingFailuresRef,
176
177
  pingInterval,
177
178
  setIsServiceWorkerAlive,
178
- onReconnectNeeded
179
+ onReconnectNeeded,
180
+ rejectAllPendingRequests
179
181
  } = deps;
180
182
  clearIntervalSafe(pingIntervalRef);
181
183
  consecutivePingFailuresRef.current = 0;
@@ -202,9 +204,12 @@ function startHealthMonitor(deps) {
202
204
  }
203
205
  if (consecutivePingFailuresRef.current >= CONFIG.CONSECUTIVE_FAILURE_THRESHOLD) {
204
206
  {
205
- console.warn("[Bridge] Service worker unresponsive, reconnecting...");
207
+ console.warn(
208
+ "[Bridge] Service worker unresponsive, rejecting pending requests and reconnecting..."
209
+ );
206
210
  }
207
211
  consecutivePingFailuresRef.current = 0;
212
+ rejectAllPendingRequests("Service worker unresponsive");
208
213
  onReconnectNeeded();
209
214
  }
210
215
  }, pingInterval);
@@ -269,7 +274,18 @@ const BridgeProvider = ({
269
274
  },
270
275
  [onError, updateStatus]
271
276
  );
272
- 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
+ }
273
289
  clearTimeoutSafe(reconnectTimeoutRef);
274
290
  clearTimeoutSafe(triggerReconnectTimeoutRef);
275
291
  clearIntervalSafe(errorCheckIntervalRef);
@@ -286,7 +302,6 @@ const BridgeProvider = ({
286
302
  reject(new Error("Bridge disconnected"));
287
303
  });
288
304
  pendingRequestsRef.current.clear();
289
- eventListenersRef.current.clear();
290
305
  setIsServiceWorkerAlive(false);
291
306
  setBridge(null);
292
307
  isConnectingRef.current = false;
@@ -506,13 +521,22 @@ const BridgeProvider = ({
506
521
  }
507
522
  }
508
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
+ };
509
532
  startHealthMonitor({
510
533
  bridge: bridgeInstance,
511
534
  pingIntervalRef,
512
535
  consecutivePingFailuresRef,
513
536
  pingInterval,
514
537
  setIsServiceWorkerAlive,
515
- onReconnectNeeded: triggerReconnect
538
+ onReconnectNeeded: triggerReconnect,
539
+ rejectAllPendingRequests
516
540
  });
517
541
  } catch (e) {
518
542
  isConnectingRef.current = false;
@@ -571,7 +595,7 @@ const BridgeProvider = ({
571
595
  return () => {
572
596
  document.removeEventListener("visibilitychange", handleVisibilityChange);
573
597
  clearTimeoutSafe(maxRetryCooldownRef);
574
- cleanup();
598
+ cleanup(false);
575
599
  };
576
600
  }, [connect, cleanup]);
577
601
  const contextValue = useMemo(
@@ -738,4 +762,94 @@ const useConnectionStatus = (store) => {
738
762
  };
739
763
  };
740
764
 
741
- export { BridgeProvider, useBridge, useBridgeMutation, useBridgeQuery, useConnectionStatus };
765
+ const globalSubscribers = /* @__PURE__ */ new Set();
766
+ function subscribeToHealth(callback) {
767
+ globalSubscribers.add(callback);
768
+ return () => {
769
+ globalSubscribers.delete(callback);
770
+ };
771
+ }
772
+ let currentHealthStatus = "unknown";
773
+ let currentIsHealthy = false;
774
+ function getHealthStatus() {
775
+ return { status: currentHealthStatus, isHealthy: currentIsHealthy };
776
+ }
777
+ function broadcastHealthChange(status, isHealthy) {
778
+ currentHealthStatus = status;
779
+ currentIsHealthy = isHealthy;
780
+ globalSubscribers.forEach((callback) => {
781
+ try {
782
+ callback(status, isHealthy);
783
+ } catch (e) {
784
+ console.error("[ServiceWorkerHealth] Subscriber error:", e);
785
+ }
786
+ });
787
+ }
788
+ const ServiceWorkerHealthContext = createContext(null);
789
+ const ServiceWorkerHealthProvider = ({
790
+ children,
791
+ onHealthChange
792
+ }) => {
793
+ const bridgeContext = useContext(BridgeContext);
794
+ const [lastHealthyAt, setLastHealthyAt] = useState(null);
795
+ const bridgeStatus = bridgeContext?.status;
796
+ const isServiceWorkerAlive = bridgeContext?.isServiceWorkerAlive ?? false;
797
+ const derivedStatus = useMemo(() => {
798
+ if (!bridgeStatus) return "unknown";
799
+ if (bridgeStatus === "connected" && isServiceWorkerAlive) {
800
+ return "healthy";
801
+ }
802
+ if (bridgeStatus === "reconnecting" || bridgeStatus === "connecting") {
803
+ return "recovering";
804
+ }
805
+ return "unhealthy";
806
+ }, [bridgeStatus, isServiceWorkerAlive]);
807
+ const isHealthy = derivedStatus === "healthy";
808
+ const isRecovering = derivedStatus === "recovering";
809
+ const isLoading = derivedStatus === "recovering" || derivedStatus === "unknown";
810
+ useEffect(() => {
811
+ if (isHealthy) {
812
+ setLastHealthyAt(Date.now());
813
+ }
814
+ }, [isHealthy]);
815
+ useEffect(() => {
816
+ broadcastHealthChange(derivedStatus, isHealthy);
817
+ onHealthChange?.(derivedStatus, isHealthy);
818
+ }, [derivedStatus, isHealthy, onHealthChange]);
819
+ const forceReconnect = useCallback(() => {
820
+ bridgeContext?.reconnect();
821
+ }, [bridgeContext]);
822
+ const value = useMemo(
823
+ () => ({
824
+ status: derivedStatus,
825
+ isHealthy,
826
+ isRecovering,
827
+ isLoading,
828
+ lastHealthyAt,
829
+ forceReconnect
830
+ }),
831
+ [derivedStatus, isHealthy, isRecovering, isLoading, lastHealthyAt, forceReconnect]
832
+ );
833
+ return /* @__PURE__ */ jsx(ServiceWorkerHealthContext.Provider, { value, children });
834
+ };
835
+ function useServiceWorkerHealth() {
836
+ const context = useContext(ServiceWorkerHealthContext);
837
+ if (!context) {
838
+ throw new Error(
839
+ "useServiceWorkerHealth must be used within a ServiceWorkerHealthProvider. Wrap your app with <ServiceWorkerHealthProvider> inside <BridgeProvider>."
840
+ );
841
+ }
842
+ return context;
843
+ }
844
+ function useServiceWorkerHealthSimple() {
845
+ const bridgeContext = useContext(BridgeContext);
846
+ const isHealthy = bridgeContext?.status === "connected" && bridgeContext?.isServiceWorkerAlive === true;
847
+ const isRecovering = bridgeContext?.status === "reconnecting" || bridgeContext?.status === "connecting";
848
+ const isLoading = !isHealthy && (isRecovering || !bridgeContext);
849
+ const reconnect = useCallback(() => {
850
+ bridgeContext?.reconnect();
851
+ }, [bridgeContext]);
852
+ return { isHealthy, isRecovering, isLoading, reconnect };
853
+ }
854
+
855
+ export { BridgeProvider, ServiceWorkerHealthProvider, getHealthStatus, subscribeToHealth, useBridge, useBridgeMutation, useBridgeQuery, useConnectionStatus, useServiceWorkerHealth, useServiceWorkerHealthSimple };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromahq/react",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "React bindings for the Chroma Chrome extension framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",