fix(notifications): keep the history under a key of its own, so an older build never reads its new shape

A build from before the change renders history rows as text; an object there crashed its
Notifications page after a rollback. The old key is read once while the new one does not exist,
and never written.
This commit is contained in:
2026-10-29 23:54:48 +01:00
parent e5c75702e8
commit cb8b99e0e7
4 changed files with 84 additions and 5 deletions
+3 -3
View File
@@ -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");
+72
View File
@@ -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"]);
});
});
+3 -1
View File
@@ -222,7 +222,9 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const stored = readAccount<unknown>(HISTORY_KEY, []);
// The old key only until the new one exists: its rows carry forward, said text as `raw`.
const stored =
readAccount<unknown>(HISTORY_KEY, null) ?? readAccount<unknown>(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
+6 -1
View File
@@ -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" },