UNPKG

@chromahq/store

Version:

Centralized, persistent store for Chrome extensions using zustand, accessible from service workers and React, with chrome.storage.local persistence.

814 lines (805 loc) 26.1 kB
import * as React from 'react'; import { useSyncExternalStore, createContext, useMemo, useContext, useRef } from 'react'; import { createStore as createStore$1 } from 'zustand/vanilla'; function chromeStoragePersist(options = {}) { return (config) => (set, get, store) => { const key = options.name; let isInitialized = false; let persistenceSetup = false; const initialState = config(set, get, store); const loadPersistedState = async () => { try { if (!chrome?.storage?.local) { isInitialized = true; setupPersistence(); return; } const result = await new Promise((resolve, reject) => { chrome.storage.local.get([key], (result2) => { if (chrome.runtime.lastError) { reject(chrome.runtime.lastError); } else { resolve(result2); } }); }); if (result[key]) { const mergedState = { ...initialState, ...result[key] }; set(mergedState); } else { await persistState(initialState); } } catch (error) { console.error(`Failed to load persisted state for "${key}":`, error); } finally { isInitialized = true; setupPersistence(); if (options.onReady) { options.onReady(); } } }; let persistDebounceTimer = null; const PERSIST_DEBOUNCE_MS = 500; const persistState = async (state) => { if (!chrome?.storage?.local) { return; } return new Promise((resolve) => { chrome.storage.local.set({ [key]: state }, () => { if (chrome.runtime.lastError) { console.error(`Failed to persist state for "${key}":`, chrome.runtime.lastError); } resolve(); }); }); }; const setupPersistence = () => { if (persistenceSetup) return; persistenceSetup = true; store.subscribe((state) => { if (persistDebounceTimer) { clearTimeout(persistDebounceTimer); } persistDebounceTimer = setTimeout(() => { persistDebounceTimer = null; persistState(state); }, PERSIST_DEBOUNCE_MS); }); }; loadPersistedState(); return initialState; }; } function useCentralStore(store, selector) { return useSyncExternalStore( store.subscribe, () => selector(store.getState()), () => selector(store.getState()) ); } function useCentralDispatch(store) { return store.setState; } function useStoreReady(store) { return useSyncExternalStore( store.onReady, store.isReady, () => false // Server-side fallback ); } function useStoreReset(store) { return store.reset; } class BridgeStore { // Consider state stale after 30s hidden constructor(bridge, initialState, storeName = "default", readyCallbacks = /* @__PURE__ */ new Set()) { this.listeners = /* @__PURE__ */ new Set(); this.currentState = null; this.previousState = null; this.initialState = null; this.ready = false; this.readyCallbacks = /* @__PURE__ */ new Set(); this.initializationAttempts = 0; this.maxInitializationAttempts = 10; this.initializationTimer = null; this.isInitializing = false; // Store handler references for cleanup (prevents memory leaks) this.reconnectHandler = null; this.disconnectHandler = null; this.stateChangedHandler = null; // Debounce timer for state sync (optimization for rapid updates) this.stateSyncDebounceTimer = null; this.stateSyncDebounceMs = 50; // Reduced to 50ms for faster reactivity // Reconnect delay timer (to allow SW to bootstrap before re-initializing) this.reconnectDelayTimer = null; // Visibility change handling - refresh state when tab becomes visible this.visibilityHandler = null; this.lastVisibleAt = Date.now(); this.staleThresholdMs = 3e4; this.initialize = async () => { if (this.isInitializing) { return; } if (this.initializationTimer) { clearTimeout(this.initializationTimer); this.initializationTimer = null; } this.initializationAttempts++; this.isInitializing = true; try { if (this.initializationAttempts > this.maxInitializationAttempts) { console.error( `BridgeStore[${this.storeName}]: Max initialization attempts (${this.maxInitializationAttempts}) reached, giving up` ); this.isInitializing = false; return; } if (!this.bridge.isConnected) { const delay = Math.min(500 * Math.pow(2, this.initializationAttempts - 1), 5e3); this.isInitializing = false; this.initializationTimer = setTimeout(() => this.initialize(), delay); return; } const state = await this.bridge.send(`store:${this.storeName}:getState`); this.previousState = this.currentState; this.currentState = state; if (this.initialState === null) { this.initialState = state; } this.notifyListeners(); this.ready = true; this.isInitializing = false; this.notifyReady(); } catch (error) { this.isInitializing = false; console.error( `BridgeStore[${this.storeName}]: Failed to initialize (attempt ${this.initializationAttempts}):`, error ); if (this.initializationAttempts < this.maxInitializationAttempts) { const delay = Math.min(1e3 * Math.pow(2, this.initializationAttempts - 1), 1e4); this.initializationTimer = setTimeout(() => this.initialize(), delay); } else { console.error(`BridgeStore[${this.storeName}]: Max attempts reached, cannot retry`); } } }; this.stateSyncSequence = 0; this.pendingStateSync = false; this.notifyListeners = () => { if (!this.listeners) { return; } if (this.currentState && this.previousState) { this.listeners.forEach((listener) => listener(this.currentState, this.previousState)); } }; this.getState = () => { return this.currentState; }; this.subscribe = (listener) => { if (!this.listeners) { console.error("BridgeStore: Cannot subscribe, listeners not initialized"); return () => { }; } this.listeners.add(listener); if (this.currentState && this.previousState) { listener(this.currentState, this.previousState); } return () => { if (this.listeners) { this.listeners.delete(listener); } }; }; // Additional StoreApi methods this.destroy = () => { if (this.initializationTimer) { clearTimeout(this.initializationTimer); this.initializationTimer = null; } if (this.stateSyncDebounceTimer) { clearTimeout(this.stateSyncDebounceTimer); this.stateSyncDebounceTimer = null; } if (this.reconnectDelayTimer) { clearTimeout(this.reconnectDelayTimer); this.reconnectDelayTimer = null; } if (this.bridge.off) { if (this.reconnectHandler) { this.bridge.off("bridge:connected", this.reconnectHandler); this.reconnectHandler = null; } if (this.disconnectHandler) { this.bridge.off("bridge:disconnected", this.disconnectHandler); this.disconnectHandler = null; } if (this.stateChangedHandler) { this.bridge.off(`store:${this.storeName}:stateChanged`, this.stateChangedHandler); this.stateChangedHandler = null; } } if (this.visibilityHandler && typeof document !== "undefined") { document.removeEventListener("visibilitychange", this.visibilityHandler); this.visibilityHandler = null; } if (this.listeners) { this.listeners.clear(); } this.readyCallbacks.clear(); }; this.getInitialState = () => { return this.getState(); }; this.isReady = () => { return this.ready; }; this.onReady = (callback) => { if (this.ready) { callback(); } else { this.readyCallbacks.add(callback); } return () => { this.readyCallbacks.delete(callback); }; }; this.reset = () => { if (this.initialState !== null) { if (!this.bridge.isConnected) { this.previousState = this.currentState; this.currentState = { ...this.initialState }; this.notifyListeners(); return; } const stateBeforeReset = this.currentState ? { ...this.currentState } : null; this.previousState = this.currentState; this.currentState = { ...this.initialState }; this.notifyListeners(); this.bridge.send(`store:${this.storeName}:reset`).catch((error) => { console.error(`BridgeStore[${this.storeName}]: Failed to reset state via bridge:`, error); if (stateBeforeReset !== null) { this.previousState = this.currentState; this.currentState = stateBeforeReset; this.notifyListeners(); } }); } }; this.notifyReady = () => { this.readyCallbacks.forEach((callback) => callback()); this.readyCallbacks.clear(); }; /** * Force re-initialization of the store (useful for debugging or after reconnection) */ this.forceInitialize = async () => { if (this.initializationTimer) { clearTimeout(this.initializationTimer); this.initializationTimer = null; } this.ready = false; this.isInitializing = false; this.initializationAttempts = 0; await this.initialize(); }; /** * Get debug information about the store state */ this.getDebugInfo = () => { return { storeName: this.storeName, ready: this.ready, isInitializing: this.isInitializing, bridgeConnected: this.bridge.isConnected, hasCurrentState: this.currentState !== null, hasInitialState: this.initialState !== null, readyCallbacksCount: this.readyCallbacks.size, initializationAttempts: this.initializationAttempts, maxInitializationAttempts: this.maxInitializationAttempts }; }; /** * Update the bridge reference and re-register all event listeners. * Called when createBridgeStore receives a new bridge object (e.g., after React remount). * This is critical for React StrictMode which causes double-mounting. */ this.updateBridge = (newBridge) => { if (this.bridge === newBridge) { return; } this.bridge = newBridge; this.reregisterEventListeners(); }; this.bridge = bridge; this.currentState = initialState || null; this.previousState = initialState || null; this.initialState = initialState || null; this.storeName = storeName; this.readyCallbacks = readyCallbacks; this.setupStateSync(); this.setupReconnectListener(); this.setupVisibilityListener(); this.initialize(); } setupReconnectListener() { if (this.bridge.on) { this.disconnectHandler = () => { this.ready = false; if (this.stateSyncDebounceTimer) { clearTimeout(this.stateSyncDebounceTimer); this.stateSyncDebounceTimer = null; } this.pendingStateSync = false; this.isInitializing = false; if (this.initializationTimer) { clearTimeout(this.initializationTimer); this.initializationTimer = null; } }; this.bridge.on("bridge:disconnected", this.disconnectHandler); this.reconnectHandler = () => { if (this.reconnectDelayTimer) { clearTimeout(this.reconnectDelayTimer); this.reconnectDelayTimer = null; } this.reregisterEventListeners(); this.forceInitialize(); }; this.bridge.on("bridge:connected", this.reconnectHandler); } } /** * Re-register all event listeners on the bridge * Called after reconnection because React StrictMode may have created a new eventListenersRef * IMPORTANT: Remove existing listeners first to prevent duplicate handlers */ reregisterEventListeners() { if (!this.bridge.on) return; const eventKey = `store:${this.storeName}:stateChanged`; if (this.stateChangedHandler) { if (this.bridge.off) { this.bridge.off(eventKey, this.stateChangedHandler); } this.bridge.on(eventKey, this.stateChangedHandler); } if (this.disconnectHandler) { if (this.bridge.off) { this.bridge.off("bridge:disconnected", this.disconnectHandler); } this.bridge.on("bridge:disconnected", this.disconnectHandler); } if (this.reconnectHandler) { if (this.bridge.off) { this.bridge.off("bridge:connected", this.reconnectHandler); } this.bridge.on("bridge:connected", this.reconnectHandler); } } setupVisibilityListener() { if (typeof document === "undefined") return; this.visibilityHandler = () => { if (document.visibilityState === "visible") { const hiddenDuration = Date.now() - this.lastVisibleAt; if (hiddenDuration > this.staleThresholdMs && this.ready && this.bridge.isConnected) { this.fetchAndApplyState(); } this.lastVisibleAt = Date.now(); } }; document.addEventListener("visibilitychange", this.visibilityHandler); } /** * Apply state directly from broadcast payload (no round-trip) */ applyBroadcastState(newState) { if (!newState || typeof newState !== "object") { return; } this.previousState = this.currentState; this.currentState = newState; this.notifyListeners(); } /** * Fetch state from SW (fallback when broadcast doesn't include payload) */ fetchAndApplyState() { if (this.pendingStateSync) { return; } this.pendingStateSync = true; const currentSequence = ++this.stateSyncSequence; this.bridge.send(`store:${this.storeName}:getState`).then((newState) => { if (currentSequence === this.stateSyncSequence) { this.previousState = this.currentState; this.currentState = newState; this.notifyListeners(); } }).catch((error) => { console.error(`BridgeStore[${this.storeName}]: Failed to sync state:`, error); }).finally(() => { this.pendingStateSync = false; }); } setupStateSync() { if (this.bridge.on) { this.stateChangedHandler = (payload) => { if (this.stateSyncDebounceTimer) { clearTimeout(this.stateSyncDebounceTimer); } this.stateSyncDebounceTimer = setTimeout(() => { this.stateSyncDebounceTimer = null; if (payload && typeof payload === "object") { this.applyBroadcastState(payload); } else { this.fetchAndApplyState(); } }, this.stateSyncDebounceMs); }; const eventKey = `store:${this.storeName}:stateChanged`; this.bridge.on(eventKey, this.stateChangedHandler); } } setState(partial, replace) { let actualUpdate; if (typeof partial === "function") { if (this.currentState === null) { return; } actualUpdate = partial(this.currentState); } else { actualUpdate = partial; } const stateBeforeUpdate = this.currentState ? { ...this.currentState } : null; this.applyOptimisticUpdate(actualUpdate, replace); const payload = { partial: actualUpdate, replace }; this.bridge.send(`store:${this.storeName}:setState`, payload).catch((error) => { console.error(`BridgeStore[${this.storeName}]: Failed to update state via bridge:`, error); if (stateBeforeUpdate !== null) { this.previousState = this.currentState; this.currentState = stateBeforeUpdate; this.notifyListeners(); } }); } applyOptimisticUpdate(actualUpdate, replace) { if (this.currentState) { this.previousState = this.currentState; if (replace) { this.currentState = actualUpdate; } else { this.currentState = { ...this.currentState, ...actualUpdate }; } this.notifyListeners(); } } } const storeCache = /* @__PURE__ */ new Map(); function createBridgeStore(bridge, initialState, storeName = "default", readyCallbacks = /* @__PURE__ */ new Set()) { if (storeCache.has(storeName)) { const cached = storeCache.get(storeName); cached.updateBridge(bridge); readyCallbacks.forEach((cb) => cached.onReady(cb)); return cached; } const store = new BridgeStore(bridge, initialState, storeName, readyCallbacks); storeCache.set(storeName, store); return store; } function clearStoreCache() { storeCache.clear(); } function destroyStore(storeName) { const store = storeCache.get(storeName); if (store) { store.destroy(); storeCache.delete(storeName); } } function createActionHookForStore(store, actionsFactory) { return function useCustomActions() { const baseActions = useStoreActions(store); return useMemo(() => actionsFactory(baseActions), [baseActions]); }; } function useStoreActions(store) { return useMemo( () => ({ update: (partial) => { store.setState((state) => ({ ...state, ...partial })); }, updateWith: (updater) => { store.setState((state) => updater(state)); }, replace: (newState) => { store.setState(newState, true); }, setState: store.setState.bind(store) }), [store] ); } function createStoreHooks() { const StoreContext = createContext(null); function StoreProvider({ store, children }) { const storeRef = useRef(store); return React.createElement(StoreContext.Provider, { value: storeRef.current }, children); } function useStore(selector) { const store = useContext(StoreContext); if (!store) throw new Error("useStore must be used within a StoreProvider"); return useCentralStore(store, selector); } function useStoreInstance() { const store = useContext(StoreContext); if (!store) throw new Error("useStoreInstance must be used within a StoreProvider"); return store; } function useActions() { const store = useStoreInstance(); return useStoreActions(store); } function useAction(actionKey) { const store = useStoreInstance(); return React.useCallback( (...args) => { const fn = store.getState()[actionKey]; if (typeof fn !== "function") { throw new Error("useAction only supports function actions"); } return fn(...args); }, [store, actionKey] ); } function createActionHook(actionsFactory) { return function useCustomActions() { const store = useStoreInstance(); const baseActions = useStoreActions(store); return useMemo(() => actionsFactory(baseActions), [baseActions]); }; } return { createActionHook, StoreProvider, useStore, useStoreInstance, useActions, useAction }; } const readyCallbacks = /* @__PURE__ */ new Set(); class StoreBuilder { constructor(name = "default") { this.onReadyCallbacks = /* @__PURE__ */ new Set(); this.config = { name, slices: [] }; } /** * Add state slices to the store */ withSlices(...slices) { this.config.slices = [...this.config.slices, ...slices]; return this; } onReady(callback) { this.onReadyCallbacks.add(callback); return this; } /** * Attach a bridge for cross-context communication */ withBridge(bridge) { this.config.bridge = bridge; return this; } /** * Enable persistence with Chrome storage */ withPersistence(options) { this.config.persistence = options; return this; } /** * Create the store */ async create() { if (this.config.slices.length === 0) { throw new Error("Store must have at least one slice. Use withSlices() to add state."); } return await this.createBaseStore(); } async createBaseStore() { const bridge = this.config.bridge; if (bridge) { return createBridgeStore(bridge, void 0, this.config.name, this.onReadyCallbacks); } return this.createServiceWorkerStore(); } createServiceWorkerStore() { let isReady = false; let initialState = null; let runtimeBridge; const notifyReady = () => { isReady = true; readyCallbacks.forEach((callback) => callback()); this.onReadyCallbacks.forEach((callback) => callback()); readyCallbacks.clear(); this.onReadyCallbacks.clear(); }; const creator = (set, get, store2) => { let state = {}; for (const slice of this.config.slices) { const sliceState = slice(set, get, store2); state = { ...state, ...sliceState }; } if (initialState === null) { initialState = { ...state }; } return state; }; const persistOptions = { name: this.config.name, onReady: notifyReady }; const persistedCreator = chromeStoragePersist(persistOptions)(creator); const store = createStore$1(persistedCreator); let broadcastDebounceTimer = null; const BROADCAST_DEBOUNCE_MS = 50; store.subscribe(() => { if (runtimeBridge) { if (broadcastDebounceTimer) { clearTimeout(broadcastDebounceTimer); } broadcastDebounceTimer = setTimeout(() => { broadcastDebounceTimer = null; runtimeBridge.broadcast(`store:${this.config.name}:stateChanged`, store.getState()); }, BROADCAST_DEBOUNCE_MS); } }); const centralStore = Object.assign(store, { isReady: () => isReady, reset: () => { if (initialState !== null) { store.setState(initialState, true); } }, setBridge: (bridge) => { runtimeBridge = bridge; }, onReady: (callback) => { if (isReady) { callback(); } else { readyCallbacks.add(callback); } return () => { readyCallbacks.delete(callback); }; } }); return centralStore; } } function createStore(name) { return new StoreBuilder(name); } function createServiceWorkerStore(slices, name = "default", persistOptions) { const sliceArray = Array.isArray(slices) ? slices : [slices]; let builder = createStore(name).withSlices(...sliceArray); if (persistOptions) { builder = builder.withPersistence(persistOptions); } return builder.create(); } function createUIStore(bridge, initialState, name = "default") { return createBridgeStore(bridge, initialState, name); } function autoRegisterStoreHandlers(store, storeName = "default") { if (!store) { throw new Error("autoRegisterStoreHandlers: store parameter is required"); } if (typeof store.getState !== "function") { throw new Error("autoRegisterStoreHandlers: store must have getState method"); } if (typeof store.setState !== "function") { throw new Error("autoRegisterStoreHandlers: store must have setState method"); } class AutoGetStoreStateMessage { handle() { if (!store) { console.error(`[Store] Store instance not available for "${storeName}"`); throw new Error("Store instance not available"); } try { return store.getState(); } catch (error) { console.error(`[Store] GetState failed for "${storeName}":`, error); throw error; } } } class AutoSetStoreStateMessage { handle(...args) { if (!store) { throw new Error("Store instance not available"); } let partial; let replace = false; if (args.length === 1 && args[0] && typeof args[0] === "object") { const payload = args[0]; if ("partial" in payload) { ({ partial, replace = false } = payload); } else { partial = payload; replace = false; } } else if (args.length >= 2) { partial = args[0]; replace = args[1] || false; } else if (args.length === 1) { partial = args[0]; replace = false; } else { return store.getState(); } if (partial === void 0) { return store.getState(); } if (replace) { store.setState(partial, true); } else { store.setState(partial); } const updatedState = store.getState(); return updatedState; } } class AutoResetStoreMessage { handle() { if (!store) { console.error(`[Store] Store instance not available for "${storeName}"`); throw new Error("Store instance not available"); } try { if (typeof store.reset === "function") { store.reset(); } return store.getState(); } catch (error) { console.error(`[Store] Reset failed for "${storeName}":`, error); throw error; } } } return { GetStoreStateMessage: AutoGetStoreStateMessage, SetStoreStateMessage: AutoSetStoreStateMessage, ResetStoreMessage: AutoResetStoreMessage }; } async function init(storeDefinition) { try { let builder = createStore(storeDefinition.name); if (storeDefinition.slices) { builder = builder.withSlices(...storeDefinition.slices); } const store = await builder.create(); return { def: storeDefinition, store, classes: autoRegisterStoreHandlers(store, storeDefinition.name) }; } catch (error) { console.error(`Failed to initialize store "${storeDefinition.name}":`, error); throw error; } } export { BridgeStore, StoreBuilder, chromeStoragePersist, clearStoreCache, createActionHookForStore, createBridgeStore, createServiceWorkerStore, createStore, createStoreHooks, createUIStore, destroyStore, init, useCentralDispatch, useCentralStore, useStoreReady, useStoreReset };