diff --git a/.changeset/notifications-persist-option.md b/.changeset/notifications-persist-option.md index 75e1bbf0a..edadf9e32 100644 --- a/.changeset/notifications-persist-option.md +++ b/.changeset/notifications-persist-option.md @@ -4,4 +4,4 @@ feat(useNotifications): add `persist` option to `createNotificationsPlugin` -Setting `persist: true` serializes the notification registry to storage and restores it on load, so notification lifecycle state — including `snoozedUntil` — survives a page reload. Backed by the existing `createPluginContext` persist/restore hooks and keyed by the plugin namespace. +Setting `persist: true` saves each notification's interaction state — `readAt`, `seenAt`, `archivedAt`, and `snoozedUntil` — to storage as a map keyed by notification id, and merges it back onto the notifications the app registers, whether they exist at restore time or register later (adapters, runtime sends). Notification content is never stored: code stays the source of truth for subject, body, and data. Expired snoozes are dropped on restore, and state for notifications that no longer register is pruned from the next write. Backed by the existing `createPluginContext` persist/restore hooks and keyed by the plugin namespace. diff --git a/apps/docs/src/pages/composables/plugins/use-notifications.md b/apps/docs/src/pages/composables/plugins/use-notifications.md index d734fb79a..09ec6e8c2 100644 --- a/apps/docs/src/pages/composables/plugins/use-notifications.md +++ b/apps/docs/src/pages/composables/plugins/use-notifications.md @@ -43,7 +43,7 @@ app.mount('#app') ### Persistence -Pass `persist: true` to remember the notification registry across reloads. Requires [useStorage](/composables/plugins/use-storage) to be installed first. Tickets are stored under the key `notifications` (the plugin namespace with the `v0:` prefix stripped). +Pass `persist: true` to remember how the user interacted with each notification across reloads. Requires [useStorage](/composables/plugins/use-storage) to be installed first. Only interaction state is stored — a map of notification id to its `readAt` / `seenAt` / `archivedAt` / `snoozedUntil` timestamps under the key `notifications` (the plugin namespace with the `v0:` prefix stripped). Notification content is never stored; your code (or an adapter) stays the source of truth. ```ts main.ts import { createStoragePlugin, createNotificationsPlugin } from '@vuetify/v0' @@ -52,7 +52,7 @@ app.use(createStoragePlugin()) app.use(createNotificationsPlugin({ persist: true })) ``` -Give tickets a stable `id` — generated ids accumulate on every reload. Unknown or malformed stored entries are ignored. On load, persisted tickets win over an adapter's first snapshot; later adapter updates still overlay live remote state. +Give tickets a stable `id` — saved state is matched by id and merges onto notifications as they register, whether that happens at load or later (adapter pushes, runtime sends). A notification that is never re-registered is not resurrected from storage, and its saved state is pruned on the next write. Expired snoozes and malformed entries are dropped on load. ## Usage @@ -404,7 +404,7 @@ The `severity` field categorizes notifications by urgency. It maps to ARIA live ??? How do I persist notifications across reloads? -Pass `persist: true` to `createNotificationsPlugin`, and install `createStoragePlugin` first. The saved value is the list of identified tickets (including `snoozedUntil`). On load it is reconciled against the registry and wins over an adapter's first snapshot. +Pass `persist: true` to `createNotificationsPlugin`, and install `createStoragePlugin` first. The saved value is a map of interaction state per notification id (`readAt`, `seenAt`, `archivedAt`, `snoozedUntil`) — never notification content. On load it merges onto notifications as your code or adapter registers them; nothing is created from storage. ??? How do I keep a notification from auto-dismissing? diff --git a/packages/0/src/composables/useNotifications/index.test.ts b/packages/0/src/composables/useNotifications/index.test.ts index 6e351aa16..28eae0f51 100644 --- a/packages/0/src/composables/useNotifications/index.test.ts +++ b/packages/0/src/composables/useNotifications/index.test.ts @@ -1053,39 +1053,43 @@ describe('createNotifications', () => { }) describe('persist/restore', () => { - function persisted (id: string, extra: Record = {}) { - return { - id, - subject: 'Hello', - createdAt: '2026-01-01T00:00:00.000Z', - readAt: null, - seenAt: null, - archivedAt: null, - snoozedUntil: null, - ...extra, - } - } + it('should not create notifications from storage', () => { + const app = createApp({ render: () => null }) + app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) + + app.runWithContext(() => { + useStorage().set('notifications', { ghost: { readAt: '2026-01-01T00:00:00.000Z' } }) + }) + + app.use(createNotificationsPlugin({ persist: true })) + + const context = app.runWithContext(() => useNotifications()) + + expect(context.size).toBe(0) + expect(context.has('ghost')).toBe(false) + }) - it('should restore persisted tickets including snoozedUntil', () => { + it('should merge saved state onto a notification registered after restore', () => { const until = '2026-12-01T00:00:00.000Z' + const readAt = '2026-01-01T00:00:00.000Z' const app = createApp({ render: () => null }) app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.runWithContext(() => { - useStorage().set('notifications', [persisted('banner', { snoozedUntil: until })]) + useStorage().set('notifications', { banner: { readAt, snoozedUntil: until } }) }) app.use(createNotificationsPlugin({ persist: true })) const context = app.runWithContext(() => useNotifications()) - const ticket = context.get('banner') + const ticket = context.register({ id: 'banner', subject: 'Hello' }) - expect(ticket?.subject).toBe('Hello') - expect(ticket?.snoozedUntil?.toISOString()).toBe(until) - expect(ticket?.createdAt.toISOString()).toBe('2026-01-01T00:00:00.000Z') + expect(ticket.subject).toBe('Hello') + expect(ticket.readAt?.toISOString()).toBe(readAt) + expect(ticket.snoozedUntil?.toISOString()).toBe(until) }) - it('should persist snooze to storage', async () => { + it('should persist only interaction state, never content', async () => { const app = createApp({ render: () => null }) app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.use(createNotificationsPlugin({ persist: true })) @@ -1095,74 +1099,124 @@ describe('createNotifications', () => { app.runWithContext(() => { const context = useNotifications() - context.register({ id: 'banner', subject: 'Hello' }) + context.register({ id: 'banner', subject: 'Secret subject', body: 'Secret body', data: { token: 'secret-data' } }) + context.register({ id: 'untouched', subject: 'No state' }) context.snooze('banner', until) + context.read('banner') }) await nextTick() - const stored = app.runWithContext(() => useStorage().get('notifications').value) as Array<{ id: string, snoozedUntil: string }> + const stored = app.runWithContext(() => useStorage().get('notifications').value) - expect(stored).toHaveLength(1) - expect(stored[0]!.id).toBe('banner') - expect(stored[0]!.snoozedUntil).toBe(until.toISOString()) + expect(stored).toEqual({ + banner: { + readAt: expect.any(String), + snoozedUntil: until.toISOString(), + }, + }) + expect(JSON.stringify(stored)).not.toContain('Secret') + expect(JSON.stringify(stored)).not.toContain('secret-data') app.unmount() }) - it('should ignore a non-array persisted value', () => { + it('should prune entries whose notification never re-registered from the next write', async () => { const app = createApp({ render: () => null }) app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.runWithContext(() => { - useStorage().set('notifications', 'garbage') + useStorage().set('notifications', { + ghost: { readAt: '2026-01-01T00:00:00.000Z' }, + banner: { readAt: '2026-01-01T00:00:00.000Z' }, + }) }) app.use(createNotificationsPlugin({ persist: true })) + app.mount(document.createElement('div')) const context = app.runWithContext(() => useNotifications()) + context.register({ id: 'banner', subject: 'Hello' }) - expect(context.size).toBe(0) + await nextTick() + + const stored = app.runWithContext(() => useStorage().get('notifications').value) + + expect(stored).toEqual({ banner: { readAt: '2026-01-01T00:00:00.000Z' } }) + + app.unmount() }) - it('should keep only object entries with a string or number id', () => { + it('should drop an expired snooze on restore', () => { const app = createApp({ render: () => null }) app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.runWithContext(() => { - useStorage().set('notifications', [ - persisted('ok'), - { evil: true }, - null, - 'x', - { id: { nested: true } }, - ]) + useStorage().set('notifications', { banner: { snoozedUntil: '2020-01-01T00:00:00.000Z' } }) }) app.use(createNotificationsPlugin({ persist: true })) const context = app.runWithContext(() => useNotifications()) + const ticket = context.register({ id: 'banner', subject: 'Hello' }) + + expect(ticket.snoozedUntil).toBeNull() + }) + + it('should ignore a malformed persisted value', () => { + const app = createApp({ render: () => null }) + app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) + + app.runWithContext(() => { + useStorage().set('notifications', 'garbage') + }) + + app.use(createNotificationsPlugin({ persist: true })) + + const context = app.runWithContext(() => useNotifications()) + const ticket = context.register({ id: 'banner', subject: 'Hello' }) expect(context.size).toBe(1) - expect(context.has('ok')).toBe(true) + expect(ticket.readAt).toBeNull() + }) + + it('should ignore the legacy persisted array format', () => { + const app = createApp({ render: () => null }) + app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) + + app.runWithContext(() => { + useStorage().set('notifications', [{ id: 'banner', subject: 'Old', snoozedUntil: '2026-12-01T00:00:00.000Z' }]) + }) + + app.use(createNotificationsPlugin({ persist: true })) + + const context = app.runWithContext(() => useNotifications()) + + expect(context.size).toBe(0) }) - it('should treat an invalid date string as null', () => { + it('should skip malformed entries and invalid date fields', () => { const app = createApp({ render: () => null }) app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.runWithContext(() => { - useStorage().set('notifications', [persisted('banner', { snoozedUntil: 'not-a-date' })]) + useStorage().set('notifications', { + banner: { readAt: '2026-01-01T00:00:00.000Z', snoozedUntil: 'not-a-date' }, + junk: 'x', + empty: {}, + }) }) app.use(createNotificationsPlugin({ persist: true })) const context = app.runWithContext(() => useNotifications()) + const ticket = context.register({ id: 'banner', subject: 'Hello' }) - expect(context.get('banner')?.snoozedUntil).toBeNull() + expect(ticket.readAt?.toISOString()).toBe('2026-01-01T00:00:00.000Z') + expect(ticket.snoozedUntil).toBeNull() }) - it('should keep restored snooze after the first adapter register', () => { + it('should merge saved state onto a notification registered by an adapter', () => { const until = '2026-12-01T00:00:00.000Z' const adapter = { setup: (ctx: { register: (input: { id: string, subject: string }) => unknown }) => { @@ -1174,7 +1228,7 @@ describe('createNotifications', () => { app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.runWithContext(() => { - useStorage().set('notifications', [persisted('banner', { subject: 'from-persist', snoozedUntil: until })]) + useStorage().set('notifications', { banner: { snoozedUntil: until } }) }) app.use(createNotificationsPlugin({ persist: true, adapter })) @@ -1182,7 +1236,8 @@ describe('createNotifications', () => { const context = app.runWithContext(() => useNotifications()) const ticket = context.get('banner') - expect(ticket?.subject).toBe('from-persist') + // Content comes from the adapter; only interaction state is restored. + expect(ticket?.subject).toBe('from-adapter') expect(ticket?.snoozedUntil?.toISOString()).toBe(until) }) @@ -1191,14 +1246,15 @@ describe('createNotifications', () => { app.use(createStoragePlugin({ adapter: new MemoryStorageAdapter() })) app.runWithContext(() => { - useStorage().set('notifications', [persisted('banner')]) + useStorage().set('notifications', { banner: { readAt: '2026-01-01T00:00:00.000Z' } }) }) app.use(createNotificationsPlugin()) const context = app.runWithContext(() => useNotifications()) + const ticket = context.register({ id: 'banner', subject: 'Hello' }) - expect(context.has('banner')).toBe(false) + expect(ticket.readAt).toBeNull() }) it('should not write to storage when persist is off', async () => { @@ -1208,7 +1264,9 @@ describe('createNotifications', () => { app.mount(document.createElement('div')) app.runWithContext(() => { - useNotifications().register({ id: 'banner', subject: 'Hello' }) + const context = useNotifications() + context.register({ id: 'banner', subject: 'Hello' }) + context.read('banner') }) await nextTick() diff --git a/packages/0/src/composables/useNotifications/index.ts b/packages/0/src/composables/useNotifications/index.ts index b183bedbf..36e4c3f26 100644 --- a/packages/0/src/composables/useNotifications/index.ts +++ b/packages/0/src/composables/useNotifications/index.ts @@ -30,7 +30,7 @@ import { createQueue } from '#v0/composables/createQueue' import { createRegistry } from '#v0/composables/createRegistry' // Utilities -import { isArray, isNaN, isNull, isNumber, isObject, isString, isUndefined, useId } from '#v0/utilities' +import { isNaN, isNull, isObject, isString, isUndefined, UNSAFE_KEYS, useId } from '#v0/utilities' // Types import type { QueueContext } from '#v0/composables/createQueue' @@ -569,50 +569,39 @@ export interface NotificationsPluginOptions extends NotificationsOptions { namespace?: string adapter?: NotificationsAdapter /** - * Persist the notification registry to storage and restore it on load. - * - * @remarks Serializes each ticket's durable fields (ids, copy, timestamps). + * Persist notification interaction state to storage and restore it on load. + * + * @remarks Stores only interaction state — a map of notification id to its + * `readAt` / `seenAt` / `archivedAt` / `snoozedUntil` timestamps (ISO + * strings, non-null fields only). Notification content is never stored: + * notifications are always created by code or an adapter, and the saved + * state merges onto them as they register — at restore time or any time + * later. Expired snoozes are dropped on restore, and entries whose + * notification never re-registers are pruned from the next persist write. * The storage key is the plugin namespace with the `v0:` prefix stripped - * (`notifications`). On load, persisted tickets win over an adapter's first - * snapshot. Later adapter updates still overlay live remote state. + * (`notifications`). * - * Intended for registries of identified notifications. Tickets sent without - * a stable `id` will accumulate across reloads. + * Requires stable notification `id`s to associate state across reloads. * * @default false */ persist?: boolean } -/** Serializable snapshot of a notification's durable state. */ -interface PersistedNotification { - id: ID - subject?: string - body?: string - severity?: NotificationSeverity - data?: Record - timeout?: number - createdAt: string | null - readAt: string | null - seenAt: string | null - archivedAt: string | null - snoozedUntil: string | null +/** Persisted per-notification interaction state (ISO strings, non-null fields only). */ +interface PersistedInteraction { + readAt?: string + seenAt?: string + archivedAt?: string + snoozedUntil?: string } -function toPersisted (ticket: NotificationTicket): PersistedNotification { - return { - id: ticket.id, - subject: ticket.subject, - body: ticket.body, - severity: ticket.severity, - data: ticket.data, - timeout: ticket.timeout, - createdAt: ticket.createdAt?.toISOString() ?? null, - readAt: ticket.readAt?.toISOString() ?? null, - seenAt: ticket.seenAt?.toISOString() ?? null, - archivedAt: ticket.archivedAt?.toISOString() ?? null, - snoozedUntil: ticket.snoozedUntil?.toISOString() ?? null, - } +/** In-memory interaction state parsed from storage. */ +interface Interaction { + readAt?: Date + seenAt?: Date + archivedAt?: Date + snoozedUntil?: Date } function toDate (value: unknown): Date | null { @@ -623,41 +612,63 @@ function toDate (value: unknown): Date | null { return isNaN(date.getTime()) ? null : date } -function replay (context: NotificationsContext, saved: unknown) { - if (!isArray(saved)) return +function snapshot (context: NotificationsContext): Record | null { + const out: Record = {} + let count = 0 + + for (const ticket of context.values()) { + if (UNSAFE_KEYS.has(String(ticket.id))) continue + + const entry: PersistedInteraction = {} + + if (!isNull(ticket.readAt)) entry.readAt = ticket.readAt.toISOString() + if (!isNull(ticket.seenAt)) entry.seenAt = ticket.seenAt.toISOString() + if (!isNull(ticket.archivedAt)) entry.archivedAt = ticket.archivedAt.toISOString() + if (!isNull(ticket.snoozedUntil) && ticket.snoozedUntil.getTime() > Date.now()) { + entry.snoozedUntil = ticket.snoozedUntil.toISOString() + } + + if (Object.keys(entry).length === 0) continue + + out[ticket.id] = entry + count++ + } + + return count > 0 ? out : null +} + +function coerce (saved: unknown): Map | null { + if (!isObject(saved)) return null + + const map = new Map() - for (const entry of saved) { + for (const [id, entry] of Object.entries(saved)) { if (!isObject(entry)) continue - if (!isString(entry.id) && !isNumber(entry.id)) continue - - const persisted = entry as unknown as PersistedNotification - const ticket = context.has(persisted.id) - ? context.get(persisted.id)! - : context.register({ - id: persisted.id, - subject: persisted.subject, - body: persisted.body, - severity: persisted.severity, - data: persisted.data, - timeout: persisted.timeout, - }) - context.upsert(ticket.id, { - subject: persisted.subject, - body: persisted.body, - severity: persisted.severity, - data: persisted.data, - timeout: persisted.timeout, - createdAt: toDate(persisted.createdAt) ?? ticket.createdAt, - readAt: toDate(persisted.readAt), - seenAt: toDate(persisted.seenAt), - archivedAt: toDate(persisted.archivedAt), - snoozedUntil: toDate(persisted.snoozedUntil), - } as Partial) + const state: Interaction = {} + const readAt = toDate(entry.readAt) + const seenAt = toDate(entry.seenAt) + const archivedAt = toDate(entry.archivedAt) + const snoozedUntil = toDate(entry.snoozedUntil) + + if (!isNull(readAt)) state.readAt = readAt + if (!isNull(seenAt)) state.seenAt = seenAt + if (!isNull(archivedAt)) state.archivedAt = archivedAt + // An expired snooze is dropped here, so the next persist write prunes it. + if (!isNull(snoozedUntil) && snoozedUntil.getTime() > Date.now()) state.snoozedUntil = snoozedUntil + + if (Object.keys(state).length > 0) map.set(id, state) } + + return map.size > 0 ? map : null } -const restored = new WeakMap() +function merge (context: NotificationsContext, map: Map, ticket: NotificationTicket) { + // Object keys stringify on write, so a numeric id round-trips as a string. + const state = map.get(ticket.id) ?? map.get(String(ticket.id)) + + if (state) context.upsert(ticket.id, state as Partial) +} // Fallback function noop () {} @@ -772,10 +783,20 @@ export const [createNotificationsContext, createNotificationsPlugin, useNotifica options => createNotifications(options), { fallback: () => createNotificationsFallback(), - persist: context => context.values().map(toPersisted), + persist: context => snapshot(context), restore: (context, saved) => { - restored.set(context, saved) - replay(context, saved) + const map = coerce(saved) + + if (!map) return + + for (const ticket of context.values()) { + merge(context, map, ticket) + } + + // Saved state stays mergeable for the whole session so late + // registrations (adapters, runtime sends) pick it up; entries whose + // notification never registers are pruned by the next persist write. + context.on('register:ticket', ticket => merge(context, map, ticket as NotificationTicket)) }, setup: (context, app, options) => { app.onUnmount(() => { @@ -794,11 +815,6 @@ export const [createNotificationsContext, createNotificationsPlugin, useNotifica }) app.onUnmount(() => adapter.dispose?.()) - - // Adapter setup writes after restore. Re-apply so persist wins the - // first snapshot; later adapter updates still overlay live remote state. - const saved = restored.get(context) - if (!isUndefined(saved)) replay(context, saved) }, }, )