diff --git a/frontend/src/lib/claimedStores.dom.test.ts b/frontend/src/lib/claimedStores.dom.test.ts index 42f915b7..218b77b6 100644 --- a/frontend/src/lib/claimedStores.dom.test.ts +++ b/frontend/src/lib/claimedStores.dom.test.ts @@ -20,7 +20,7 @@ const entry = (id: number, message: string) => ({ }); const storedMessages = () => ( - JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as { + JSON.parse(localStorage.getItem("siftlode.notifHistory") ?? "[]") as { message: NotifText; }[] ).map((n) => sayNotif(n.message)); @@ -48,7 +48,7 @@ beforeEach(() => { describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => { it("keep the account's notification history and settings once it is claimed", async () => { localStorage.setItem("siftlode.owner", "5"); - localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")])); + localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "older")])); localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 })); const notifications = await import("./notifications"); const { claimLocalData } = await import("./storage"); @@ -76,7 +76,7 @@ describe("stores read at import, before the account is known", { timeout: CASE_B describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => { it("starts the new account's notices from an empty history", async () => { localStorage.setItem("siftlode.owner", "4"); - localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")])); + localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "four's")])); const notifications = await import("./notifications"); const { claimLocalData } = await import("./storage"); diff --git a/frontend/src/lib/notifHistory.dom.test.ts b/frontend/src/lib/notifHistory.dom.test.ts new file mode 100644 index 00000000..168d7bab --- /dev/null +++ b/frontend/src/lib/notifHistory.dom.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// +// The notice history changed shape (a notice keeps what to say, not the said text). A build from +// before it reads the old key and renders its rows as text, so an object there crashed its +// Notifications page after a rollback. The history now lives under a key of its own: the old one is +// read once, while the new one does not exist yet, and never written. +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { msg, raw, sayNotif, type NotifText } from "./notifications"; + +const OLD_KEY = "siftlode.notifications"; + +// A row as the build before wrote it: its text already said. +const oldRow = (id: number, message: string) => ({ + id, + level: "info", + message, + ts: 1, + read: true, + dismissed: true, + transient: false, + repeat: 1, + requiresInteraction: false, +}); + +const saidHistory = (key: string) => + (JSON.parse(localStorage.getItem(key) ?? "[]") as { message: NotifText }[]).map((n) => + sayNotif(n.message), + ); + +beforeAll(async () => { + await import("./notifications"); + await import("./storage"); +}, 60_000); + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.resetModules(); // the store loads at import: each case needs a fresh one +}); + +describe("the notice history's own key", { timeout: 30_000 }, () => { + it("carries the old history forward and leaves the old key as the build before wrote it", async () => { + const old = JSON.stringify([oldRow(7, "older")]); + localStorage.setItem("siftlode.owner", "5"); + localStorage.setItem(OLD_KEY, old); + const notifications = await import("./notifications"); + const { claimLocalData, LS } = await import("./storage"); + + claimLocalData(5); + notifications.notify({ message: msg("common.copy.done") }); + + expect(LS.notifHistory).not.toBe(OLD_KEY); + expect(localStorage.getItem(OLD_KEY)).toBe(old); + expect(saidHistory(LS.notifHistory)).toEqual(["older", "Copied to the clipboard"]); + }); + + it("reads the old key only while the new one does not exist", async () => { + const { LS } = await import("./storage"); + localStorage.setItem("siftlode.owner", "5"); + localStorage.setItem(OLD_KEY, JSON.stringify([oldRow(7, "older")])); + localStorage.setItem( + LS.notifHistory, + JSON.stringify([{ ...oldRow(8, ""), message: raw("newer") }]), + ); + const notifications = await import("./notifications"); + const { claimLocalData } = await import("./storage"); + + claimLocalData(5); + + expect(notifications.getNotifications().map((n) => sayNotif(n.message))).toEqual(["newer"]); + }); +}); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 65634342..f3e327af 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -222,7 +222,9 @@ let cachedUnread = 0; function load(): Notification[] { try { - const stored = readAccount(HISTORY_KEY, []); + // The old key only until the new one exists: its rows carry forward, said text as `raw`. + const stored = + readAccount(HISTORY_KEY, null) ?? readAccount(LS.legacyNotifHistory, []); if (!Array.isArray(stored)) return []; // Restored entries are history only. Mark them dismissed so they don't // resurrect as active toasts on reload — their auto-dismiss timers aren't diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 90e509c3..7a237b45 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -100,7 +100,12 @@ const KEYS = { // flag). A YT.Player instance is created per video and starts at full volume, so without this // every next video is loud again (same idea as plexPlayerPrefs). ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" }, - notifHistory: { key: "siftlode.notifications", kind: "cache" }, + // The notice history: what each notice says (lib/notifications NotifText), not the said text. + notifHistory: { key: "siftlode.notifHistory", kind: "cache" }, + // DEPRECATED — the history as said text, which a build from before the change still reads (and + // renders as text: an object there crashed its Notifications page after a rollback). Read once while + // `notifHistory` does not exist yet, then inert. Do not write. + legacyNotifHistory: { key: "siftlode.notifications", kind: "cache" }, notifSettings: { key: "siftlode.notifSettings", kind: "pref" }, onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" }, seenVersion: { key: "siftlode.seenVersion", kind: "pref" },