fix(ui): let another tab's column save replace a toggle this tab could not save

A column toggle that localStorage refused was kept in memory and read ahead of storage, so a later save from another tab stayed hidden until this tab saved again. A storage event now drops the in-memory copy for its key, or all of them when another tab clears storage
This commit is contained in:
ryan-crabbe-berri 2026-09-16 15:43:21 -07:00
parent adc937c493
commit d29753c521
2 changed files with 45 additions and 3 deletions

View file

@ -109,8 +109,9 @@ describe("usePersistedColumnVisibility", () => {
});
it("applies new defaults passed after mount", () => {
const initialProps: { defaults: VisibilityState } = { defaults: { spend: false } };
const { result, rerender } = renderHook(({ defaults }) => usePersistedColumnVisibility("keys", defaults), {
initialProps: { defaults: { spend: false } },
initialProps,
});
rerender({ defaults: { name: false } });
@ -146,6 +147,35 @@ describe("usePersistedColumnVisibility", () => {
expect(stored("full")).toEqual({ name: false });
});
it("shows another tab's save over a toggle this tab could not save", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "setItem").mockImplementationOnce(() => {
throw new Error("QuotaExceededError");
});
const { result } = renderHook(() => usePersistedColumnVisibility("shadowed"));
act(() => result.current.onColumnVisibilityChange({ email: false }));
act(() => {
localStorage.setItem(keyFor("shadowed"), JSON.stringify({ name: false }));
window.dispatchEvent(new StorageEvent("storage", { key: keyFor("shadowed") }));
});
expect(result.current.columnVisibility).toEqual({ name: false });
});
it("drops a toggle this tab could not save once another tab clears storage", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "setItem").mockImplementationOnce(() => {
throw new Error("QuotaExceededError");
});
const { result } = renderHook(() => usePersistedColumnVisibility("cleared", { spend: false }));
act(() => result.current.onColumnVisibilityChange({ email: false }));
act(() => window.dispatchEvent(new StorageEvent("storage", { key: null })));
expect(result.current.columnVisibility).toEqual({ spend: false });
});
it("returns the defaults without throwing when storage is unavailable", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {

View file

@ -18,11 +18,23 @@ function storageKey(tableId: string): string {
return `${STORAGE_KEY_PREFIX}${tableId}`;
}
function forgetUnsavedWrite(event: StorageEvent): void {
if (event.key === null) {
unsavedWrites.clear();
return;
}
unsavedWrites.delete(event.key);
}
function subscribe(onChange: () => void): () => void {
window.addEventListener("storage", onChange);
const onStorage = (event: StorageEvent): void => {
forgetUnsavedWrite(event);
onChange();
};
window.addEventListener("storage", onStorage);
window.addEventListener(LOCAL_STORAGE_EVENT, onChange);
return () => {
window.removeEventListener("storage", onChange);
window.removeEventListener("storage", onStorage);
window.removeEventListener(LOCAL_STORAGE_EVENT, onChange);
};
}