From d0ac364efdf6537afeb996e12efcdc3ec19e0a8e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 22:18:38 -0800 Subject: [PATCH 1/3] refactor: replace cross-tab chrome.storage sync with sinking (IndexedDB + SharedWorker) Core: Add `persistToolbarState` option (default true) to guard all localStorage calls. When false, localStorage is skipped and in-memory state is used as fallback. This allows external consumers (e.g. web extension) to take over persistence. Web extension: Replace chrome.storage-based toolbar state sync with sinking library for cross-tab IndexedDB sync via SharedWorker. Chrome.storage is kept only for the extension icon enable/disable toggle. On startup, the extension disables core's localStorage persistence and uses sinking for all toolbar state read/write/subscribe operations. Co-authored-by: Cursor --- .../react-grab/src/components/renderer.tsx | 1 + .../src/components/toolbar/index.tsx | 5 +- .../src/components/toolbar/state.ts | 11 +- packages/react-grab/src/core/index.tsx | 24 +++- .../react-grab/src/core/plugin-registry.ts | 2 + packages/react-grab/src/types.ts | 7 + packages/web-extension/package.json | 1 + packages/web-extension/src/content/bridge.ts | 40 ++---- .../web-extension/src/content/react-grab.ts | 123 ++++++++++++------ packages/web-extension/src/manifest.json | 6 + packages/web-extension/src/storage/client.ts | 82 ++++++++++++ packages/web-extension/src/worker.ts | 1 + pnpm-lock.yaml | 20 ++- 13 files changed, 245 insertions(+), 78 deletions(-) create mode 100644 packages/web-extension/src/storage/client.ts create mode 100644 packages/web-extension/src/worker.ts diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index badf3a8b6..6193e28cd 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -192,6 +192,7 @@ export const ReactGrabRenderer: Component = (props) => { onStateChange={props.onToolbarStateChange} onSubscribeToStateChanges={props.onSubscribeToToolbarStateChanges} onSelectHoverChange={props.onToolbarSelectHoverChange} + persistToolbarState={props.persistToolbarState} /> diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 984871ac4..b408de59a 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -57,6 +57,7 @@ interface ToolbarProps { callback: (state: ToolbarState) => void, ) => () => void; onSelectHoverChange?: (isHovered: boolean) => void; + persistToolbarState?: boolean; } export const Toolbar: Component = (props) => { @@ -757,12 +758,12 @@ export const Toolbar: Component = (props) => { }; const saveAndNotify = (state: ToolbarState) => { - saveToolbarState(state); + saveToolbarState(state, props.persistToolbarState); props.onStateChange?.(state); }; onMount(() => { - const savedState = loadToolbarState(); + const savedState = loadToolbarState(props.persistToolbarState); const rect = containerRef?.getBoundingClientRect(); const viewport = getVisualViewport(); diff --git a/packages/react-grab/src/components/toolbar/state.ts b/packages/react-grab/src/components/toolbar/state.ts index e02b9615f..571c5ce22 100644 --- a/packages/react-grab/src/components/toolbar/state.ts +++ b/packages/react-grab/src/components/toolbar/state.ts @@ -5,7 +5,10 @@ export type SnapEdge = "top" | "bottom" | "left" | "right"; const STORAGE_KEY = "react-grab-toolbar-state"; -export const loadToolbarState = (): ToolbarState | null => { +export const loadToolbarState = ( + persistToolbarState = true, +): ToolbarState | null => { + if (!persistToolbarState) return null; try { const serializedToolbarState = localStorage.getItem(STORAGE_KEY); if (!serializedToolbarState) return null; @@ -28,7 +31,11 @@ export const loadToolbarState = (): ToolbarState | null => { return null; }; -export const saveToolbarState = (state: ToolbarState): void => { +export const saveToolbarState = ( + state: ToolbarState, + persistToolbarState = true, +): void => { + if (!persistToolbarState) return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (error) { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 6fac4e7ec..e37b18fcf 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -247,7 +247,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { store.current.isPendingDismiss, ); - const savedToolbarState = loadToolbarState(); + const shouldPersistToolbarState = () => + pluginRegistry.store.options.persistToolbarState !== false; + const savedToolbarState = loadToolbarState( + initialOptions.persistToolbarState !== false, + ); const [isEnabled, setIsEnabled] = createSignal( savedToolbarState?.enabled ?? true, ); @@ -1514,14 +1518,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const handleToggleEnabled = () => { const newEnabled = !isEnabled(); setIsEnabled(newEnabled); - const currentState = loadToolbarState(); + const persistEnabled = shouldPersistToolbarState(); + const currentState = + loadToolbarState(persistEnabled) ?? currentToolbarState(); const newState = { edge: currentState?.edge ?? "bottom", ratio: currentState?.ratio ?? 0.5, collapsed: currentState?.collapsed ?? false, enabled: newEnabled, }; - saveToolbarState(newState); + saveToolbarState(newState, persistEnabled); setCurrentToolbarState(newState); toolbarStateChangeCallbacks.forEach((cb) => cb(newState)); if (!newEnabled) { @@ -3395,6 +3401,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; }} onToolbarSelectHoverChange={setIsToolbarSelectHovered} + persistToolbarState={shouldPersistToolbarState()} contextMenuPosition={contextMenuPosition()} contextMenuBounds={contextMenuBounds()} contextMenuTagName={contextMenuTagName()} @@ -3505,16 +3512,21 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { inToggleFeedbackPeriod = false; } }, - getToolbarState: () => loadToolbarState(), + getToolbarState: () => { + const persistEnabled = shouldPersistToolbarState(); + return loadToolbarState(persistEnabled) ?? currentToolbarState(); + }, setToolbarState: (state: Partial) => { - const currentState = loadToolbarState(); + const persistEnabled = shouldPersistToolbarState(); + const currentState = + loadToolbarState(persistEnabled) ?? currentToolbarState(); const newState = { edge: state.edge ?? currentState?.edge ?? "bottom", ratio: state.ratio ?? currentState?.ratio ?? 0.5, collapsed: state.collapsed ?? currentState?.collapsed ?? false, enabled: state.enabled ?? currentState?.enabled ?? true, }; - saveToolbarState(newState); + saveToolbarState(newState, persistEnabled); setCurrentToolbarState(newState); if (state.enabled !== undefined && state.enabled !== isEnabled()) { setIsEnabled(state.enabled); diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 2f01cddec..97c4f583f 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -35,6 +35,7 @@ interface OptionsState { activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; freezeReactUpdates: boolean; + persistToolbarState: boolean; } const DEFAULT_OPTIONS: OptionsState = { @@ -45,6 +46,7 @@ const DEFAULT_OPTIONS: OptionsState = { activationKey: undefined, getContent: undefined, freezeReactUpdates: true, + persistToolbarState: true, }; interface PluginStoreState { diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 5ae5df600..216ebe6da 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -366,6 +366,12 @@ export interface Options { * @default true */ freezeReactUpdates?: boolean; + /** + * Whether to persist toolbar state (position, collapsed, enabled) to localStorage. + * Set to false when an external consumer (e.g. web extension) handles persistence. + * @default true + */ + persistToolbarState?: boolean; } export interface SettableOptions extends Options { @@ -505,6 +511,7 @@ export interface ReactGrabRendererProps { callback: (state: ToolbarState) => void, ) => () => void; onToolbarSelectHoverChange?: (isHovered: boolean) => void; + persistToolbarState?: boolean; contextMenuPosition?: { x: number; y: number } | null; contextMenuBounds?: OverlayBounds | null; contextMenuTagName?: string; diff --git a/packages/web-extension/package.json b/packages/web-extension/package.json index be26b048d..3fb93721b 100644 --- a/packages/web-extension/package.json +++ b/packages/web-extension/package.json @@ -13,6 +13,7 @@ "react": "19.1.2", "react-dom": "19.1.2", "react-grab": "workspace:*", + "sinking": "^0.0.6", "turndown": "^7.2.0", "webextension-polyfill": "^0.12.0" }, diff --git a/packages/web-extension/src/content/bridge.ts b/packages/web-extension/src/content/bridge.ts index f5c10efd6..0647c661a 100644 --- a/packages/web-extension/src/content/bridge.ts +++ b/packages/web-extension/src/content/bridge.ts @@ -8,16 +8,6 @@ chrome.storage.onChanged.addListener((changes) => { "*", ); } - - if (changes.react_grab_toolbar_state) { - const newState = changes.react_grab_toolbar_state.newValue; - if (newState) { - window.postMessage( - { type: "__REACT_GRAB_TOOLBAR_STATE_CHANGE__", state: newState }, - "*", - ); - } - } }); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { @@ -38,25 +28,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { window.addEventListener("message", (event) => { if (event.data?.type === "__REACT_GRAB_QUERY_STATE__") { - chrome.storage.local.get( - ["react_grab_enabled", "react_grab_toolbar_state"], - (result) => { - const enabled = result.react_grab_enabled ?? true; - const toolbarState = result.react_grab_toolbar_state ?? null; + chrome.storage.local.get(["react_grab_enabled"], (result) => { + const enabled = result.react_grab_enabled ?? true; - window.postMessage( - { - type: "__REACT_GRAB_STATE_RESPONSE__", - enabled, - toolbarState, - }, - "*", - ); - }, - ); + window.postMessage( + { + type: "__REACT_GRAB_STATE_RESPONSE__", + enabled, + }, + "*", + ); + }); } - if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__") { - chrome.storage.local.set({ react_grab_toolbar_state: event.data.state }); + if (event.data?.type === "__REACT_GRAB_GET_WORKER_URL__") { + const workerUrl = chrome.runtime.getURL("src/worker.ts"); + window.postMessage({ type: "__REACT_GRAB_WORKER_URL__", workerUrl }, "*"); } }); diff --git a/packages/web-extension/src/content/react-grab.ts b/packages/web-extension/src/content/react-grab.ts index 5b81615ff..9912cf80f 100644 --- a/packages/web-extension/src/content/react-grab.ts +++ b/packages/web-extension/src/content/react-grab.ts @@ -5,6 +5,13 @@ import { LOCALHOST_INIT_DELAY_MS, STATE_QUERY_TIMEOUT_MS, } from "../constants.js"; +import { + initSinkingClient, + loadToolbarStateFromSinking, + saveToolbarStateToSinking, + subscribeToToolbarState, + getCachedToolbarState, +} from "../storage/client.js"; declare global { interface Window { @@ -30,24 +37,42 @@ let extensionApi: ReactGrabAPI | null = null; let lastToolbarState: ToolbarState | null = null; let isApplyingExternalState = false; let stateChangeUnsubscribe: (() => void) | null = null; +let sinkingUnsubscribe: (() => void) | null = null; + +const isToolbarStateEqual = ( + stateA: ToolbarState | null, + stateB: ToolbarState | null, +): boolean => { + if (stateA === stateB) return true; + if (!stateA || !stateB) return false; + return ( + stateA.edge === stateB.edge && + stateA.ratio === stateB.ratio && + stateA.collapsed === stateB.collapsed && + stateA.enabled === stateB.enabled + ); +}; const handleToolbarStateFromApi = (toolbarState: ToolbarState | null): void => { if (isApplyingExternalState) return; if (!toolbarState) return; - if ( - lastToolbarState && - lastToolbarState.edge === toolbarState.edge && - lastToolbarState.ratio === toolbarState.ratio && - lastToolbarState.collapsed === toolbarState.collapsed && - lastToolbarState.enabled === toolbarState.enabled - ) { - return; - } + if (isToolbarStateEqual(lastToolbarState, toolbarState)) return; lastToolbarState = toolbarState; - window.postMessage( - { type: "__REACT_GRAB_TOOLBAR_STATE_SAVE__", state: toolbarState }, - "*", - ); + void saveToolbarStateToSinking(toolbarState); +}; + +const handleSinkingChange = (): void => { + const cachedState = getCachedToolbarState(); + if (!cachedState) return; + if (isToolbarStateEqual(lastToolbarState, cachedState)) return; + + lastToolbarState = cachedState; + const api = getActiveApi(); + if (api) { + isApplyingExternalState = true; + api.setToolbarState(cachedState); + isApplyingExternalState = false; + } }; const subscribeToStateChanges = (api: ReactGrabAPI): void => { @@ -57,10 +82,19 @@ const subscribeToStateChanges = (api: ReactGrabAPI): void => { stateChangeUnsubscribe = api.onToolbarStateChange((state) => { handleToolbarStateFromApi(state); }); + + if (sinkingUnsubscribe) { + sinkingUnsubscribe(); + } + sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange); +}; + +const disableCorePersistence = (api: ReactGrabAPI): void => { + api.setOptions({ persistToolbarState: false }); }; const createExtensionApi = (): ReactGrabAPI => { - const options: Options = { enabled: true }; + const options: Options = { enabled: true, persistToolbarState: false }; if (!isLocalhost) { options.getContent = (elements) => { @@ -95,6 +129,8 @@ const initializeReactGrab = (): Promise => { const delayedApi = getActiveApi(); if (delayedApi) { extensionApi = delayedApi; + disableCorePersistence(delayedApi); + subscribeToStateChanges(delayedApi); resolve(delayedApi); return; } @@ -116,6 +152,7 @@ window.addEventListener("react-grab:init", (event) => { } extensionApi = pageApi; window.__REACT_GRAB__ = pageApi; + disableCorePersistence(pageApi); subscribeToStateChanges(pageApi); }); @@ -128,37 +165,20 @@ const handleToggle = async (enabled: boolean): Promise => { } }; -const handleToolbarStateChange = async (state: ToolbarState): Promise => { - if (isApplyingExternalState) return; - - await initializeReactGrab(); - const api = getActiveApi(); - if (api) { - isApplyingExternalState = true; - api.setToolbarState(state); - isApplyingExternalState = false; - } -}; - window.addEventListener("message", (event: MessageEvent) => { if (event.data?.type === "__REACT_GRAB_EXTENSION_TOGGLE__") { void handleToggle(event.data.enabled); } - - if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_CHANGE__") { - void handleToolbarStateChange(event.data.state); - } }); interface InitialState { enabled: boolean; - toolbarState: ToolbarState | null; } const queryInitialState = (): Promise => { return new Promise((resolve) => { const timeout = setTimeout(() => { - resolve({ enabled: true, toolbarState: null }); + resolve({ enabled: true }); }, STATE_QUERY_TIMEOUT_MS); const handler = (event: MessageEvent) => { @@ -167,7 +187,6 @@ const queryInitialState = (): Promise => { window.removeEventListener("message", handler); resolve({ enabled: event.data.enabled ?? true, - toolbarState: event.data.toolbarState ?? null, }); } }; @@ -177,16 +196,46 @@ const queryInitialState = (): Promise => { }); }; +const requestWorkerUrl = (): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(null); + }, STATE_QUERY_TIMEOUT_MS); + + const handler = (event: MessageEvent) => { + if (event.data?.type === "__REACT_GRAB_WORKER_URL__") { + clearTimeout(timeout); + window.removeEventListener("message", handler); + resolve(event.data.workerUrl ?? null); + } + }; + + window.addEventListener("message", handler); + window.postMessage({ type: "__REACT_GRAB_GET_WORKER_URL__" }, "*"); + }); +}; + const startup = async (): Promise => { - const initialState = await queryInitialState(); + const [initialState, workerUrl] = await Promise.all([ + queryInitialState(), + requestWorkerUrl(), + ]); + + if (workerUrl) { + initSinkingClient(workerUrl); + } + const api = await initializeReactGrab(); if (api) { - if (initialState.toolbarState) { + const sinkingToolbarState = await loadToolbarStateFromSinking(); + if (sinkingToolbarState) { isApplyingExternalState = true; - api.setToolbarState(initialState.toolbarState); + api.setToolbarState(sinkingToolbarState); isApplyingExternalState = false; - } else if (!initialState.enabled) { + } + + if (!initialState.enabled) { api.setEnabled(false); } } diff --git a/packages/web-extension/src/manifest.json b/packages/web-extension/src/manifest.json index d421d9422..810fd69ac 100644 --- a/packages/web-extension/src/manifest.json +++ b/packages/web-extension/src/manifest.json @@ -37,6 +37,12 @@ "world": "MAIN" } ], + "web_accessible_resources": [ + { + "resources": ["src/worker.ts"], + "matches": [""] + } + ], "host_permissions": [""], "permissions": ["storage"] } diff --git a/packages/web-extension/src/storage/client.ts b/packages/web-extension/src/storage/client.ts new file mode 100644 index 000000000..350edc922 --- /dev/null +++ b/packages/web-extension/src/storage/client.ts @@ -0,0 +1,82 @@ +import { Sinking, type DatabaseSchema } from "sinking/core"; + +interface ToolbarState { + edge: "top" | "bottom" | "left" | "right"; + ratio: number; + collapsed: boolean; + enabled: boolean; +} + +interface ToolbarStateRecord extends ToolbarState { + id: string; +} + +const TOOLBAR_STATE_STORE = "toolbar-state"; +const TOOLBAR_STATE_KEY = "default"; + +const schema: DatabaseSchema = { + name: "react-grab", + version: 1, + stores: { + [TOOLBAR_STATE_STORE]: { keyPath: "id" }, + }, +}; + +let sinkingClient: Sinking | null = null; + +export const initSinkingClient = (workerUrl: string | URL): Sinking => { + if (sinkingClient) return sinkingClient; + sinkingClient = new Sinking({ workerUrl, schema }); + return sinkingClient; +}; + +export const getSinkingClient = (): Sinking | null => sinkingClient; + +export const loadToolbarStateFromSinking = + async (): Promise => { + if (!sinkingClient) return null; + const record = await sinkingClient.get( + TOOLBAR_STATE_STORE, + TOOLBAR_STATE_KEY, + ); + if (!record) return null; + return { + edge: record.edge, + ratio: record.ratio, + collapsed: record.collapsed, + enabled: record.enabled, + }; + }; + +export const saveToolbarStateToSinking = async ( + state: ToolbarState, +): Promise => { + if (!sinkingClient) return; + const record: ToolbarStateRecord = { ...state, id: TOOLBAR_STATE_KEY }; + await sinkingClient.put(TOOLBAR_STATE_STORE, TOOLBAR_STATE_KEY, record); +}; + +export const subscribeToToolbarState = (listener: () => void): (() => void) => { + if (!sinkingClient) return () => {}; + const query = sinkingClient.get( + TOOLBAR_STATE_STORE, + TOOLBAR_STATE_KEY, + ); + return sinkingClient.subscribe(query.description, listener); +}; + +export const getCachedToolbarState = (): ToolbarState | null => { + if (!sinkingClient) return null; + const query = sinkingClient.get( + TOOLBAR_STATE_STORE, + TOOLBAR_STATE_KEY, + ); + const record = sinkingClient.getCached(query.description); + if (!record) return null; + return { + edge: record.edge, + ratio: record.ratio, + collapsed: record.collapsed, + enabled: record.enabled, + }; +}; diff --git a/packages/web-extension/src/worker.ts b/packages/web-extension/src/worker.ts new file mode 100644 index 000000000..ce187dba6 --- /dev/null +++ b/packages/web-extension/src/worker.ts @@ -0,0 +1 @@ +import "sinking/worker"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c49a3050..803696390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,9 @@ importers: react-grab: specifier: workspace:* version: link:../react-grab + sinking: + specifier: ^0.0.6 + version: 0.0.6(react@19.1.2) turndown: specifier: ^7.2.0 version: 7.2.2 @@ -3051,8 +3054,8 @@ packages: engines: {node: '>=20'} hasBin: true - '@sourcegraph/amp@0.0.1770231444-g53361d': - resolution: {integrity: sha512-TuwM7W9QSkJ7J3ZouQBGu7G3snjtsWCBl61+e2zdmF8YjHwYFhNbUjeK7PHDu7NSSqOvR7Wu04Chmgpx50HIWA==} + '@sourcegraph/amp@0.0.1770352274-gd36e02': + resolution: {integrity: sha512-Gs7m7nomhJ79jGneQcHYoG8jWvb1aLfxVci3KgzCeIOya19662nEi/Y+T8LsnZa5eI3tSJWa2mDRFeojECVahg==} engines: {node: '>=20'} hasBin: true @@ -6341,6 +6344,11 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sinking@0.0.6: + resolution: {integrity: sha512-VzNzbrkZdsirp6QfjqysKgv223uB8QhP15WwYoQi+DPCi9lwjLBDFBFq280jP8AuXqbySlHZ/ZogoJqtMJHuJg==} + peerDependencies: + react: '>=19' + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -9033,14 +9041,14 @@ snapshots: '@sourcegraph/amp-sdk@0.1.0-20251210081226-g90e3892': dependencies: - '@sourcegraph/amp': 0.0.1770231444-g53361d + '@sourcegraph/amp': 0.0.1770352274-gd36e02 zod: 3.25.76 '@sourcegraph/amp@0.0.1767830505-ga62310': dependencies: '@napi-rs/keyring': 1.1.9 - '@sourcegraph/amp@0.0.1770231444-g53361d': + '@sourcegraph/amp@0.0.1770352274-gd36e02': dependencies: '@napi-rs/keyring': 1.1.9 @@ -12574,6 +12582,10 @@ snapshots: signal-exit@4.1.0: {} + sinking@0.0.6(react@19.1.2): + dependencies: + react: 19.1.2 + sisteransi@1.0.5: {} slash@3.0.0: {} From a1c739a1af900cc29299248ae2ec8fa8e4e5411d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 22:26:34 -0800 Subject: [PATCH 2/3] refactor: clean up sinking integration - Remove duplicate ToolbarState interfaces, import from react-grab - Remove unused getSinkingClient export - Extract toToolbarState and getToolbarStateQuery helpers in storage client - Extract resolveToolbarState helper in core to deduplicate pattern - Simplify subscribeToStateChanges with optional chaining and direct callback Co-authored-by: Cursor --- packages/react-grab/src/core/index.tsx | 19 +++---- .../web-extension/src/content/react-grab.ts | 21 ++------ packages/web-extension/src/storage/client.ts | 54 +++++++------------ 3 files changed, 31 insertions(+), 63 deletions(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index e37b18fcf..f31a8dcd0 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -249,6 +249,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const shouldPersistToolbarState = () => pluginRegistry.store.options.persistToolbarState !== false; + const resolveToolbarState = (): ToolbarState | null => + loadToolbarState(shouldPersistToolbarState()) ?? currentToolbarState(); const savedToolbarState = loadToolbarState( initialOptions.persistToolbarState !== false, ); @@ -1518,16 +1520,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const handleToggleEnabled = () => { const newEnabled = !isEnabled(); setIsEnabled(newEnabled); - const persistEnabled = shouldPersistToolbarState(); - const currentState = - loadToolbarState(persistEnabled) ?? currentToolbarState(); + const currentState = resolveToolbarState(); const newState = { edge: currentState?.edge ?? "bottom", ratio: currentState?.ratio ?? 0.5, collapsed: currentState?.collapsed ?? false, enabled: newEnabled, }; - saveToolbarState(newState, persistEnabled); + saveToolbarState(newState, shouldPersistToolbarState()); setCurrentToolbarState(newState); toolbarStateChangeCallbacks.forEach((cb) => cb(newState)); if (!newEnabled) { @@ -3512,21 +3512,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { inToggleFeedbackPeriod = false; } }, - getToolbarState: () => { - const persistEnabled = shouldPersistToolbarState(); - return loadToolbarState(persistEnabled) ?? currentToolbarState(); - }, + getToolbarState: () => resolveToolbarState(), setToolbarState: (state: Partial) => { - const persistEnabled = shouldPersistToolbarState(); - const currentState = - loadToolbarState(persistEnabled) ?? currentToolbarState(); + const currentState = resolveToolbarState(); const newState = { edge: state.edge ?? currentState?.edge ?? "bottom", ratio: state.ratio ?? currentState?.ratio ?? 0.5, collapsed: state.collapsed ?? currentState?.collapsed ?? false, enabled: state.enabled ?? currentState?.enabled ?? true, }; - saveToolbarState(newState, persistEnabled); + saveToolbarState(newState, shouldPersistToolbarState()); setCurrentToolbarState(newState); if (state.enabled !== undefined && state.enabled !== isEnabled()) { setIsEnabled(state.enabled); diff --git a/packages/web-extension/src/content/react-grab.ts b/packages/web-extension/src/content/react-grab.ts index 9912cf80f..aa64d6e89 100644 --- a/packages/web-extension/src/content/react-grab.ts +++ b/packages/web-extension/src/content/react-grab.ts @@ -1,5 +1,5 @@ import { init } from "react-grab/core"; -import type { Options, ReactGrabAPI } from "react-grab"; +import type { Options, ReactGrabAPI, ToolbarState } from "react-grab"; import TurndownService from "turndown"; import { LOCALHOST_INIT_DELAY_MS, @@ -26,13 +26,6 @@ const isLocalhost = const turndownService = new TurndownService(); -interface ToolbarState { - edge: "top" | "bottom" | "left" | "right"; - ratio: number; - collapsed: boolean; - enabled: boolean; -} - let extensionApi: ReactGrabAPI | null = null; let lastToolbarState: ToolbarState | null = null; let isApplyingExternalState = false; @@ -76,16 +69,10 @@ const handleSinkingChange = (): void => { }; const subscribeToStateChanges = (api: ReactGrabAPI): void => { - if (stateChangeUnsubscribe) { - stateChangeUnsubscribe(); - } - stateChangeUnsubscribe = api.onToolbarStateChange((state) => { - handleToolbarStateFromApi(state); - }); + stateChangeUnsubscribe?.(); + stateChangeUnsubscribe = api.onToolbarStateChange(handleToolbarStateFromApi); - if (sinkingUnsubscribe) { - sinkingUnsubscribe(); - } + sinkingUnsubscribe?.(); sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange); }; diff --git a/packages/web-extension/src/storage/client.ts b/packages/web-extension/src/storage/client.ts index 350edc922..43f998b86 100644 --- a/packages/web-extension/src/storage/client.ts +++ b/packages/web-extension/src/storage/client.ts @@ -1,12 +1,6 @@ +import type { ToolbarState } from "react-grab"; import { Sinking, type DatabaseSchema } from "sinking/core"; -interface ToolbarState { - edge: "top" | "bottom" | "left" | "right"; - ratio: number; - collapsed: boolean; - enabled: boolean; -} - interface ToolbarStateRecord extends ToolbarState { id: string; } @@ -24,28 +18,31 @@ const schema: DatabaseSchema = { let sinkingClient: Sinking | null = null; +const toToolbarState = (record: ToolbarStateRecord): ToolbarState => ({ + edge: record.edge, + ratio: record.ratio, + collapsed: record.collapsed, + enabled: record.enabled, +}); + +const getToolbarStateQuery = () => + sinkingClient!.get( + TOOLBAR_STATE_STORE, + TOOLBAR_STATE_KEY, + ); + export const initSinkingClient = (workerUrl: string | URL): Sinking => { if (sinkingClient) return sinkingClient; sinkingClient = new Sinking({ workerUrl, schema }); return sinkingClient; }; -export const getSinkingClient = (): Sinking | null => sinkingClient; - export const loadToolbarStateFromSinking = async (): Promise => { if (!sinkingClient) return null; - const record = await sinkingClient.get( - TOOLBAR_STATE_STORE, - TOOLBAR_STATE_KEY, - ); + const record = await getToolbarStateQuery(); if (!record) return null; - return { - edge: record.edge, - ratio: record.ratio, - collapsed: record.collapsed, - enabled: record.enabled, - }; + return toToolbarState(record); }; export const saveToolbarStateToSinking = async ( @@ -58,25 +55,14 @@ export const saveToolbarStateToSinking = async ( export const subscribeToToolbarState = (listener: () => void): (() => void) => { if (!sinkingClient) return () => {}; - const query = sinkingClient.get( - TOOLBAR_STATE_STORE, - TOOLBAR_STATE_KEY, - ); - return sinkingClient.subscribe(query.description, listener); + return sinkingClient.subscribe(getToolbarStateQuery().description, listener); }; export const getCachedToolbarState = (): ToolbarState | null => { if (!sinkingClient) return null; - const query = sinkingClient.get( - TOOLBAR_STATE_STORE, - TOOLBAR_STATE_KEY, + const record = sinkingClient.getCached( + getToolbarStateQuery().description, ); - const record = sinkingClient.getCached(query.description); if (!record) return null; - return { - edge: record.edge, - ratio: record.ratio, - collapsed: record.collapsed, - enabled: record.enabled, - }; + return toToolbarState(record); }; From 7fe1b47c6410978b8435df7fd78e7be816147c68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 06:43:42 +0000 Subject: [PATCH 3/3] Fix sinking cross-tab subscription race condition Re-subscribe to sinking toolbar state after initializing sinkingClient if an API was already initialized via the react-grab:init event. This ensures the cross-tab sync subscription is properly established even when the event fires before startup() runs on non-localhost sites. --- packages/web-extension/src/content/react-grab.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web-extension/src/content/react-grab.ts b/packages/web-extension/src/content/react-grab.ts index aa64d6e89..ebe0b1c8b 100644 --- a/packages/web-extension/src/content/react-grab.ts +++ b/packages/web-extension/src/content/react-grab.ts @@ -210,6 +210,12 @@ const startup = async (): Promise => { if (workerUrl) { initSinkingClient(workerUrl); + + const existingApi = getActiveApi(); + if (existingApi) { + sinkingUnsubscribe?.(); + sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange); + } } const api = await initializeReactGrab();