From f37340afd8ee0ee568de8858233529c7aa991005 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 12:28:20 +0000 Subject: [PATCH 1/7] fix(web): a settings change in one tab no longer reverts another tab's change Co-Authored-By: Claude Opus 5.5 --- apps/web/src/clientPersistenceStorage.ts | 15 +++++++++++++- apps/web/src/hooks/useSettings.test.ts | 16 +++++++++++++++ apps/web/src/hooks/useSettings.ts | 26 +++++++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index e1c1459facb36..a050c1b749681 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -1,6 +1,10 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; -import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; +import { + getLocalStorageItem, + setLocalStorageItem, + subscribeLocalStorageKey, +} from "./hooks/useLocalStorage"; const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; @@ -23,3 +27,12 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); } + +/** Fires when another tab saves client settings. */ +export function subscribeBrowserClientSettings(onChange: () => void): () => void { + if (!hasWindow()) { + return () => undefined; + } + + return subscribeLocalStorageKey(CLIENT_SETTINGS_STORAGE_KEY, onChange); +} diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 59b183aed6239..3e85e1b494e60 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -31,6 +31,8 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); }); describe("client settings hydration", () => { @@ -195,6 +197,20 @@ describe("persistClientSettingsPatch", () => { expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(1, firstSettings); expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(2, secondSettings); }); + + it("keeps a setting another tab saved after this tab loaded", async () => { + vi.stubGlobal("window", new EventTarget()); + await ensureClientSettingsHydrated(); + const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + persistenceMocks.getClientSettings.mockResolvedValue(otherTabSettings); + window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + + await persistClientSettingsPatch({ wordWrap: false }); + + const expected = { ...otherTabSettings, wordWrap: false }; + expect(persistenceMocks.setClientSettings).toHaveBeenLastCalledWith(expected); + expect(getClientSettings()).toEqual(expected); + }); }); describe("persistClientSettingsUpdate", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2c24f13760092..31b535f06c6d4 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -30,6 +30,7 @@ import { splitSharedServerPatch, supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; +import { subscribeBrowserClientSettings } from "~/clientPersistenceStorage"; import { ensureLocalApi } from "~/localApi"; import { getThemeDefinition, @@ -75,6 +76,7 @@ let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; let clientSettingsPersistenceQueue: Promise = Promise.resolve(); let deferredClientSettingsPatchCount = 0; +let stopReloadingClientSettings: (() => void) | null = null; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -153,6 +155,7 @@ async function hydrateClientSettings(): Promise { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } setClientSettingsHydrationStatus("ready"); + stopReloadingClientSettings ??= subscribeBrowserClientSettings(reloadClientSettings); } catch (error) { if (hydrationGeneration === clientSettingsHydrationGeneration) { setClientSettingsHydrationStatus("failed"); @@ -187,11 +190,30 @@ function enqueueClientSettingsPersistence(work: () => Promise): Promise return result; } +function reloadClientSettings(): void { + deferredClientSettingsPatchCount += 1; + void enqueueClientSettingsPersistence(async () => { + try { + const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + if (persistedSettings) { + replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); + } + } finally { + deferredClientSettingsPatchCount -= 1; + } + }).catch((error) => { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} reload failed`, { + operation: "reload", + ...safeErrorLogAttributes(error), + }); + }); +} + export function persistClientSettingsPatch( patch: ClientSettingsPatch, persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { - // Patches queued before hydration must publish before newer optimistic patches. + // Patches queued behind hydration or a reload must publish before newer optimistic patches. const deferPatch = clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; if (deferPatch) { @@ -733,6 +755,8 @@ export function updateClientSettings(patch: ClientSettingsPatch): Promise export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; + stopReloadingClientSettings?.(); + stopReloadingClientSettings = null; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrationStatus = "pending"; clientSettingsHydrationPromise = null; From d9c18c647a9cbdf9b717b8a4c9a2d0a1c0752973 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 12:44:13 +0000 Subject: [PATCH 2/7] refactor(web): share the saved client settings read between hydration and reload Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useSettings.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 31b535f06c6d4..e9e77cee2d1de 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -131,6 +131,11 @@ export function subscribeClientSettingsHydration(listener: () => void): () => vo }; } +async function readPersistedClientSettings(): Promise { + const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + return persistedSettings ? { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings } : null; +} + async function hydrateClientSettings(): Promise { if (clientSettingsHydrationStatus === "ready") { return; @@ -147,12 +152,12 @@ async function hydrateClientSettings(): Promise { ); const nextHydration = (async () => { try { - const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + const persistedSettings = await readPersistedClientSettings(); if (hydrationGeneration !== clientSettingsHydrationGeneration) { return; } if (persistedSettings) { - replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); + replaceClientSettingsSnapshot(persistedSettings); } setClientSettingsHydrationStatus("ready"); stopReloadingClientSettings ??= subscribeBrowserClientSettings(reloadClientSettings); @@ -194,9 +199,9 @@ function reloadClientSettings(): void { deferredClientSettingsPatchCount += 1; void enqueueClientSettingsPersistence(async () => { try { - const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + const persistedSettings = await readPersistedClientSettings(); if (persistedSettings) { - replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); + replaceClientSettingsSnapshot(persistedSettings); } } finally { deferredClientSettingsPatchCount -= 1; From 7333bd5245dfdb3ae599e47be2b479f4fb4c54bb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 13:17:43 +0000 Subject: [PATCH 3/7] fix(web): re-read client settings before writing after a failed reload If reloading the settings another tab saved failed, the next patch or update wrote this tab's stale snapshot over them. A storage event now marks the snapshot stale until a read succeeds, and both write paths re-read first; if that read fails they fail as they do when hydration fails, without writing. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useSettings.test.ts | 41 ++++++++++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 41 +++++++++++++++----------- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 3e85e1b494e60..dbc51f1f9219d 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -211,6 +211,26 @@ describe("persistClientSettingsPatch", () => { expect(persistenceMocks.setClientSettings).toHaveBeenLastCalledWith(expected); expect(getClientSettings()).toEqual(expected); }); + + it("writes only after re-reading settings another tab saved when the reload failed", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.stubGlobal("window", new EventTarget()); + await ensureClientSettingsHydrated(); + const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + persistenceMocks.getClientSettings + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockResolvedValue(otherTabSettings); + window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + + await persistClientSettingsPatch({ wordWrap: false }); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + await persistClientSettingsPatch({ snapShotFlash: false }); + const expected = { ...otherTabSettings, snapShotFlash: false }; + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(expected); + expect(getClientSettings()).toEqual(expected); + }); }); describe("persistClientSettingsUpdate", () => { @@ -236,6 +256,27 @@ describe("persistClientSettingsUpdate", () => { expect(getClientSettings().timestampFormat).toBe("12-hour"); }); + it("rejects after a failed reload until settings another tab saved can be re-read", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.stubGlobal("window", new EventTarget()); + await ensureClientSettingsHydrated(); + const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + persistenceMocks.getClientSettings + .mockRejectedValueOnce(failure) + .mockRejectedValueOnce(failure) + .mockResolvedValue(otherTabSettings); + window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + const disableWrap = (current: ClientSettings) => ({ ...current, wordWrap: false }); + + await expect(persistClientSettingsUpdate(disableWrap)).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + const expected = { ...otherTabSettings, wordWrap: false }; + await expect(persistClientSettingsUpdate(disableWrap)).resolves.toEqual(expected); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(expected); + }); + it("preserves an optimistic write made while an awaited update persists", async () => { let finishFirstPersistence!: () => void; let durableSettings = DEFAULT_CLIENT_SETTINGS; diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e9e77cee2d1de..0598e54e35935 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -76,6 +76,8 @@ let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; let clientSettingsPersistenceQueue: Promise = Promise.resolve(); let deferredClientSettingsPatchCount = 0; +// Set when another tab saves; cleared only by a successful re-read, so writes never use a stale snapshot. +let clientSettingsStale = false; let stopReloadingClientSettings: (() => void) | null = null; function emitClientSettingsChange() { @@ -195,18 +197,22 @@ function enqueueClientSettingsPersistence(work: () => Promise): Promise return result; } -function reloadClientSettings(): void { - deferredClientSettingsPatchCount += 1; - void enqueueClientSettingsPersistence(async () => { - try { - const persistedSettings = await readPersistedClientSettings(); - if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); - } - } finally { - deferredClientSettingsPatchCount -= 1; +async function ensureFreshClientSettings(): Promise { + if (clientSettingsHydrationStatus !== "ready") { + return hydrateClientSettings(); + } + if (clientSettingsStale) { + const persistedSettings = await readPersistedClientSettings(); + if (persistedSettings) { + replaceClientSettingsSnapshot(persistedSettings); } - }).catch((error) => { + clientSettingsStale = false; + } +} + +function reloadClientSettings(): void { + clientSettingsStale = true; + void enqueueClientSettingsPersistence(ensureFreshClientSettings).catch((error) => { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} reload failed`, { operation: "reload", ...safeErrorLogAttributes(error), @@ -220,7 +226,9 @@ export function persistClientSettingsPatch( ): Promise { // Patches queued behind hydration or a reload must publish before newer optimistic patches. const deferPatch = - clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; + clientSettingsHydrationStatus !== "ready" || + clientSettingsStale || + deferredClientSettingsPatchCount > 0; if (deferPatch) { deferredClientSettingsPatchCount += 1; } else { @@ -229,9 +237,7 @@ export function persistClientSettingsPatch( return enqueueClientSettingsPersistence(async () => { if (deferPatch) { try { - if (clientSettingsHydrationStatus !== "ready") { - await hydrateClientSettings(); - } + await ensureFreshClientSettings(); replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); } finally { deferredClientSettingsPatchCount -= 1; @@ -252,8 +258,8 @@ export async function persistClientSettingsUpdate( persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { return enqueueClientSettingsPersistence(async () => { - if (clientSettingsHydrationStatus !== "ready") { - await hydrateClientSettings(); + if (clientSettingsHydrationStatus !== "ready" || clientSettingsStale) { + await ensureFreshClientSettings(); } for (;;) { const current = getClientSettingsSnapshot(); @@ -762,6 +768,7 @@ export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; stopReloadingClientSettings?.(); stopReloadingClientSettings = null; + clientSettingsStale = false; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrationStatus = "pending"; clientSettingsHydrationPromise = null; From a3fdc315d8bfa9ab55d25a492507f95cc6f0f34e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 13:23:23 +0000 Subject: [PATCH 4/7] refactor(web): always gate settings writes on the freshness check Call ensureFreshClientSettings unconditionally in updates, wait for the first persist to start instead of one microtask in the overlap test, and share the other-tab save setup across the cross-tab tests. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useSettings.test.ts | 57 +++++++++++++------------- apps/web/src/hooks/useSettings.ts | 4 +- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index dbc51f1f9219d..8365f93d23afe 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -35,6 +35,19 @@ afterEach(() => { vi.unstubAllGlobals(); }); +const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; +const readFailure = new Error("storage unavailable"); + +async function saveInOtherTab(failedReads = 0) { + vi.stubGlobal("window", new EventTarget()); + await ensureClientSettingsHydrated(); + for (let read = 0; read < failedReads; read += 1) { + persistenceMocks.getClientSettings.mockRejectedValueOnce(readFailure); + } + persistenceMocks.getClientSettings.mockResolvedValue(otherTabSettings); + window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); +} + describe("client settings hydration", () => { const savedSettings = { ...DEFAULT_CLIENT_SETTINGS, @@ -199,11 +212,7 @@ describe("persistClientSettingsPatch", () => { }); it("keeps a setting another tab saved after this tab loaded", async () => { - vi.stubGlobal("window", new EventTarget()); - await ensureClientSettingsHydrated(); - const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; - persistenceMocks.getClientSettings.mockResolvedValue(otherTabSettings); - window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + await saveInOtherTab(); await persistClientSettingsPatch({ wordWrap: false }); @@ -214,14 +223,8 @@ describe("persistClientSettingsPatch", () => { it("writes only after re-reading settings another tab saved when the reload failed", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.stubGlobal("window", new EventTarget()); - await ensureClientSettingsHydrated(); - const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; - persistenceMocks.getClientSettings - .mockRejectedValueOnce(new Error("storage unavailable")) - .mockRejectedValueOnce(new Error("storage unavailable")) - .mockResolvedValue(otherTabSettings); - window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + // Fails the reload and the first write's re-read. + await saveInOtherTab(2); await persistClientSettingsPatch({ wordWrap: false }); expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); @@ -257,19 +260,12 @@ describe("persistClientSettingsUpdate", () => { }); it("rejects after a failed reload until settings another tab saved can be re-read", async () => { - const failure = new Error("storage unavailable"); vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.stubGlobal("window", new EventTarget()); - await ensureClientSettingsHydrated(); - const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; - persistenceMocks.getClientSettings - .mockRejectedValueOnce(failure) - .mockRejectedValueOnce(failure) - .mockResolvedValue(otherTabSettings); - window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + // Fails the reload and the first write's re-read. + await saveInOtherTab(2); const disableWrap = (current: ClientSettings) => ({ ...current, wordWrap: false }); - await expect(persistClientSettingsUpdate(disableWrap)).rejects.toBe(failure); + await expect(persistClientSettingsUpdate(disableWrap)).rejects.toBe(readFailure); expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); const expected = { ...otherTabSettings, wordWrap: false }; @@ -283,13 +279,18 @@ describe("persistClientSettingsUpdate", () => { const firstPersistence = new Promise((resolve) => { finishFirstPersistence = resolve; }); + let markFirstPersistenceStarted!: () => void; + const firstPersistenceStarted = new Promise((resolve) => { + markFirstPersistenceStarted = resolve; + }); const persist = vi .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise>() - .mockImplementationOnce((settings) => - firstPersistence.then(() => { + .mockImplementationOnce((settings) => { + markFirstPersistenceStarted(); + return firstPersistence.then(() => { durableSettings = settings; - }), - ) + }); + }) .mockImplementation(async (settings) => { durableSettings = settings; }); @@ -303,7 +304,7 @@ describe("persistClientSettingsUpdate", () => { }), persist, ); - await Promise.resolve(); + await firstPersistenceStarted; const patch = persistClientSettingsPatch({ wordWrap: false }, persist); finishFirstPersistence(); await Promise.all([pending, patch]); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 0598e54e35935..ff5b6d605764d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -258,9 +258,7 @@ export async function persistClientSettingsUpdate( persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { return enqueueClientSettingsPersistence(async () => { - if (clientSettingsHydrationStatus !== "ready" || clientSettingsStale) { - await ensureFreshClientSettings(); - } + await ensureFreshClientSettings(); for (;;) { const current = getClientSettingsSnapshot(); const next = update(current); From 3f9bb80cbf17592a125b63e3106f8146df2c7b17 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 09:40:48 +0000 Subject: [PATCH 5/7] fix(web): reset client settings to defaults when another tab clears them A reload after another tab removed the saved settings read nothing and kept this tab's old snapshot, so the next write restored the cleared values. The stale reload now falls back to the defaults. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useSettings.test.ts | 14 +++++++++----- apps/web/src/hooks/useSettings.ts | 5 +---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 8365f93d23afe..eda50c63c88db 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -38,13 +38,13 @@ afterEach(() => { const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; const readFailure = new Error("storage unavailable"); -async function saveInOtherTab(failedReads = 0) { +async function saveInOtherTab(failedReads = 0, saved: ClientSettings | null = otherTabSettings) { vi.stubGlobal("window", new EventTarget()); await ensureClientSettingsHydrated(); for (let read = 0; read < failedReads; read += 1) { persistenceMocks.getClientSettings.mockRejectedValueOnce(readFailure); } - persistenceMocks.getClientSettings.mockResolvedValue(otherTabSettings); + persistenceMocks.getClientSettings.mockResolvedValue(saved); window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); } @@ -211,12 +211,16 @@ describe("persistClientSettingsPatch", () => { expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(2, secondSettings); }); - it("keeps a setting another tab saved after this tab loaded", async () => { - await saveInOtherTab(); + it.each([ + { change: "saved", loaded: null, saved: otherTabSettings }, + { change: "cleared", loaded: otherTabSettings, saved: null }, + ])("follows settings another tab $change after this tab loaded", async ({ loaded, saved }) => { + persistenceMocks.getClientSettings.mockResolvedValueOnce(loaded); + await saveInOtherTab(0, saved); await persistClientSettingsPatch({ wordWrap: false }); - const expected = { ...otherTabSettings, wordWrap: false }; + const expected = { ...(saved ?? DEFAULT_CLIENT_SETTINGS), wordWrap: false }; expect(persistenceMocks.setClientSettings).toHaveBeenLastCalledWith(expected); expect(getClientSettings()).toEqual(expected); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index ff5b6d605764d..0f4b60c5c9c0e 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -202,10 +202,7 @@ async function ensureFreshClientSettings(): Promise { return hydrateClientSettings(); } if (clientSettingsStale) { - const persistedSettings = await readPersistedClientSettings(); - if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); - } + replaceClientSettingsSnapshot((await readPersistedClientSettings()) ?? DEFAULT_CLIENT_SETTINGS); clientSettingsStale = false; } } From dc8f890be48e6788338d0f42ff67300bdc65273c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 09:44:35 +0000 Subject: [PATCH 6/7] refactor(web): fall back to default client settings in the shared read Both callers of the saved settings read now get the defaults when nothing is stored, so neither can keep an outdated snapshot. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useSettings.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 0f4b60c5c9c0e..cafd8814c6c6b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -133,9 +133,11 @@ export function subscribeClientSettingsHydration(listener: () => void): () => vo }; } -async function readPersistedClientSettings(): Promise { +async function readPersistedClientSettings(): Promise { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); - return persistedSettings ? { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings } : null; + return persistedSettings + ? { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings } + : DEFAULT_CLIENT_SETTINGS; } async function hydrateClientSettings(): Promise { @@ -158,9 +160,7 @@ async function hydrateClientSettings(): Promise { if (hydrationGeneration !== clientSettingsHydrationGeneration) { return; } - if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); - } + replaceClientSettingsSnapshot(persistedSettings); setClientSettingsHydrationStatus("ready"); stopReloadingClientSettings ??= subscribeBrowserClientSettings(reloadClientSettings); } catch (error) { @@ -202,7 +202,7 @@ async function ensureFreshClientSettings(): Promise { return hydrateClientSettings(); } if (clientSettingsStale) { - replaceClientSettingsSnapshot((await readPersistedClientSettings()) ?? DEFAULT_CLIENT_SETTINGS); + replaceClientSettingsSnapshot(await readPersistedClientSettings()); clientSettingsStale = false; } } From 8c04ef331ce280afd07feab46426703a1a74a468 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 10:44:46 +0000 Subject: [PATCH 7/7] fix(web): reload local storage subscribers when another tab clears storage localStorage.clear() in another tab sends a storage event with a null key, which subscribeLocalStorageKey ignored. A tab then kept its old client settings, and its next change wrote them back. Subscribers now treat a null key as a change. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useLocalStorage.ts | 3 ++- apps/web/src/hooks/useSettings.test.ts | 37 +++++++++++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 5c172287131af..3483155bdb6c7 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -134,7 +134,8 @@ export function readLocalStorageValue( /** Fires on writes from this tab and from other tabs. */ export function subscribeLocalStorageKey(key: string, onChange: () => void): () => void { const handleStorageChange = (event: StorageEvent) => { - if (event.key === key) { + // Storage events carry a null key after clear(). + if (event.key === key || event.key === null) { onChange(); } }; diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index eda50c63c88db..8533c6bfb7fa8 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -38,14 +38,18 @@ afterEach(() => { const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; const readFailure = new Error("storage unavailable"); -async function saveInOtherTab(failedReads = 0, saved: ClientSettings | null = otherTabSettings) { +async function saveInOtherTab( + failedReads = 0, + saved: ClientSettings | null = otherTabSettings, + key: string | null = "t3code:client-settings:v1", +) { vi.stubGlobal("window", new EventTarget()); await ensureClientSettingsHydrated(); for (let read = 0; read < failedReads; read += 1) { persistenceMocks.getClientSettings.mockRejectedValueOnce(readFailure); } persistenceMocks.getClientSettings.mockResolvedValue(saved); - window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" })); + window.dispatchEvent(Object.assign(new Event("storage"), { key })); } describe("client settings hydration", () => { @@ -214,16 +218,25 @@ describe("persistClientSettingsPatch", () => { it.each([ { change: "saved", loaded: null, saved: otherTabSettings }, { change: "cleared", loaded: otherTabSettings, saved: null }, - ])("follows settings another tab $change after this tab loaded", async ({ loaded, saved }) => { - persistenceMocks.getClientSettings.mockResolvedValueOnce(loaded); - await saveInOtherTab(0, saved); - - await persistClientSettingsPatch({ wordWrap: false }); - - const expected = { ...(saved ?? DEFAULT_CLIENT_SETTINGS), wordWrap: false }; - expect(persistenceMocks.setClientSettings).toHaveBeenLastCalledWith(expected); - expect(getClientSettings()).toEqual(expected); - }); + { + change: "cleared with localStorage.clear()", + loaded: otherTabSettings, + saved: null, + key: null, + }, + ])( + "follows settings another tab $change after this tab loaded", + async ({ loaded, saved, key }) => { + persistenceMocks.getClientSettings.mockResolvedValueOnce(loaded); + await saveInOtherTab(0, saved, key); + + await persistClientSettingsPatch({ wordWrap: false }); + + const expected = { ...(saved ?? DEFAULT_CLIENT_SETTINGS), wordWrap: false }; + expect(persistenceMocks.setClientSettings).toHaveBeenLastCalledWith(expected); + expect(getClientSettings()).toEqual(expected); + }, + ); it("writes only after re-reading settings another tab saved when the reload failed", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined);