From 6df25f3afc9aa4637d84708ea5e3c4b7e49b8a1a Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Wed, 9 Sep 2026 11:46:52 +0200 Subject: [PATCH] fix(hub-ui): dismiss toasts when messages are removed --- .../hub-ui/src/client/state/messages.test.ts | 57 +++++++++++++++++++ packages/hub-ui/src/client/state/messages.ts | 17 +++--- 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 packages/hub-ui/src/client/state/messages.test.ts diff --git a/packages/hub-ui/src/client/state/messages.test.ts b/packages/hub-ui/src/client/state/messages.test.ts new file mode 100644 index 000000000..3d1a3ab5d --- /dev/null +++ b/packages/hub-ui/src/client/state/messages.test.ts @@ -0,0 +1,57 @@ +import type { DevframeMessageEntry, DevframeMessagesListDelta } from '@devframes/hub' +import type { DocksContext } from '@devframes/hub/client' +import { afterEach, expect, it, vi } from 'vitest' + +afterEach(async () => { + const { dismissToast, useToasts } = await import('./toasts') + for (const toast of [...useToasts()]) dismissToast(toast.id) + vi.restoreAllMocks() + vi.resetModules() + vi.useRealTimers() +}) + +async function fixture() { + const { useMessages } = await import('./messages') + const { useToasts } = await import('./toasts') + let delta: DevframeMessagesListDelta = { entries: [], removedIds: [], version: 0, full: true } + let refresh = () => {} + const call = vi.fn(async () => delta) + // eslint-disable-next-line slop/no-chained-type-assertions -- Only the RPC feed participates in this state test. + const state = useMessages({ rpc: { + call, + isTrusted: true, + ensureTrusted: async () => {}, + client: { register: (command: { handler: () => void }) => { refresh = command.handler } }, + } } as unknown as DocksContext) + await vi.waitUntil(() => call.mock.calls.length === 1) + await Promise.resolve() + async function publish(next: DevframeMessagesListDelta) { + delta = next + refresh() + await Promise.resolve() + } + const message: DevframeMessageEntry = { id: 'pending', message: 'Pending approval', level: 'info', from: 'browser', timestamp: 0, notify: true, autoDismiss: 60_000 } + await publish({ entries: [message], removedIds: [], version: 1, full: false }) + return { state, toasts: useToasts(), publish, message } +} + +it.each([false, true])('removes visible toasts and timers when the message is removed (full=%s)', async (full) => { + expect.assertions(5) + const { state, toasts, publish } = await fixture() + const clearTimer = vi.spyOn(globalThis, 'clearTimeout') + expect(state.entries).toHaveLength(1) + expect(toasts).toHaveLength(1) + await publish({ entries: [], removedIds: full ? [] : ['pending'], version: 2, full }) + expect(state.entries).toHaveLength(0) + expect(toasts).toHaveLength(0) + expect(clearTimer).toHaveBeenCalledOnce() +}) + +it('keeps surviving toasts and unread counts when receiving a full snapshot', async () => { + expect.assertions(3) + const { state, toasts, publish, message } = await fixture() + await publish({ entries: [message], removedIds: [], version: 2, full: true }) + expect(state.entries).toHaveLength(1) + expect(toasts).toHaveLength(1) + expect(state.unreadCount).toBe(1) +}) diff --git a/packages/hub-ui/src/client/state/messages.ts b/packages/hub-ui/src/client/state/messages.ts index fc5dc4934..1c8de1708 100644 --- a/packages/hub-ui/src/client/state/messages.ts +++ b/packages/hub-ui/src/client/state/messages.ts @@ -2,7 +2,7 @@ import type { DevframeMessageEntry, DevframeMessagesListDelta, DevframeRpcClient import type { DocksContext } from '@devframes/hub/client' import type { Reactive } from 'vue' import { reactive } from 'vue' -import { addToast } from './toasts' +import { addToast, dismissToast } from './toasts' export interface MessagesState { entries: DevframeMessageEntry[] @@ -44,13 +44,16 @@ export function useMessages(context: DocksContext): Reactive { ) as DevframeMessagesListDelta let newCount = 0 - // A full snapshot resets any locally cached list before applying it. - if (result.full) - entryMap.clear() - - // Apply removals - for (const id of result.removedIds) + /** Preserve surviving entries so a full snapshot does not notify them again. */ + let removedIds = result.removedIds + if (result.full) { + const retainedIds = new Set(result.entries.map(entry => entry.id)) + removedIds = [...entryMap.keys()].filter(id => !retainedIds.has(id)) + } + for (const id of removedIds) { entryMap.delete(id) + dismissToast(id) + } // Apply new/updated entries. On initial fetch (page refresh) only entries // still loading toast; afterwards any notifying new or changed entry does.