Skip to content

refactor: replace cross-tab chrome.storage sync with sinking#160

Open
aidenybai wants to merge 3 commits intomainfrom
refactor/sinking-cross-tab-sync
Open

refactor: replace cross-tab chrome.storage sync with sinking#160
aidenybai wants to merge 3 commits intomainfrom
refactor/sinking-cross-tab-sync

Conversation

@aidenybai
Copy link
Owner

@aidenybai aidenybai commented Feb 6, 2026

Summary

  • Add persistToolbarState option to core (default true) so localStorage persistence can be disabled by external consumers
  • Replace web extension's chrome.storage-based toolbar state sync with sinking (IndexedDB + SharedWorker) for cross-tab sync
  • Keep chrome.storage only for the extension icon enable/disable toggle (cross-origin)

Changes

Core (react-grab):

  • types.ts: Add persistToolbarState?: boolean to Options, SettableOptions, ReactGrabRendererProps
  • plugin-registry.ts: Add persistToolbarState to OptionsState (default true)
  • toolbar/state.ts: Guard loadToolbarState/saveToolbarState behind the flag
  • toolbar/index.tsx: Pass flag to load/save calls
  • renderer.tsx: Pass persistToolbarState prop to Toolbar
  • core/index.tsx: Track flag via plugin registry, guard all localStorage calls, fall back to in-memory state when disabled

Web extension:

  • Add sinking dependency
  • New src/worker.ts (SharedWorker entry)
  • New src/storage/client.ts (sinking client with typed helpers)
  • manifest.json: Add web_accessible_resources for worker
  • bridge.ts: Remove toolbar state chrome.storage handling, add worker URL relay
  • react-grab.ts: Disable core localStorage, init sinking client, load/save/subscribe via sinking

Behavior

  • No extension: localStorage works exactly as before (default)
  • Extension present: Core localStorage disabled, sinking handles persistence + cross-tab sync via SharedWorker

Test plan

  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm format passes
  • pnpm test passes (429 tests)
  • Manual: verify toolbar state persists across page reloads without extension
  • Manual: verify toolbar state syncs across tabs with extension installed

Made with Cursor


Summary by cubic

Replaced the web extension’s cross‑tab toolbar state sync from chrome.storage to sinking (IndexedDB + SharedWorker) for reliable, real‑time sync. Added a persistToolbarState option to core so external consumers can disable localStorage persistence (default stays true).

  • Refactors

    • Core: added persistToolbarState (default true) and guarded all localStorage reads/writes; falls back to in‑memory when disabled.
    • Extension: uses sinking to load/save/subscribe toolbar state and disables core persistence at init; chrome.storage kept only for the icon enable/disable toggle.
    • Behavior: without the extension, localStorage works as before; with the extension, toolbar state persists and syncs across tabs via sinking.
  • Migration

    • If you manage persistence externally, set persistToolbarState: false via init or setOptions.

Written for commit 7fe1b47. Summary will update on new commits.


Note

Medium Risk
Touches persistence and synchronization paths for toolbar state and changes extension storage backends, which could cause state loss/desync across reloads/tabs if misconfigured or if the worker/client init fails.

Overview
Decouples toolbar persistence from core localStorage by introducing persistToolbarState (default true) and threading it through renderer/toolbar/core state helpers; when disabled, core state reads fall back to in-memory currentToolbarState and writes are skipped.

Refactors the web extension to stop syncing toolbar state via chrome.storage messages and instead persist/sync it across tabs using sinking (IndexedDB + SharedWorker). This adds a sinking client wrapper plus a web-accessible worker entry, updates the content-script bridge to only handle enabled/disabled state and to provide the worker URL, and ensures the extension disables core persistence and hydrates/applies toolbar state from sinking on startup.

Written by Cursor Bugbot for commit 7fe1b47. This will update automatically on new commits. Configure here.

…DB + 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 <cursoragent@cursor.com>
@vercel
Copy link

vercel bot commented Feb 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-grab-website Ready Ready Preview, Comment Feb 6, 2026 6:44am

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 6, 2026

Open in StackBlitz

@react-grab/cli

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/cli@160

grab

npm i https://pkg.pr.new/aidenybai/react-grab/grab@160

@react-grab/ami

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/ami@160

@react-grab/amp

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/amp@160

@react-grab/claude-code

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/claude-code@160

@react-grab/codex

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/codex@160

@react-grab/cursor

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/cursor@160

@react-grab/droid

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/droid@160

@react-grab/gemini

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/gemini@160

@react-grab/opencode

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/opencode@160

react-grab

npm i https://pkg.pr.new/aidenybai/react-grab@160

@react-grab/relay

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/relay@160

@react-grab/utils

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/utils@160

commit: 7fe1b47

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found several critical issues: race condition in startup sequence, inconsistent persistence flag reading, missing error handling, and wrong worker file extension in manifest. Must fix before merge.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +252 to +254
const savedToolbarState = loadToolbarState(
initialOptions.persistToolbarState !== false,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical inconsistency: Initial load uses initialOptions.persistToolbarState but runtime checks use pluginRegistry.store.options.persistToolbarState. If setOptions({ persistToolbarState: false }) is called (which the extension does at line 93 of react-grab.ts), shouldPersistToolbarState() will correctly return false, but this initial load already happened with the wrong value. The extension explicitly passes persistToolbarState: false in initial options, so this specific code path works, but the inconsistent pattern is fragile—if initialization order changes or if someone calls setOptions before the toolbar mounts, this breaks.

Comment on lines 224 to 226
if (workerUrl) {
initSinkingClient(workerUrl);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: Sinking client initialization is not awaited. The constructor for Sinking likely triggers async IndexedDB operations or SharedWorker connection setup. Immediately after this non-blocking call, line 231 calls loadToolbarStateFromSinking() which will return null if the client hasn't finished initializing. This means the persisted toolbar state will be silently lost on first load.

Fix: Change to await initSinkingClient(workerUrl); and add proper error handling for initialization failures.

if (sinkingUnsubscribe) {
sinkingUnsubscribe();
}
sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition: Subscription happens synchronously during subscribeToStateChanges, but if the sinking client isn't fully initialized yet (see issue at line 224-226), this subscription may fail silently or not trigger when expected. The handler will be registered but might miss early state changes if another tab updates the state during the initialization window.

Comment on lines 35 to 49
export const loadToolbarStateFromSinking =
async (): Promise<ToolbarState | null> => {
if (!sinkingClient) return null;
const record = await sinkingClient.get<ToolbarStateRecord>(
TOOLBAR_STATE_STORE,
TOOLBAR_STATE_KEY,
);
if (!record) return null;
return {
edge: record.edge,
ratio: record.ratio,
collapsed: record.collapsed,
enabled: record.enabled,
};
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No error handling: All sinking operations are wrapped in zero try-catch blocks. If IndexedDB fails (quota exceeded, browser privacy mode, corrupted DB), SharedWorker fails to initialize, or any sinking API throws, the extension will crash or silently fail. Recommendation: wrap all sinking operations in try-catch and gracefully degrade to in-memory state when persistence fails.

Comment on lines +51 to +57
export const saveToolbarStateToSinking = async (
state: ToolbarState,
): Promise<void> => {
if (!sinkingClient) return;
const record: ToolbarStateRecord = { ...state, id: TOOLBAR_STATE_KEY };
await sinkingClient.put(TOOLBAR_STATE_STORE, TOOLBAR_STATE_KEY, record);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No error handling: Same issue—saveToolbarStateToSinking has no error handling. If IndexedDB write fails, the user will lose their toolbar state without any indication. The void in line 61 of react-grab.ts (calling this function) suppresses TypeScript's unhandled promise warning, making this worse.

],
"web_accessible_resources": [
{
"resources": ["src/worker.ts"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong file extension: The worker file is TypeScript (worker.ts) but web_accessible_resources should reference the compiled JavaScript output. Extensions can't execute TypeScript directly. This will cause a runtime error when the content script tries to load the worker URL. Need to check what the build output path is (likely dist/src/worker.js or similar) and reference that instead, or ensure the build process handles this correctly.

Comment on lines +132 to +133
disableCorePersistence(delayedApi);
subscribeToStateChanges(delayedApi);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant call to disableCorePersistence: This is already done inside createExtensionApi at line 111. When localhost detects an existing API and waits for it, calling disableCorePersistence again is correct, but this code path shows a subtle issue: if the page loads the library itself on localhost, the extension correctly disables persistence—but the page's API was initialized with default persistToolbarState: true, meaning localStorage was already written to during the page's initialization (line 252-254 of core/index.tsx). The extension then disables it, but stale localStorage data remains and will be read on next page load if the extension isn't active.

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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect path: Similar to manifest.json issue—this references the source TypeScript file instead of the compiled JavaScript. chrome.runtime.getURL('src/worker.ts') will return a URL to the .ts file which won't exist in the built extension. Should be 'src/worker.js' or wherever the built worker ends up.

- 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 <cursoragent@cursor.com>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 13 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/web-extension/src/storage/client.ts">

<violation number="1" location="packages/web-extension/src/storage/client.ts:29">
P0: The implementation attempts to create a `SharedWorker` using a `chrome-extension://` URL (passed as `workerUrl`) from a content script running in the page's MAIN world. This violates the Same-Origin Policy for SharedWorkers, which requires the worker script to be served from the same origin as the document spawning it.

This will cause `new Sinking(...)` to throw a `SecurityError`, causing the `startup` sequence in `react-grab.ts` to crash and the extension to fail completely.

To fix this, you must use an iframe bridge pattern: inject a hidden iframe (served from the extension) into the page, spawn the SharedWorker from within that iframe, and communicate via `postMessage`.</violation>

<violation number="2" location="packages/web-extension/src/storage/client.ts:38">
P1: Storage operations like `sinkingClient.get` can fail (e.g., IndexedDB errors, corruption, quota limits). Since `loadToolbarStateFromSinking` is awaited during the critical `startup` phase in `react-grab.ts`, an unhandled error here will cause the entire initialization to abort, preventing the toolbar from appearing.

Wrap the operation in a `try/catch` block to ensure graceful degradation (falling back to default state) if storage is inaccessible.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


export const initSinkingClient = (workerUrl: string | URL): Sinking => {
if (sinkingClient) return sinkingClient;
sinkingClient = new Sinking({ workerUrl, schema });
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: The implementation attempts to create a SharedWorker using a chrome-extension:// URL (passed as workerUrl) from a content script running in the page's MAIN world. This violates the Same-Origin Policy for SharedWorkers, which requires the worker script to be served from the same origin as the document spawning it.

This will cause new Sinking(...) to throw a SecurityError, causing the startup sequence in react-grab.ts to crash and the extension to fail completely.

To fix this, you must use an iframe bridge pattern: inject a hidden iframe (served from the extension) into the page, spawn the SharedWorker from within that iframe, and communicate via postMessage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web-extension/src/storage/client.ts, line 29:

<comment>The implementation attempts to create a `SharedWorker` using a `chrome-extension://` URL (passed as `workerUrl`) from a content script running in the page's MAIN world. This violates the Same-Origin Policy for SharedWorkers, which requires the worker script to be served from the same origin as the document spawning it.

This will cause `new Sinking(...)` to throw a `SecurityError`, causing the `startup` sequence in `react-grab.ts` to crash and the extension to fail completely.

To fix this, you must use an iframe bridge pattern: inject a hidden iframe (served from the extension) into the page, spawn the SharedWorker from within that iframe, and communicate via `postMessage`.</comment>

<file context>
@@ -0,0 +1,82 @@
+
+export const initSinkingClient = (workerUrl: string | URL): Sinking => {
+  if (sinkingClient) return sinkingClient;
+  sinkingClient = new Sinking({ workerUrl, schema });
+  return sinkingClient;
+};
</file context>
Fix with Cubic

export const loadToolbarStateFromSinking =
async (): Promise<ToolbarState | null> => {
if (!sinkingClient) return null;
const record = await sinkingClient.get<ToolbarStateRecord>(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Storage operations like sinkingClient.get can fail (e.g., IndexedDB errors, corruption, quota limits). Since loadToolbarStateFromSinking is awaited during the critical startup phase in react-grab.ts, an unhandled error here will cause the entire initialization to abort, preventing the toolbar from appearing.

Wrap the operation in a try/catch block to ensure graceful degradation (falling back to default state) if storage is inaccessible.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web-extension/src/storage/client.ts, line 38:

<comment>Storage operations like `sinkingClient.get` can fail (e.g., IndexedDB errors, corruption, quota limits). Since `loadToolbarStateFromSinking` is awaited during the critical `startup` phase in `react-grab.ts`, an unhandled error here will cause the entire initialization to abort, preventing the toolbar from appearing.

Wrap the operation in a `try/catch` block to ensure graceful degradation (falling back to default state) if storage is inaccessible.</comment>

<file context>
@@ -0,0 +1,82 @@
+export const loadToolbarStateFromSinking =
+  async (): Promise<ToolbarState | null> => {
+    if (!sinkingClient) return null;
+    const record = await sinkingClient.get<ToolbarStateRecord>(
+      TOOLBAR_STATE_STORE,
+      TOOLBAR_STATE_KEY,
</file context>
Fix with Cubic

isApplyingExternalState = false;
} else if (!initialState.enabled) {
}

Copy link

@vercel vercel bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: initSinkingClient called without awaiting async SharedWorker initialization, potentially allowing subsequent operations to proceed before the Sinking client is fully ready

Fix on Vercel

const record = await getToolbarStateQuery();
if (!record) return null;
return toToolbarState(record);
};
Copy link

@vercel vercel bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling in toolbar state storage operations causes silent failures when IndexedDB or Sinking operations fail

Fix on Vercel

export const subscribeToToolbarState = (listener: () => void): (() => void) => {
if (!sinkingClient) return () => {};
return sinkingClient.subscribe(getToolbarStateQuery().description, listener);
};
Copy link

@vercel vercel bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subscribeToToolbarState and getCachedToolbarState incorrectly call sinkingClient.get() without awaiting and try to access .description property on a Promise object

Fix on Vercel

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.

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.
@cursor
Copy link

cursor bot commented Feb 6, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Sinking cross-tab subscription lost due to initialization race
    • Re-subscribed to sinking toolbar state after initializing sinkingClient if an API already exists, ensuring cross-tab sync works when react-grab:init fires before startup().

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/web-extension/src/content/react-grab.ts">

<violation number="1" location="packages/web-extension/src/content/react-grab.ts:214">
P2: The manual subscription to sinking state here is incomplete for existing APIs. If the `react-grab:init` event was missed (e.g., due to late injection), this code fails to disable core persistence and attach the API-to-sinking listener (`onToolbarStateChange`). This can lead to double persistence (localStorage + Sinking) and one-way synchronization. Use the existing helper functions `disableCorePersistence` and `subscribeToStateChanges` to ensure full initialization.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +214 to +218
const existingApi = getActiveApi();
if (existingApi) {
sinkingUnsubscribe?.();
sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange);
}
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The manual subscription to sinking state here is incomplete for existing APIs. If the react-grab:init event was missed (e.g., due to late injection), this code fails to disable core persistence and attach the API-to-sinking listener (onToolbarStateChange). This can lead to double persistence (localStorage + Sinking) and one-way synchronization. Use the existing helper functions disableCorePersistence and subscribeToStateChanges to ensure full initialization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web-extension/src/content/react-grab.ts, line 214:

<comment>The manual subscription to sinking state here is incomplete for existing APIs. If the `react-grab:init` event was missed (e.g., due to late injection), this code fails to disable core persistence and attach the API-to-sinking listener (`onToolbarStateChange`). This can lead to double persistence (localStorage + Sinking) and one-way synchronization. Use the existing helper functions `disableCorePersistence` and `subscribeToStateChanges` to ensure full initialization.</comment>

<file context>
@@ -210,6 +210,12 @@ const startup = async (): Promise<void> => {
   if (workerUrl) {
     initSinkingClient(workerUrl);
+
+    const existingApi = getActiveApi();
+    if (existingApi) {
+      sinkingUnsubscribe?.();
</file context>
Suggested change
const existingApi = getActiveApi();
if (existingApi) {
sinkingUnsubscribe?.();
sinkingUnsubscribe = subscribeToToolbarState(handleSinkingChange);
}
const existingApi = getActiveApi();
if (existingApi) {
disableCorePersistence(existingApi);
subscribeToStateChanges(existingApi);
}
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants