From af267a6f79f0dfcf5dded9927667eb03a1ee5a28 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 20:26:31 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20restore=20thinking=20?= =?UTF-8?q?level=20when=20model=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThinkingProvider was reading the model non-reactively, so when the model changed (e.g., via Cycle Model), the thinking level wasn't updated. Fix: Subscribe to model changes via usePersistedState with listener mode, so when the model changes, we derive the new thinking key and load the correct stored value for that model. --- _Generated with `mux` • Model: `anthropic:claude-sonnet-4-20250514` • Thinking: `low`_ --- src/browser/contexts/ThinkingContext.tsx | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 1adc9ff052..9d01006253 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import React, { createContext, useContext } from "react"; +import React, { createContext, useContext, useMemo } from "react"; import type { ThinkingLevel } from "@/common/types/thinking"; -import { usePersistedState, readPersistedState } from "@/browser/hooks/usePersistedState"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; @@ -20,25 +20,14 @@ interface ThinkingProviderProps { } /** - * Reads the current model from localStorage for the given scope. - * Returns canonical model format (after gateway migration). + * Hook to get the model key for the current scope. */ -function getScopedModel(workspaceId?: string, projectPath?: string): string { - const defaultModel = getDefaultModel(); - // Use workspace-scoped model key if available, otherwise project-scoped - const modelKey = workspaceId +function useModelKey(workspaceId?: string, projectPath?: string): string | null { + return workspaceId ? getModelKey(workspaceId) : projectPath ? getModelKey(`__project__/${projectPath}`) : null; - - if (!modelKey) { - return defaultModel; - } - - const rawModel = readPersistedState(modelKey, defaultModel); - // Normalize to canonical format (e.g., strip legacy gateway prefix) - return migrateGatewayModel(rawModel || defaultModel); } export const ThinkingProvider: React.FC = ({ @@ -46,11 +35,22 @@ export const ThinkingProvider: React.FC = ({ projectPath, children, }) => { - // Read current model from localStorage (non-reactive, re-reads on each render) - const modelString = getScopedModel(workspaceId, projectPath); - const key = getThinkingLevelByModelKey(modelString); + const defaultModel = getDefaultModel(); + const modelKey = useModelKey(workspaceId, projectPath); + + // Subscribe to model changes so we update thinking level when model changes + const [rawModel] = usePersistedState(modelKey ?? "model:__unused__", defaultModel, { + listener: true, + }); + + // Derive the thinking level key from the current model + const thinkingKey = useMemo(() => { + const model = migrateGatewayModel(rawModel || defaultModel); + return getThinkingLevelByModelKey(model); + }, [rawModel, defaultModel]); + const [thinkingLevel, setThinkingLevel] = usePersistedState( - key, + thinkingKey, "off", { listener: true } // Listen for changes from command palette and other sources ); From 61e2a2db4e4b273e14dbc7c1b8ecc30277d43633 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 20:35:03 -0600 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20rebind=20thinking=20s?= =?UTF-8?q?tate=20on=20model=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Key ThinkingProviderInner by per-model thinking key to avoid one-render stale thinking state during model switches (e.g. Cycle Model) - Add regression test: switching models restores per-model thinking level --- src/browser/contexts/ThinkingContext.test.tsx | 91 +++++++++++++++++++ src/browser/contexts/ThinkingContext.tsx | 41 +++++++-- 2 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 src/browser/contexts/ThinkingContext.test.tsx diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx new file mode 100644 index 0000000000..5d84405d54 --- /dev/null +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -0,0 +1,91 @@ +import { GlobalWindow } from "happy-dom"; + +// Setup basic DOM environment for testing-library +const dom = new GlobalWindow(); +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ +(global as any).window = dom.window; +(global as any).document = dom.window.document; + +// Ensure globals exist for instanceof checks inside usePersistedState +(globalThis as any).StorageEvent = dom.window.StorageEvent; +(globalThis as any).CustomEvent = dom.window.CustomEvent; + +// happy-dom's requestAnimationFrame behavior can vary; ensure it's present so +// usePersistedState listener updates (which batch via RAF) are flushed. +if (!globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => + setTimeout(() => cb(Date.now()), 0) as unknown as number; +} +if (!globalThis.cancelAnimationFrame) { + globalThis.cancelAnimationFrame = (id: number) => { + clearTimeout(id as unknown as NodeJS.Timeout); + }; +} +(global as any).console = console; +/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import React from "react"; +import { ThinkingProvider } from "./ThinkingContext"; +import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; +import { getModelKey, getThinkingLevelByModelKey } from "@/common/constants/storage"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; + +interface TestProps { + workspaceId: string; +} + +const TestComponent: React.FC = (props) => { + const [thinkingLevel] = useThinkingLevel(); + return ( +
+ {thinkingLevel}:{props.workspaceId} +
+ ); +}; + +describe("ThinkingContext", () => { + // Make getDefaultModel deterministic. + // (getDefaultModel reads from the global "model-default" localStorage key.) + beforeEach(() => { + window.localStorage.setItem("model-default", JSON.stringify("openai:default")); + }); + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + }); + + test("switching models restores the per-model thinking level", async () => { + const workspaceId = "ws-1"; + + // Model A + updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2"); + updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high"); + + // Model B + updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low"); + + const view = render( + + + + ); + + await waitFor(() => { + expect(view.getByTestId("thinking").textContent).toBe("high:ws-1"); + }); + + // Change model -> should restore that model's stored thinking level + act(() => { + updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5"); + }); + + await waitFor(() => { + expect(view.getByTestId("thinking").textContent).toBe("low:ws-1"); + }); + }); +}); diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 9d01006253..23cf6cb764 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -13,6 +13,30 @@ interface ThinkingContextType { const ThinkingContext = createContext(undefined); +interface ThinkingProviderInnerProps { + thinkingKey: string; + children: ReactNode; +} + +const ThinkingProviderInner: React.FC = (props) => { + const [thinkingLevel, setThinkingLevel] = usePersistedState( + props.thinkingKey, + "off", + { listener: true } + ); + + // Memoize context value to prevent unnecessary re-renders of consumers + const contextValue = useMemo( + () => ({ thinkingLevel, setThinkingLevel }), + [thinkingLevel, setThinkingLevel] + ); + + return ( + + {props.children} + + ); +}; interface ThinkingProviderProps { workspaceId?: string; // For existing workspaces projectPath?: string; // For workspace creation (uses project-scoped model key) @@ -38,27 +62,24 @@ export const ThinkingProvider: React.FC = ({ const defaultModel = getDefaultModel(); const modelKey = useModelKey(workspaceId, projectPath); - // Subscribe to model changes so we update thinking level when model changes + // Subscribe to model changes so we update thinking level when model changes. + // This uses a fallback key to satisfy hooks rules; it should be unused in practice + // because ThinkingProvider is expected to have either workspaceId or projectPath. const [rawModel] = usePersistedState(modelKey ?? "model:__unused__", defaultModel, { listener: true, }); - // Derive the thinking level key from the current model const thinkingKey = useMemo(() => { const model = migrateGatewayModel(rawModel || defaultModel); return getThinkingLevelByModelKey(model); }, [rawModel, defaultModel]); - const [thinkingLevel, setThinkingLevel] = usePersistedState( - thinkingKey, - "off", - { listener: true } // Listen for changes from command palette and other sources - ); - + // Key the inner provider by thinkingKey so switching models re-mounts and + // synchronously reads the new key's value (avoids one-render stale state). return ( - + {children} - + ); }; From 95e256a2d66873b7112e5d7340d8f5b875fa0cc1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 21:15:38 -0600 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20make=20usePersis?= =?UTF-8?q?tedState=20external-store=20backed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rework usePersistedState to use React 18 useSyncExternalStore - Cache localStorage snapshots by raw string to prevent render loops - Remove ThinkingProvider remount workaround (prevents Review/diff refresh) - Add regression tests for model switching + no child remount --- _Generated with • Model: • Thinking: _ --- src/browser/contexts/ThinkingContext.test.tsx | 70 +++-- src/browser/contexts/ThinkingContext.tsx | 40 +-- src/browser/hooks/usePersistedState.ts | 242 +++++++++--------- 3 files changed, 184 insertions(+), 168 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 5d84405d54..927974e499 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -1,4 +1,11 @@ import { GlobalWindow } from "happy-dom"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import React from "react"; +import { ThinkingProvider } from "./ThinkingContext"; +import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; +import { getModelKey, getThinkingLevelByModelKey } from "@/common/constants/storage"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; // Setup basic DOM environment for testing-library const dom = new GlobalWindow(); @@ -10,28 +17,9 @@ const dom = new GlobalWindow(); (globalThis as any).StorageEvent = dom.window.StorageEvent; (globalThis as any).CustomEvent = dom.window.CustomEvent; -// happy-dom's requestAnimationFrame behavior can vary; ensure it's present so -// usePersistedState listener updates (which batch via RAF) are flushed. -if (!globalThis.requestAnimationFrame) { - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => - setTimeout(() => cb(Date.now()), 0) as unknown as number; -} -if (!globalThis.cancelAnimationFrame) { - globalThis.cancelAnimationFrame = (id: number) => { - clearTimeout(id as unknown as NodeJS.Timeout); - }; -} (global as any).console = console; /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { act, cleanup, render, waitFor } from "@testing-library/react"; -import React from "react"; -import { ThinkingProvider } from "./ThinkingContext"; -import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; -import { getModelKey, getThinkingLevelByModelKey } from "@/common/constants/storage"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; - interface TestProps { workspaceId: string; } @@ -48,17 +36,55 @@ const TestComponent: React.FC = (props) => { describe("ThinkingContext", () => { // Make getDefaultModel deterministic. // (getDefaultModel reads from the global "model-default" localStorage key.) - beforeEach(() => { - window.localStorage.setItem("model-default", JSON.stringify("openai:default")); - }); beforeEach(() => { window.localStorage.clear(); + window.localStorage.setItem("model-default", JSON.stringify("openai:default")); }); afterEach(() => { cleanup(); }); + test("switching models does not remount children", async () => { + const workspaceId = "ws-1"; + + updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2"); + updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high"); + updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low"); + + let unmounts = 0; + + const Child: React.FC = () => { + React.useEffect(() => { + return () => { + unmounts += 1; + }; + }, []); + + const [thinkingLevel] = useThinkingLevel(); + return
{thinkingLevel}
; + }; + + const view = render( + + + + ); + + await waitFor(() => { + expect(view.getByTestId("child").textContent).toBe("high"); + }); + + act(() => { + updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5"); + }); + + await waitFor(() => { + expect(view.getByTestId("child").textContent).toBe("low"); + }); + + expect(unmounts).toBe(0); + }); test("switching models restores the per-model thinking level", async () => { const workspaceId = "ws-1"; diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 23cf6cb764..a587432545 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -13,30 +13,6 @@ interface ThinkingContextType { const ThinkingContext = createContext(undefined); -interface ThinkingProviderInnerProps { - thinkingKey: string; - children: ReactNode; -} - -const ThinkingProviderInner: React.FC = (props) => { - const [thinkingLevel, setThinkingLevel] = usePersistedState( - props.thinkingKey, - "off", - { listener: true } - ); - - // Memoize context value to prevent unnecessary re-renders of consumers - const contextValue = useMemo( - () => ({ thinkingLevel, setThinkingLevel }), - [thinkingLevel, setThinkingLevel] - ); - - return ( - - {props.children} - - ); -}; interface ThinkingProviderProps { workspaceId?: string; // For existing workspaces projectPath?: string; // For workspace creation (uses project-scoped model key) @@ -74,13 +50,17 @@ export const ThinkingProvider: React.FC = ({ return getThinkingLevelByModelKey(model); }, [rawModel, defaultModel]); - // Key the inner provider by thinkingKey so switching models re-mounts and - // synchronously reads the new key's value (avoids one-render stale state). - return ( - - {children} - + const [thinkingLevel, setThinkingLevel] = usePersistedState(thinkingKey, "off", { + listener: true, + }); + + // Memoize context value to prevent unnecessary re-renders of consumers. + const contextValue = useMemo( + () => ({ thinkingLevel, setThinkingLevel }), + [thinkingLevel, setThinkingLevel] ); + + return {children}; }; export const useThinking = () => { diff --git a/src/browser/hooks/usePersistedState.ts b/src/browser/hooks/usePersistedState.ts index 2b991fc8d9..97878389a1 100644 --- a/src/browser/hooks/usePersistedState.ts +++ b/src/browser/hooks/usePersistedState.ts @@ -1,8 +1,58 @@ import type { Dispatch, SetStateAction } from "react"; -import { useState, useCallback, useEffect, useRef } from "react"; +import { useCallback, useRef, useSyncExternalStore } from "react"; import { getStorageChangeEvent } from "@/common/constants/events"; type SetValue = T | ((prev: T) => T); + +interface Subscriber { + callback: () => void; + componentId: string; + listener: boolean; +} + +const subscribersByKey = new Map>(); + +function addSubscriber(key: string, subscriber: Subscriber): () => void { + const subs = subscribersByKey.get(key) ?? new Set(); + subs.add(subscriber); + subscribersByKey.set(key, subs); + + return () => { + const current = subscribersByKey.get(key); + if (!current) return; + current.delete(subscriber); + if (current.size === 0) { + subscribersByKey.delete(key); + } + }; +} + +function notifySubscribers(key: string, origin?: string) { + const subs = subscribersByKey.get(key); + if (!subs) return; + + for (const sub of subs) { + // If listener=false, only react to updates originating from this hook instance. + if (!sub.listener) { + if (!origin || origin !== sub.componentId) continue; + } + sub.callback(); + } +} + +let storageListenerInstalled = false; +function ensureStorageListenerInstalled() { + if (storageListenerInstalled) return; + if (typeof window === "undefined") return; + + window.addEventListener("storage", (e: StorageEvent) => { + if (!e.key) return; + // Cross-tab update: only listener=true subscribers should react. + notifySubscribers(e.key); + }); + + storageListenerInstalled = true; +} /** * Read a persisted state value from localStorage (non-hook version) * Mirrors the reading logic from usePersistedState @@ -60,8 +110,11 @@ export function updatePersistedState( window.localStorage.setItem(key, JSON.stringify(newValue)); } - // Dispatch custom event for same-tab synchronization - // No origin since this is an external update - all listeners should receive it + // Notify same-tab subscribers (usePersistedState) immediately. + notifySubscribers(key); + + // Dispatch custom event for same-tab synchronization for non-hook listeners. + // No origin since this is an external update - all listeners should receive it. const customEvent = new CustomEvent(getStorageChangeEvent(key), { detail: { key, newValue }, }); @@ -90,144 +143,101 @@ export function usePersistedState( initialValue: T, options?: UsePersistedStateOptions ): [T, Dispatch>] { - // Unique component ID to prevent echo when listening to own updates + // Unique component ID for distinguishing self-updates. const componentIdRef = useRef(Math.random().toString(36)); - // Lazy initialization - only runs on first render - const [state, setState] = useState(() => { - // Handle SSR and environments without localStorage + ensureStorageListenerInstalled(); + + const subscribe = useCallback( + (callback: () => void) => { + return addSubscriber(key, { + callback, + componentId: componentIdRef.current, + listener: Boolean(options?.listener), + }); + }, + [key, options?.listener] + ); + + // Match the previous `usePersistedState` behavior: `initialValue` is only used + // as the default when no value is stored; changes to `initialValue` should not + // reinitialize state. + const initialValueRef = useRef(initialValue); + + // useSyncExternalStore requires getSnapshot() to be referentially stable when + // the underlying store value is unchanged. Since localStorage values are JSON, + // we cache the parsed value by raw string. + const snapshotRef = useRef<{ key: string; raw: string | null; value: T } | null>(null); + + const getSnapshot = useCallback((): T => { if (typeof window === "undefined" || !window.localStorage) { - return initialValue; + return initialValueRef.current; } try { - const storedValue = window.localStorage.getItem(key); - if (storedValue === null) { - return initialValue; + const raw = window.localStorage.getItem(key); + + if (raw === null || raw === "undefined") { + if (snapshotRef.current?.key === key && snapshotRef.current.raw === null) { + return snapshotRef.current.value; + } + + snapshotRef.current = { + key, + raw: null, + value: initialValueRef.current, + }; + + return initialValueRef.current; } - // Handle 'undefined' string case - if (storedValue === "undefined") { - return initialValue; + if (snapshotRef.current?.key === key && snapshotRef.current.raw === raw) { + return snapshotRef.current.value; } - return JSON.parse(storedValue) as T; + const parsed = JSON.parse(raw) as T; + snapshotRef.current = { key, raw, value: parsed }; + return parsed; } catch (error) { console.warn(`Error reading localStorage key "${key}":`, error); - return initialValue; + return initialValueRef.current; } - }); + }, [key]); - // Re-initialize state when key changes (e.g., when switching workspaces) - useEffect(() => { - if (typeof window === "undefined" || !window.localStorage) { - return; - } - - try { - const storedValue = window.localStorage.getItem(key); - if (storedValue === null || storedValue === "undefined") { - setState(initialValue); - return; - } + const getServerSnapshot = useCallback(() => initialValueRef.current, []); - const parsedValue = JSON.parse(storedValue) as T; - setState(parsedValue); - } catch (error) { - console.warn(`Error reading localStorage key "${key}" on key change:`, error); - setState(initialValue); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key]); // Only depend on key, not initialValue (to avoid infinite loops) + const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - // Enhanced setState that supports functional updates const setPersistedState = useCallback( (value: SetValue) => { - setState((prevState) => { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + try { + const prevState = readPersistedState(key, initialValueRef.current); const newValue = value instanceof Function ? value(prevState) : value; - // Write to localStorage synchronously to ensure data persists - // even if app closes immediately after (e.g., Electron quit, crash). - // This fixes race condition where queueMicrotask deferred writes could be lost. - if (typeof window !== "undefined" && window.localStorage) { - try { - if (newValue === undefined || newValue === null) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, JSON.stringify(newValue)); - } - - // Dispatch custom event for same-tab synchronization - // Include origin marker to prevent echo - const customEvent = new CustomEvent(getStorageChangeEvent(key), { - detail: { key, newValue, origin: componentIdRef.current }, - }); - window.dispatchEvent(customEvent); - } catch (error) { - console.warn(`Error writing to localStorage key "${key}":`, error); - } + if (newValue === undefined || newValue === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(newValue)); } - return newValue; - }); + // Notify hook subscribers synchronously (keeps UI responsive). + notifySubscribers(key, componentIdRef.current); + + // Dispatch custom event for same-tab synchronization for non-hook listeners. + const customEvent = new CustomEvent(getStorageChangeEvent(key), { + detail: { key, newValue, origin: componentIdRef.current }, + }); + window.dispatchEvent(customEvent); + } catch (error) { + console.warn(`Error writing to localStorage key "${key}":`, error); + } }, [key] ); - // Listen for storage changes when listener option is enabled - useEffect(() => { - if (!options?.listener) return; - - let rafId: number | null = null; - - const handleStorageChange = (e: Event) => { - // Cancel any pending update - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - - // Batch update to next animation frame to prevent jittery scroll - rafId = requestAnimationFrame(() => { - rafId = null; - - if (e instanceof StorageEvent) { - // Cross-tab storage event - if (e.key === key && e.newValue !== null) { - try { - const newValue = JSON.parse(e.newValue) as T; - setState(newValue); - } catch (error) { - console.warn(`Error parsing storage event for key "${key}":`, error); - } - } - } else if (e instanceof CustomEvent) { - // Same-tab custom event - const detail = e.detail as { key: string; newValue: T; origin?: string }; - if (detail.key === key) { - // Skip if this update originated from this component (prevent echo) - if (detail.origin && detail.origin === componentIdRef.current) { - return; - } - setState(detail.newValue); - } - } - }); - }; - - // Listen to both storage events (cross-tab) and custom events (same-tab) - const storageChangeEvent = getStorageChangeEvent(key); - window.addEventListener("storage", handleStorageChange); - window.addEventListener(storageChangeEvent, handleStorageChange); - - return () => { - // Cancel pending animation frame - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - window.removeEventListener("storage", handleStorageChange); - window.removeEventListener(storageChangeEvent, handleStorageChange); - }; - }, [key, options?.listener]); - return [state, setPersistedState]; }