From 09d12679fa528fcc6b49b9e93b169ee70b6b8478 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sun, 6 Sep 2026 18:30:19 +0200 Subject: [PATCH] fix: sync useLocalStorage across same-tab consumers of one key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the Notes app is explicitly non-singleton, so opening two "New Note" windows meant two DraftNote instances writing the same localStorage key independently — the native `storage` event only fires in *other* tabs/documents, never the one that wrote, so the two windows would silently diverge (discard/publish in one wouldn't update the other). useLocalStorage now also dispatches a same-document custom event on every write, and every instance sharing that key listens for it — verified live with two open draft windows staying in sync as one is typed into. Also dropped a redundant `{}` params argument on an openApp() call that every other call site omits when opening with no parameters. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --- src/apps/feed/index.tsx | 2 +- src/hooks/useLocalStorage.ts | 67 +++++++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/apps/feed/index.tsx b/src/apps/feed/index.tsx index d3d61a9..13d5f39 100644 --- a/src/apps/feed/index.tsx +++ b/src/apps/feed/index.tsx @@ -91,7 +91,7 @@ export default function FeedApp({ setTitle }: AppProps) { variant="ghost" size="sm" className="h-7 gap-1.5 px-2 text-xs" - onClick={() => openApp('notes', {})} + onClick={() => openApp('notes')} > New note diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 73c2959..01966dc 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -1,4 +1,19 @@ -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * Fired on `window` whenever `useLocalStorage` writes a key, so every + * component sharing that key *within this document* stays in sync — the + * native `storage` event only fires in *other* tabs/documents, never the + * one that made the write. Without this, e.g. two "New Note" draft windows + * open at once would silently diverge: each holds its own React state, both + * write to the same localStorage entry, and neither sees the other's edits. + */ +const LOCAL_STORAGE_EVENT = 'app:local-storage'; + +interface LocalStorageEventDetail { + key: string; + value: string; +} /** * Generic hook for managing localStorage state @@ -24,17 +39,30 @@ export function useLocalStorage( } }); - const setValue = (value: T | ((prev: T) => T)) => { - try { - const valueToStore = value instanceof Function ? value(state) : value; - setState(valueToStore); - localStorage.setItem(key, serialize(valueToStore)); - } catch (error) { - console.warn(`Failed to save ${key} to localStorage:`, error); - } - }; + const setValue = useCallback( + (value: T | ((prev: T) => T)) => { + setState((prev) => { + try { + const valueToStore = value instanceof Function ? value(prev) : value; + const serialized = serialize(valueToStore); + localStorage.setItem(key, serialized); + window.dispatchEvent( + new CustomEvent(LOCAL_STORAGE_EVENT, { + detail: { key, value: serialized }, + }), + ); + return valueToStore; + } catch (error) { + console.warn(`Failed to save ${key} to localStorage:`, error); + return prev; + } + }); + }, + [key, serialize], + ); - // Sync with localStorage changes from other tabs + // Sync with localStorage changes from other tabs, and from other + // components sharing this key in this tab (see LOCAL_STORAGE_EVENT above). useEffect(() => { const handleStorageChange = (e: StorageEvent) => { if (e.key === key && e.newValue !== null) { @@ -45,10 +73,23 @@ export function useLocalStorage( } } }; + const handleLocalChange = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.key !== key) return; + try { + setState(deserialize(detail.value)); + } catch (error) { + console.warn(`Failed to sync ${key} from localStorage:`, error); + } + }; window.addEventListener('storage', handleStorageChange); - return () => window.removeEventListener('storage', handleStorageChange); + window.addEventListener(LOCAL_STORAGE_EVENT, handleLocalChange); + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener(LOCAL_STORAGE_EVENT, handleLocalChange); + }; }, [key, deserialize]); return [state, setValue] as const; -} \ No newline at end of file +}