From ab1eb5fed3fa31bf2ef96f43ec2fe9d42cbc3aec Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:36:21 -0400 Subject: [PATCH 1/2] fix(calendar): make the enable-calendar prompt usable on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile branch of the custom toast layout stripped the body and the close button, so a persistent prompt reduced to a bare truncated title with no way to dismiss it, and its action button was styled `text-panel` — a background token — leaving it near-invisible on the dark island. Persistent prompts now get the full card on mobile (description, close button, actions on their own row) and the island spans the dock gutter instead of 90vw inside an already-padded container. Two related fixes the above exposed: persistent toasts no longer occupy the mobile region's single replaceable slot, so a passing "Copied" toast can't tear one down and permanently suppress it; and with several inboxes flagged, prompts queue one at a time on a phone rather than burying the screen. Closing the calendar prompt now sticks across reloads — nothing is broken while calendar is off, and Settings > Email keeps a permanent per-inbox "Enable calendar" button. Reauth keeps re-asking every session, since that one means mail is actually dead. --- .../auth/CalendarPermissionPrompt.tsx | 13 ++ .../auth/GmailReauthenticationPrompt.tsx | 3 + .../src/lib/core/component/Toast/Toast.tsx | 136 ++++++++----- .../lib/core/component/Toast/ToastRegion.tsx | 9 +- .../Toast/useKeyedPersistentToasts.test.ts | 188 +++++++++++++++++- .../Toast/useKeyedPersistentToasts.ts | 100 +++++++++- 6 files changed, 392 insertions(+), 57 deletions(-) diff --git a/apps/web/src/features/auth/CalendarPermissionPrompt.tsx b/apps/web/src/features/auth/CalendarPermissionPrompt.tsx index 1f5bc60d9d1..053acbeb709 100644 --- a/apps/web/src/features/auth/CalendarPermissionPrompt.tsx +++ b/apps/web/src/features/auth/CalendarPermissionPrompt.tsx @@ -1,6 +1,7 @@ import { useCalendarUiFlag } from '@app/features/calendar/use-calendar-ui-flag'; import { useKeyedPersistentToasts } from '@core/component/Toast/useKeyedPersistentToasts'; import { useAddInboxFlow } from '@core/email-link'; +import { isMobile } from '@core/mobile/isMobile'; import { useEmailLinksQuery } from '@queries/email/link'; /** @@ -12,6 +13,10 @@ import { useEmailLinksQuery } from '@queries/email/link'; * * Inboxes that also need a full reconnect are skipped: the reconnect prompt * covers them, and reconnecting records the calendar grant anyway. + * + * Closing the prompt sticks across reloads. Nothing is broken while calendar + * is off, so re-asking every load is just nagging — Settings › Email keeps a + * per-inbox "Enable calendar" button for whenever the user wants it. */ export function CalendarPermissionPrompt() { const calendarUiEnabled = useCalendarUiFlag(); @@ -26,6 +31,14 @@ export function CalendarPermissionPrompt() { ) : [], key: (link) => link.id, + persistKey: 'macro:calendar-prompt:dismissed', + // Until the flag resolves and the links land, the empty list above means + // "don't know yet", not "no inbox needs this" — stored dismissals must + // survive that window. + itemsLoaded: () => calendarUiEnabled() && linksQuery.isSuccess, + // A phone has room for one of these above the dock; extra inboxes wait + // their turn rather than burying the screen. + maxVisible: () => (isMobile() ? 1 : Number.POSITIVE_INFINITY), toast: (link, dismiss) => ({ title: 'Enable calendar', content(): string { diff --git a/apps/web/src/features/auth/GmailReauthenticationPrompt.tsx b/apps/web/src/features/auth/GmailReauthenticationPrompt.tsx index de233ceeaa3..a724db44ac2 100644 --- a/apps/web/src/features/auth/GmailReauthenticationPrompt.tsx +++ b/apps/web/src/features/auth/GmailReauthenticationPrompt.tsx @@ -1,5 +1,6 @@ import { useKeyedPersistentToasts } from '@core/component/Toast/useKeyedPersistentToasts'; import { useAddInboxFlow } from '@core/email-link'; +import { isMobile } from '@core/mobile/isMobile'; import { useEmailLinksQuery, useInboxHealthProbeQuery, @@ -24,6 +25,8 @@ export function GmailReauthenticationPrompt() { items: () => (linksQuery.data?.links ?? []).filter((link) => link.needs_reauth), key: (link) => link.id, + // One at a time on a phone; the other dead inboxes queue behind it. + maxVisible: () => (isMobile() ? 1 : Number.POSITIVE_INFINITY), toast: (link, dismiss) => ({ title: 'Reconnect Gmail', content(): string { diff --git a/apps/web/src/lib/core/component/Toast/Toast.tsx b/apps/web/src/lib/core/component/Toast/Toast.tsx index 443f3540bf7..7625eea44ae 100644 --- a/apps/web/src/lib/core/component/Toast/Toast.tsx +++ b/apps/web/src/lib/core/component/Toast/Toast.tsx @@ -192,6 +192,29 @@ function dismissActiveToast(region: string): boolean { return true; } +/** + * Hand the region's single visible slot to a new toast, and report whether it + * displaced one (so the newcomer can skip its entrance animation). + * + * Persistent toasts opt out of the slot entirely: they are prompts the user is + * expected to answer, so a passing "Copied" must not tear one down, and a + * prompt appearing must not swallow a result the user is still reading. They + * stack in the region instead, and leave only when dismissed. + */ +function replaceActiveToast(region: string, persistent?: boolean): boolean { + if (persistent) return false; + return dismissActiveToast(region); +} + +function trackActiveToast( + region: string, + toastId: number, + persistent?: boolean +): void { + if (persistent) return; + setActiveToastId(region, toastId); +} + function clearTrackedToast(region: string, toastId: number): void { if (getActiveToastId(region) === toastId) { setActiveToastId(region, undefined); @@ -251,8 +274,8 @@ function ActionButtons(props: { actions: ToastAction[]; mobile?: boolean }) { + + + + +
+ {customConfig().content?.()} +
- - - - + +
+ +
- - -
{customConfig().content?.()}
-
- - )} + + ); + }} {/* ── Standard layout ── */} @@ -667,7 +713,7 @@ function embed( const useMobile = isMobile(); const region = options?.region ?? (useMobile ? 'mobile-toast-region' : 'toast-region'); - const skipOpenAnimation = dismissActiveToast(region); + const skipOpenAnimation = replaceActiveToast(region, options?.persistent); const toastId = toaster.show( (props) => ( ( {/* - Mobile-only region: centered above the mobile dock. Only one toast is - ever visible — Toast.tsx dismisses the previous mobile toast as soon - as a new one is shown, so no stacking is needed here. + Mobile-only region: centered above the mobile dock. At most one + transient toast is visible — Toast.tsx dismisses the previous one as + soon as a new one is shown. Persistent prompts opt out of that slot and + stack above it until answered, so this list needs real spacing. */}
- +
diff --git a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts index 16989cf89d2..520a4014e90 100644 --- a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts +++ b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import { createRoot, createSignal } from 'solid-js'; +import { batch, createRoot, createSignal } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -23,39 +23,74 @@ type Item = { id: string; label: string }; type ToastOptions = { persistent?: boolean; onDismiss?: () => void }; +function toastOptionsAt(index: number): ToastOptions { + return mocks.toastCustom.mock.calls[index]?.[1] as ToastOptions; +} + function lastToastOptions(): ToastOptions { return mocks.toastCustom.mock.calls.at(-1)?.[1] as ToastOptions; } +const PERSIST_KEY = 'macro:test-prompt:dismissed'; + +function persistedKeys(): string[] { + return JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '[]'); +} + describe('useKeyedPersistentToasts', () => { let nextToastId: number; beforeEach(() => { nextToastId = 1; mocks.toastCustom.mockImplementation(() => nextToastId++); + localStorage.clear(); }); afterEach(() => { vi.clearAllMocks(); }); - function mount(initial: Item[]) { + function mount( + initial: Item[], + options?: { + persistKey?: string; + maxVisible?: number; + itemsLoaded?: boolean; + } + ) { let setItems!: (items: Item[]) => void; + let setLoaded!: (loaded: boolean) => void; let dispose!: () => void; createRoot((d) => { dispose = d; const [items, set] = createSignal(initial); + const [loaded, setLoadedSignal] = createSignal( + options?.itemsLoaded ?? true + ); setItems = set; + setLoaded = setLoadedSignal; useKeyedPersistentToasts({ items, key: (item) => item.id, + persistKey: options?.persistKey, + itemsLoaded: loaded, + maxVisible: + options?.maxVisible === undefined + ? undefined + : () => options.maxVisible!, toast: (item, dismiss) => ({ title: item.label, actions: [{ label: 'Go', onClick: dismiss }], }), }); }); - return { setItems, dispose }; + return { setItems, setLoaded, dispose }; + } + + function titlesShown(): string[] { + return mocks.toastCustom.mock.calls.map( + (call) => (call[0] as { title: string }).title + ); } it('shows one persistent toast per item and no duplicates on re-run', () => { @@ -121,4 +156,151 @@ describe('useKeyedPersistentToasts', () => { expect(mocks.toastDismiss).toHaveBeenCalledWith(1); expect(mocks.toastDismiss).toHaveBeenCalledWith(2); }); + + it('re-prompts a returning item when its unmount lands after it left', () => { + const item = { id: 'a', label: 'A' }; + const { setItems, dispose } = mount([item]); + const unmount = toastOptionsAt(0).onDismiss!; + + setItems([]); + // The toast element unmounts on its exit animation, i.e. after the item + // has already been forgotten. + unmount(); + setItems([{ ...item }]); + + expect(mocks.toastCustom).toHaveBeenCalledTimes(2); + dispose(); + }); + + it('does not touch storage without a persistKey', () => { + const { dispose } = mount([{ id: 'a', label: 'A' }]); + lastToastOptions().onDismiss?.(); + expect(localStorage.length).toBe(0); + dispose(); + }); + + describe('with persistKey', () => { + it('remembers a close across mounts', () => { + const item = { id: 'a', label: 'A' }; + const first = mount([item], { persistKey: PERSIST_KEY }); + lastToastOptions().onDismiss?.(); + expect(persistedKeys()).toEqual(['a']); + first.dispose(); + + const second = mount([item], { persistKey: PERSIST_KEY }); + expect(mocks.toastCustom).toHaveBeenCalledTimes(1); + second.dispose(); + }); + + it('re-prompts next mount when the action was taken instead of closed', () => { + const item = { id: 'a', label: 'A' }; + const first = mount([item], { persistKey: PERSIST_KEY }); + const config = mocks.toastCustom.mock.calls[0][0] as { + actions: { onClick: () => void }[]; + }; + config.actions[0].onClick(); + toastOptionsAt(0).onDismiss?.(); + expect(persistedKeys()).toEqual([]); + first.dispose(); + + // The flow may not have landed the grant — keep the prompt available. + const second = mount([item], { persistKey: PERSIST_KEY }); + expect(mocks.toastCustom).toHaveBeenCalledTimes(2); + second.dispose(); + }); + + it('does not record a dismissal when the owner is disposed', () => { + const item = { id: 'a', label: 'A' }; + const { dispose } = mount([item], { persistKey: PERSIST_KEY }); + dispose(); + toastOptionsAt(0).onDismiss?.(); + expect(persistedKeys()).toEqual([]); + }); + + it('forgets a stored dismissal once the item leaves the set', () => { + const item = { id: 'a', label: 'A' }; + const { setItems, dispose } = mount([item], { persistKey: PERSIST_KEY }); + lastToastOptions().onDismiss?.(); + expect(persistedKeys()).toEqual(['a']); + + setItems([]); + expect(persistedKeys()).toEqual([]); + dispose(); + }); + + it('keeps stored dismissals while the item set is still loading', () => { + const item = { id: 'a', label: 'A' }; + const first = mount([item], { persistKey: PERSIST_KEY }); + lastToastOptions().onDismiss?.(); + expect(persistedKeys()).toEqual(['a']); + first.dispose(); + + // How a page load starts: query hasn't answered, so the list is empty. + const second = mount([], { + persistKey: PERSIST_KEY, + itemsLoaded: false, + }); + expect(persistedKeys()).toEqual(['a']); + + // Links land, still needing calendar — the stored "no" holds. Data and + // success flip together, so the empty list is never seen as loaded. + batch(() => { + second.setItems([item]); + second.setLoaded(true); + }); + expect(mocks.toastCustom).toHaveBeenCalledTimes(1); + expect(persistedKeys()).toEqual(['a']); + second.dispose(); + }); + + it('ignores malformed stored state', () => { + localStorage.setItem(PERSIST_KEY, '{"nope":true}'); + const { dispose } = mount([{ id: 'a', label: 'A' }], { + persistKey: PERSIST_KEY, + }); + expect(mocks.toastCustom).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + describe('with maxVisible', () => { + const three: Item[] = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ]; + + it('shows only up to the cap', () => { + const { dispose } = mount(three, { maxVisible: 1 }); + expect(titlesShown()).toEqual(['A']); + dispose(); + }); + + it('promotes the next queued item when one is closed', () => { + const { dispose } = mount(three, { maxVisible: 1 }); + lastToastOptions().onDismiss?.(); + expect(titlesShown()).toEqual(['A', 'B']); + + lastToastOptions().onDismiss?.(); + expect(titlesShown()).toEqual(['A', 'B', 'C']); + dispose(); + }); + + it('promotes the next queued item when one takes its action', () => { + const { dispose } = mount(three, { maxVisible: 1 }); + const config = mocks.toastCustom.mock.calls[0][0] as { + actions: { onClick: () => void }[]; + }; + config.actions[0].onClick(); + expect(titlesShown()).toEqual(['A', 'B']); + dispose(); + }); + + it('promotes the next queued item when one leaves the set', () => { + const { setItems, dispose } = mount(three, { maxVisible: 1 }); + setItems(three.slice(1)); + expect(titlesShown()).toEqual(['A', 'B']); + dispose(); + }); + }); }); diff --git a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts index ee52b7ec9a3..d91cff1ed47 100644 --- a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts +++ b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts @@ -1,58 +1,148 @@ -import { type Accessor, createEffect, onCleanup } from 'solid-js'; +import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; import { type CustomToastConfig, toast } from './Toast'; +function readDismissed(storageKey: string): string[] { + if (typeof localStorage === 'undefined') return []; + try { + const parsed: unknown = JSON.parse( + localStorage.getItem(storageKey) ?? '[]' + ); + if (!Array.isArray(parsed)) return []; + return parsed.filter((key): key is string => typeof key === 'string'); + } catch { + return []; + } +} + +function writeDismissed(storageKey: string, keys: Iterable): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(storageKey, JSON.stringify([...keys])); + } catch { + // Storage may be unavailable or full. In-memory dismissal still works. + } +} + /** * Keep exactly one persistent toast alive per keyed item in a reactive set. * - * When an item leaves the set its toast is dismissed and its session dismissal - * is forgotten, so a later reappearance prompts again. A user-dismissed key is + * When an item leaves the set its toast is dismissed and its dismissal is + * forgotten, so a later reappearance prompts again. A user-dismissed key is * not re-prompted while the item remains in the set. All live toasts are * dismissed on owner cleanup. * * The factory receives a `dismiss` handle for action handlers that should * close the toast and suppress re-prompting for the session (e.g. after * kicking off a flow that will eventually remove the item from the set). + * + * `maxVisible` turns the set into a queue when there isn't room for all of it + * at once, and `persistKey` carries closes across reloads — see each below. */ export function useKeyedPersistentToasts(options: { items: Accessor; key: (item: T) => string; toast: (item: T, dismiss: () => void) => CustomToastConfig; + /** + * localStorage key under which explicit user dismissals are remembered + * across reloads. Set it for advisory prompts, where re-asking on every load + * is nagging and the action stays reachable elsewhere (e.g. settings). Leave + * it unset for prompts that must keep re-surfacing until resolved, like a + * dead inbox grant. + * + * Only a close counts: taking the action, or the toast being torn down for + * us, still re-prompts next session if the item is still there. + */ + persistKey?: string; + /** + * Cap on how many of these prompts are on screen at once. The rest queue up + * and take their turn as the visible ones are answered. Reactive, so a + * layout-dependent cap (a phone has room for one, a desktop corner for + * several) re-flows when it changes. Unset means show them all. + */ + maxVisible?: Accessor; + /** + * Whether `items` currently reflects real server state rather than a query + * that has not answered yet. Both look like an empty list from in here, and + * treating "still loading" as "the item is gone" would forget dismissals the + * moment the app starts. Defaults to true, which is right for a set that is + * synchronously derived. + */ + itemsLoaded?: Accessor; }): void { const toastIds = new Map(); const dismissed = new Set(); + /** + * Keys whose toast we tore down ourselves. Their `onDismiss` reports our own + * teardown — item left the set, action taken, owner disposed — rather than a + * user decision, so it must not be recorded as one. + */ + const selfDismissed = new Set(); + + const persistKey = options.persistKey; + const persisted = new Set(persistKey ? readDismissed(persistKey) : []); + for (const key of persisted) dismissed.add(key); + + // Dismissals free a slot for whatever is queued behind them, but they happen + // outside the effect and mutate plain sets. This is what re-runs it. + const [dismissals, setDismissals] = createSignal(0); + const onUserDismissed = () => setDismissals((count) => count + 1); const dismissToast = (key: string) => { const id = toastIds.get(key); if (id !== undefined) { + selfDismissed.add(key); toast.dismiss(id); toastIds.delete(key); } }; + const forget = (key: string) => { + dismissed.delete(key); + if (persistKey && persisted.delete(key)) { + writeDismissed(persistKey, persisted); + } + }; + createEffect(() => { + dismissals(); const items = options.items(); + const maxVisible = options.maxVisible?.() ?? Number.POSITIVE_INFINITY; const liveKeys = new Set(items.map(options.key)); for (const key of [...toastIds.keys()]) { if (!liveKeys.has(key)) dismissToast(key); } - for (const key of [...dismissed]) { - if (!liveKeys.has(key)) dismissed.delete(key); + if (options.itemsLoaded?.() ?? true) { + for (const key of [...dismissed]) { + if (!liveKeys.has(key)) forget(key); + } } for (const item of items) { const key = options.key(item); if (toastIds.has(key) || dismissed.has(key)) continue; + if (toastIds.size >= maxVisible) break; const suppress = () => { dismissed.add(key); dismissToast(key); + onUserDismissed(); }; const id = toast.custom(options.toast(item, suppress), { persistent: true, onDismiss: () => { toastIds.delete(key); + // Our own teardown already left `dismissed` how it wants it, and the + // unmount can land after the item was forgotten — re-adding here + // would strand a returning item. + if (selfDismissed.delete(key)) return; + dismissed.add(key); + if (persistKey && !persisted.has(key)) { + persisted.add(key); + writeDismissed(persistKey, persisted); + } + onUserDismissed(); }, }); toastIds.set(key, id); From 133e25ce6c690b1a32e83e43728a0d38142bfe26 Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:04:39 -0400 Subject: [PATCH 2/2] fix(toast): track prompt teardown by instance, honor a tightening cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A toast unmounts at the end of its exit animation. If its item left and returned in that window, the key already belonged to a replacement, and the departing toast's `onDismiss` retracted the replacement's entry and consumed its teardown marker — leaving a live toast untracked, so the next effect run built a duplicate, and a genuine close could be read as our own teardown and go unrecorded. Track live toasts by object identity so a stale unmount only answers for itself. `maxVisible` also only gated new toasts, so a cap that tightened under prompts already on screen — a tablet rotating into phone width — left them stacked on a layout with no room. Retract the surplus back into the queue instead. --- .../Toast/useKeyedPersistentToasts.test.ts | 51 +++++++++++++++++-- .../Toast/useKeyedPersistentToasts.ts | 50 +++++++++++------- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts index 520a4014e90..1b4df196daf 100644 --- a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts +++ b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.test.ts @@ -60,6 +60,7 @@ describe('useKeyedPersistentToasts', () => { ) { let setItems!: (items: Item[]) => void; let setLoaded!: (loaded: boolean) => void; + let setMaxVisible!: (max: number) => void; let dispose!: () => void; createRoot((d) => { dispose = d; @@ -67,24 +68,25 @@ describe('useKeyedPersistentToasts', () => { const [loaded, setLoadedSignal] = createSignal( options?.itemsLoaded ?? true ); + const [maxVisible, setMaxVisibleSignal] = createSignal( + options?.maxVisible ?? Number.POSITIVE_INFINITY + ); setItems = set; setLoaded = setLoadedSignal; + setMaxVisible = setMaxVisibleSignal; useKeyedPersistentToasts({ items, key: (item) => item.id, persistKey: options?.persistKey, itemsLoaded: loaded, - maxVisible: - options?.maxVisible === undefined - ? undefined - : () => options.maxVisible!, + maxVisible, toast: (item, dismiss) => ({ title: item.label, actions: [{ label: 'Go', onClick: dismiss }], }), }); }); - return { setItems, setLoaded, dispose }; + return { setItems, setLoaded, setMaxVisible, dispose }; } function titlesShown(): string[] { @@ -302,5 +304,44 @@ describe('useKeyedPersistentToasts', () => { expect(titlesShown()).toEqual(['A', 'B']); dispose(); }); + + it('retracts surplus prompts when the cap tightens', () => { + const { setMaxVisible, dispose } = mount(three); + expect(titlesShown()).toEqual(['A', 'B', 'C']); + + // e.g. a tablet rotating into phone width with three prompts up. + setMaxVisible(1); + expect(mocks.toastDismiss.mock.calls.flat()).toEqual([2, 3]); + dispose(); + }); + + it('re-shows a retracted prompt once the cap loosens again', () => { + const { setMaxVisible, dispose } = mount(three, { maxVisible: 1 }); + expect(titlesShown()).toEqual(['A']); + + setMaxVisible(3); + expect(titlesShown()).toEqual(['A', 'B', 'C']); + dispose(); + }); + }); + + it('lets a stale unmount retract only its own toast', () => { + const item = { id: 'a', label: 'A' }; + const { setItems, dispose } = mount([item]); + const staleUnmount = toastOptionsAt(0).onDismiss!; + + // Item leaves and returns inside the first toast's exit animation, so the + // key already belongs to a replacement by the time #1 finally unmounts. + setItems([]); + setItems([{ ...item }]); + expect(mocks.toastCustom).toHaveBeenCalledTimes(2); + + staleUnmount(); + + // The replacement is still tracked, so no duplicate is created for it and + // the stale teardown is not mistaken for a user dismissal. + setItems([{ ...item, label: 'A2' }]); + expect(mocks.toastCustom).toHaveBeenCalledTimes(2); + dispose(); }); }); diff --git a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts index d91cff1ed47..c36dd308b0b 100644 --- a/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts +++ b/apps/web/src/lib/core/component/Toast/useKeyedPersistentToasts.ts @@ -69,14 +69,22 @@ export function useKeyedPersistentToasts(options: { */ itemsLoaded?: Accessor; }): void { - const toastIds = new Map(); + /** + * A shown toast. Tracked by object identity rather than by key alone: a + * toast unmounts at the end of its exit animation, by which point the key + * may already belong to a replacement, and the departing toast must not + * retract or answer for it. + */ + type LiveToast = { id: number }; + + const live = new Map(); const dismissed = new Set(); /** - * Keys whose toast we tore down ourselves. Their `onDismiss` reports our own - * teardown — item left the set, action taken, owner disposed — rather than a - * user decision, so it must not be recorded as one. + * Toasts we tore down ourselves. Their `onDismiss` reports our own teardown + * — item left the set, action taken, cap tightened, owner disposed — rather + * than a user decision, so it must not be recorded as one. */ - const selfDismissed = new Set(); + const selfDismissed = new Set(); const persistKey = options.persistKey; const persisted = new Set(persistKey ? readDismissed(persistKey) : []); @@ -88,11 +96,11 @@ export function useKeyedPersistentToasts(options: { const onUserDismissed = () => setDismissals((count) => count + 1); const dismissToast = (key: string) => { - const id = toastIds.get(key); - if (id !== undefined) { - selfDismissed.add(key); - toast.dismiss(id); - toastIds.delete(key); + const entry = live.get(key); + if (entry) { + selfDismissed.add(entry); + toast.dismiss(entry.id); + live.delete(key); } }; @@ -109,7 +117,7 @@ export function useKeyedPersistentToasts(options: { const maxVisible = options.maxVisible?.() ?? Number.POSITIVE_INFINITY; const liveKeys = new Set(items.map(options.key)); - for (const key of [...toastIds.keys()]) { + for (const key of [...live.keys()]) { if (!liveKeys.has(key)) dismissToast(key); } if (options.itemsLoaded?.() ?? true) { @@ -117,25 +125,31 @@ export function useKeyedPersistentToasts(options: { if (!liveKeys.has(key)) forget(key); } } + // A cap can tighten under prompts that are already up — a tablet rotating + // into phone width, say. Retract the newest back into the queue rather + // than leaving them stacked on a layout with no room for them. + for (const key of [...live.keys()].slice(maxVisible)) dismissToast(key); for (const item of items) { const key = options.key(item); - if (toastIds.has(key) || dismissed.has(key)) continue; - if (toastIds.size >= maxVisible) break; + if (live.has(key) || dismissed.has(key)) continue; + if (live.size >= maxVisible) break; const suppress = () => { dismissed.add(key); dismissToast(key); onUserDismissed(); }; - const id = toast.custom(options.toast(item, suppress), { + const entry: LiveToast = { id: 0 }; + entry.id = toast.custom(options.toast(item, suppress), { persistent: true, onDismiss: () => { - toastIds.delete(key); + // Only retract ourselves; the key may already hold a replacement. + if (live.get(key) === entry) live.delete(key); // Our own teardown already left `dismissed` how it wants it, and the // unmount can land after the item was forgotten — re-adding here // would strand a returning item. - if (selfDismissed.delete(key)) return; + if (selfDismissed.delete(entry)) return; dismissed.add(key); if (persistKey && !persisted.has(key)) { @@ -145,11 +159,11 @@ export function useKeyedPersistentToasts(options: { onUserDismissed(); }, }); - toastIds.set(key, id); + live.set(key, entry); } }); onCleanup(() => { - for (const key of [...toastIds.keys()]) dismissToast(key); + for (const key of [...live.keys()]) dismissToast(key); }); }