26b4315e09
Address review on the persisted unread dots, plus a latent data-loss bug in the shared persistence helper that the restart e2e exposed. Review findings: - Session ids are caller-supplied and each profile backend is its own namespace, while the desktop's lists routinely mix profiles (cron and messaging slices are always cross-profile; recents are too in all-profiles mode). Both persisted records are now bucketed per profile - nested records keyed by the ROW's own profile (normalizeProfileKey, absent -> default), never the live gateway's, except the live busy->idle edge with no loaded row, which can only come from the active gateway. Same-id sessions in different profiles no longer share watermarks or markers. - Markers are now bounded (200 per profile, oldest evicted) and cleaned up when a session leaves the user's world: forgetSessionUnread() is wired into removeSession, archiveSession, and the settings permanent-delete path (which bypasses the other two). Cold-boot clobber (found by the restart e2e after the refactor): - persistentAtom wrote its value back to storage immediately at creation. On a cold boot the bundle can evaluate against a storage snapshot that has not caught up yet, so that echo overwrote real records with the fallback. Creation is now read-only; only actual changes persist. Regression-tested in persisted.test.ts. - The read side of the same race is handled in session-unread.ts: the first list arrival re-reads both records from storage (readable by then) and merges them under the in-memory state, so a boot that seeded empty atoms adopts the disk state instead of re-seeding every row and burying the unread gap. Unread listeners are also disabled in secondary windows - their partial list view must not write the primary's whole-record state (same isolation rule as session tiles). Tests: cross-profile same-id regression, live-edge profile fallback, forgetSessionUnread cleanup, marker cap, persistentAtom creation read-only; the restart e2e passes again end to end.
67 lines
2.1 KiB
TypeScript
67 lines
2.1 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { Codecs, persistentAtom } from './persisted'
|
|
|
|
// Unique key per test run — persistentAtom instances are singletons per call,
|
|
// so tests mint fresh keys instead of sharing one.
|
|
let counter = 0
|
|
const freshKey = () => `test.persisted.${++counter}`
|
|
|
|
describe('persistentAtom', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('seeds from a stored value', () => {
|
|
const key = freshKey()
|
|
window.localStorage.setItem(key, JSON.stringify({ a: 1 }))
|
|
|
|
const $value = persistentAtom<Record<string, number>>(key, {})
|
|
|
|
expect($value.get()).toEqual({ a: 1 })
|
|
})
|
|
|
|
it('does NOT write at creation — an empty read must never clobber storage', () => {
|
|
// A cold boot can evaluate the bundle against a storage snapshot that has
|
|
// not caught up yet (early hidden/boot load). If creation echoed the
|
|
// fallback back out, that load would overwrite the real record other
|
|
// loads are about to read. Creation must be read-only.
|
|
const key = freshKey()
|
|
persistentAtom<Record<string, number>>(key, {})
|
|
|
|
expect(window.localStorage.getItem(key)).toBeNull()
|
|
})
|
|
|
|
it('leaves an existing stored value untouched at creation even when it fails to decode', () => {
|
|
const key = freshKey()
|
|
window.localStorage.setItem(key, 'not json')
|
|
|
|
const $value = persistentAtom<Record<string, number>>(key, { fallback: 1 })
|
|
|
|
expect($value.get()).toEqual({ fallback: 1 })
|
|
// The corrupt value survives until a real write replaces it — creation
|
|
// never writes.
|
|
expect(window.localStorage.getItem(key)).toBe('not json')
|
|
})
|
|
|
|
it('persists real changes', () => {
|
|
const key = freshKey()
|
|
const $value = persistentAtom<Record<string, number>>(key, {})
|
|
|
|
$value.set({ b: 2 })
|
|
|
|
expect(window.localStorage.getItem(key)).toBe(JSON.stringify({ b: 2 }))
|
|
})
|
|
|
|
it('removes the key when the codec encodes null', () => {
|
|
const key = freshKey()
|
|
window.localStorage.setItem(key, JSON.stringify(['x']))
|
|
|
|
const $value = persistentAtom<string[]>(key, [], Codecs.stringArray)
|
|
|
|
$value.set([])
|
|
|
|
expect(window.localStorage.getItem(key)).toBeNull()
|
|
})
|
|
})
|