Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion apps/loopover-ui/src/lib/use-local-storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { useLocalStorage } from "@/lib/use-local-storage";

function dispatchStorage(init: StorageEventInit) {
window.dispatchEvent(new StorageEvent("storage", init));
}

describe("useLocalStorage legacyKey migration (rebrand key rename)", () => {
beforeEach(() => {
window.localStorage.clear();
Expand Down Expand Up @@ -56,3 +60,82 @@ describe("useLocalStorage legacyKey migration (rebrand key rename)", () => {
expect(window.localStorage.getItem("new.key")).toBe(JSON.stringify("new-value"));
});
});

describe("useLocalStorage cross-tab storage sync", () => {
beforeEach(() => {
window.localStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it("updates value when another tab writes a valid JSON value for the hook's own key", async () => {
const { result } = renderHook(() => useLocalStorage<string>("solo.key", "initial"));
await waitFor(() => expect(result.current[2]).toBe(true));
expect(result.current[0]).toBe("initial");

act(() => {
dispatchStorage({ key: "solo.key", newValue: JSON.stringify("from-other-tab") });
});
expect(result.current[0]).toBe("from-other-tab");
});

it("ignores a storage event for a different key", async () => {
const { result } = renderHook(() => useLocalStorage<string>("solo.key", "initial"));
await waitFor(() => expect(result.current[2]).toBe(true));

act(() => {
dispatchStorage({ key: "other.key", newValue: JSON.stringify("nope") });
});
expect(result.current[0]).toBe("initial");
});

it("resets to initial when another tab removes the key (newValue null)", async () => {
window.localStorage.setItem("solo.key", JSON.stringify("present"));
const { result } = renderHook(() => useLocalStorage<string>("solo.key", "initial"));
await waitFor(() => expect(result.current[2]).toBe(true));
expect(result.current[0]).toBe("present");

act(() => {
dispatchStorage({ key: "solo.key", newValue: null });
});
expect(result.current[0]).toBe("initial");
});

it("ignores a malformed newValue without throwing", async () => {
const { result } = renderHook(() => useLocalStorage<string>("solo.key", "initial"));
await waitFor(() => expect(result.current[2]).toBe(true));

act(() => {
dispatchStorage({ key: "solo.key", newValue: "{not-json" });
});
expect(result.current[0]).toBe("initial");
});

it("honors a storage event for the configured legacyKey", async () => {
const { result } = renderHook(() =>
useLocalStorage<string>("new.key", "initial", "legacy.key"),
);
await waitFor(() => expect(result.current[2]).toBe(true));

act(() => {
dispatchStorage({ key: "legacy.key", newValue: JSON.stringify("from-legacy-tab") });
});
expect(result.current[0]).toBe("from-legacy-tab");
});

it("removes the storage listener on unmount", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const { result, unmount } = renderHook(() => useLocalStorage<string>("solo.key", "initial"));
await waitFor(() => expect(result.current[2]).toBe(true));

unmount();
expect(removeSpy).toHaveBeenCalledWith("storage", expect.any(Function));

// Firing after unmount must not throw (listener is gone).
expect(() => {
dispatchStorage({ key: "solo.key", newValue: JSON.stringify("after-unmount") });
}).not.toThrow();
});
});
23 changes: 22 additions & 1 deletion apps/loopover-ui/src/lib/use-local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

/**
* Tiny SSR-safe localStorage hook. Reads once on mount; writes are persisted
Expand All @@ -12,6 +12,10 @@ import { useCallback, useEffect, useState } from "react";
export function useLocalStorage<T>(key: string, initial: T, legacyKey?: string) {
const [value, setValue] = useState<T>(initial);
const [hydrated, setHydrated] = useState(false);
// Call sites often pass a fresh `[]` / `{...}` literal each render; keep the
// listener keyed only on `key`/`legacyKey` and read the latest initial via ref.
const initialRef = useRef(initial);
initialRef.current = initial;

useEffect(() => {
try {
Expand All @@ -29,6 +33,23 @@ export function useLocalStorage<T>(key: string, initial: T, legacyKey?: string)
/* ignore */
}
setHydrated(true);

// Cross-tab sync: the browser fires `storage` only in *other* same-origin tabs
// (never the tab that wrote). Same-tab writes already update state via `update()`.
const onStorage = (event: StorageEvent) => {
if (event.key !== key && (!legacyKey || event.key !== legacyKey)) return;
if (event.newValue === null) {
setValue(initialRef.current);
return;
}
try {
setValue(JSON.parse(event.newValue) as T);
} catch {
/* ignore */
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, legacyKey]);

const update = useCallback(
Expand Down
Loading