Toasts: repeated actions stack empty glass pills; dedupe identical toasts, busy state on async buttons #616

Closed
opened 2026-10-01 10:04:50 +00:00 by kayg · 8 comments
Owner

Owner report (2026-10-01, screenshot): "interesting bug when I spam 'Sync now'"

Repeated identical actions stack toasts. The stacked card behind the front toast renders as an empty glass pill (no text) peeking out below.
Fix (AppToaster/toast store):

  1. Dedupe: an identical toast (same message + kind, or the same id for the same action/item) replaces the visible one and restarts its timer, with a subtle bump (the shared spring), never a stack. Give action toasts a stable id per action + target (e.g. mail-sync:<account>), so repeated clicks update one toast.
  2. Stack rendering: when different toasts do stack, the cards behind show their own content (Sonner-style collapsed stack: scaled, slightly offset, readable on hover/expand) or are fully hidden. Never an empty pill.
  3. Buttons that start async work (Sync now, Test connection, …) are disabled or show a spinner while the request is in flight, so spamming cannot queue duplicates (reuse the shared button busy state).
    Test: an e2e clicks Sync now 5× fast: one toast is visible, no empty card exists in the DOM/stack, and the button shows busy. A unit test for toast dedupe by id.
## Owner report (2026-10-01, screenshot): "interesting bug when I spam 'Sync now'" Repeated identical actions stack toasts. The stacked card behind the front toast renders as an **empty glass pill** (no text) peeking out below. **Fix (AppToaster/toast store):** 1. **Dedupe:** an identical toast (same message + kind, or the same `id` for the same action/item) **replaces** the visible one and restarts its timer, with a subtle bump (the shared spring), never a stack. Give action toasts a stable id per action + target (e.g. `mail-sync:<account>`), so repeated clicks update one toast. 2. **Stack rendering:** when different toasts do stack, the cards behind show their own content (Sonner-style collapsed stack: scaled, slightly offset, readable on hover/expand) or are fully hidden. Never an empty pill. 3. Buttons that start async work (Sync now, Test connection, …) are disabled or show a spinner while the request is in flight, so spamming cannot queue duplicates (reuse the shared button busy state). **Test:** an e2e clicks Sync now 5× fast: one toast is visible, no empty card exists in the DOM/stack, and the button shows busy. A unit test for toast dedupe by id.
Author
Owner

Owner report 2 (2026-10-01): moving a Journal entry across days several times shows "Journal entry moved to 17:15 · Undo" in front and two empty glass pills stacked behind it. So the blank stack happens for different toasts too, not only identical ones: point 2 (stack rendering) is the core bug. Also give move toasts a stable id per item (move:<item id>), so repeated moves of the same entry update one toast. Its Undo must undo the latest move, or offer to undo all moves of that item: state which, following the macOS Calendar undo convention.

Owner report 2 (2026-10-01): moving a Journal entry across days several times shows "Journal entry moved to 17:15 · Undo" in front and **two empty glass pills stacked behind it**. So the blank stack happens for **different** toasts too, not only identical ones: point 2 (stack rendering) is the core bug. Also give move toasts a stable id per item (`move:<item id>`), so repeated moves of the same entry update one toast. Its Undo must undo the **latest** move, or offer to undo all moves of that item: state which, following the macOS Calendar undo convention.
Author
Owner

Starting issue #616 on branch job/toaststack-616 from base 9c50871eda. I am tracing the shared toast store and AppToaster stack, Mail sync busy handling, and Journal move Undo before implementing the fix and regression coverage.

Starting issue #616 on branch job/toaststack-616 from base 9c50871eda199ed69ef2fe3af8b44bec1bc5ebd5. I am tracing the shared toast store and AppToaster stack, Mail sync busy handling, and Journal move Undo before implementing the fix and regression coverage.
Author
Owner

Correction to my start note: the job branch starts at 9a6c38c3b7; local origin/dev is currently three commits ahead at 9c50871eda. I will merge origin/dev once before the final gates, as required.

Correction to my start note: the job branch starts at 9a6c38c3b7c8bbbdad9919cd2419f773d9c34b6e; local origin/dev is currently three commits ahead at 9c50871eda199ed69ef2fe3af8b44bec1bc5ebd. I will merge origin/dev once before the final gates, as required.
Author
Owner

Finding: AppToaster.svelte set every child of a collapsed, older toast to opacity: 0, while leaving its glass card visible. This created the reported blank pills. Toasts without explicit IDs also received a new sequence ID on every call, so repeated Sync and move messages could stack. Mail already disables Sync now and sets aria-busy during the queue request; I am adding a handler guard and testing the delayed request.

Decision for Journal moves: follow macOS Calendar's one-operation-at-a-time Undo convention. A repeated move of one Log entry replaces its toast and binds Undo to the latest successful move shown. Earlier successful moves stay as separate ⌘Z steps.

Finding: `AppToaster.svelte` set every child of a collapsed, older toast to `opacity: 0`, while leaving its glass card visible. This created the reported blank pills. Toasts without explicit IDs also received a new sequence ID on every call, so repeated Sync and move messages could stack. Mail already disables Sync now and sets `aria-busy` during the queue request; I am adding a handler guard and testing the delayed request. Decision for Journal moves: follow macOS Calendar's one-operation-at-a-time Undo convention. A repeated move of one Log entry replaces its toast and binds Undo to the latest successful move shown. Earlier successful moves stay as separate ⌘Z steps.
Author
Owner

Additional E2E finding: the Mail sync route returned its declared HTTP 202, but MailSection did not pass emptyResponseStatuses: [202] to apiFetch. The client then tried to parse the empty success body as JSON and showed the failure toast. I am adding the endpoint's declared empty response status to the call; the delayed five-click probe confirmed the button stayed busy and sent one request.

Additional E2E finding: the Mail sync route returned its declared HTTP 202, but `MailSection` did not pass `emptyResponseStatuses: [202]` to `apiFetch`. The client then tried to parse the empty success body as JSON and showed the failure toast. I am adding the endpoint's declared empty response status to the call; the delayed five-click probe confirmed the button stayed busy and sent one request.
Author
Owner

Production screenshots are attached for Mail and Calendar at phone (390 px), tablet (820 px), and desktop (1440 px), in paper and tokyo-night themes.

The new issue-specific production E2E passes. I also ran the existing bun run test:e2e:calendar; it stops before its Log-move section at e2e/calendar.mjs:1403 because the existing composer assertion for .endsWith(' Frozen snapshot') fails (the send carries the snapshot: 13:29 nFrozen snapshot). I left the expectation unchanged. The issue-specific E2E now exercises two real moves and confirms toast Undo restores the latest move while keyboard Undo restores the prior move, following the macOS Calendar one-operation-at-a-time convention.

Production screenshots are attached for Mail and Calendar at phone (390 px), tablet (820 px), and desktop (1440 px), in paper and tokyo-night themes. - Calendar: [390 paper](https://git.kayg.org/attachments/3f76046a-6d66-46b3-aead-5ebc804b34eb), [390 tokyo-night](https://git.kayg.org/attachments/2a1f7c73-48a4-427c-a8f6-d136202ee6b2), [820 paper](https://git.kayg.org/attachments/398c6329-44d5-4ed0-b2ad-53ab16529ae1), [820 tokyo-night](https://git.kayg.org/attachments/7ad6d360-4bc7-417e-9d9c-2999bada8c88), [1440 paper](https://git.kayg.org/attachments/6b989d19-3cf1-43f1-91d0-c845dc41c73b), [1440 tokyo-night](https://git.kayg.org/attachments/3acdcb49-bb81-43b1-9ca3-c4efa6ed68dc). - Mail: [390 paper](https://git.kayg.org/attachments/25c4c519-cecb-4485-8857-16b077a0c1a5), [390 tokyo-night](https://git.kayg.org/attachments/645a2b21-6113-43f7-892c-81bed36b97f9), [820 paper](https://git.kayg.org/attachments/d35f5fcc-b3a3-4ddb-bd04-d1fd07464bd4), [820 tokyo-night](https://git.kayg.org/attachments/a9bc8331-a07a-4b23-ac0e-9698b6b5d5f6), [1440 paper](https://git.kayg.org/attachments/2c68d10e-09b7-41a6-a423-834440794bf1), [1440 tokyo-night](https://git.kayg.org/attachments/1e8b6d01-30e9-466b-9ad4-86590c0f94ad). The new issue-specific production E2E passes. I also ran the existing `bun run test:e2e:calendar`; it stops before its Log-move section at `e2e/calendar.mjs:1403` because the existing composer assertion for `.endsWith(' Frozen snapshot')` fails (`the send carries the snapshot: 13:29 nFrozen snapshot`). I left the expectation unchanged. The issue-specific E2E now exercises two real moves and confirms toast Undo restores the latest move while keyboard Undo restores the prior move, following the macOS Calendar one-operation-at-a-time convention.
Author
Owner

Finished: toaststack-616

Branch: job/toaststack-616
Head: f9fc48abe042474426a12d682f4a15a9aef7cf4c

Built

  • The toast adapter dedupes live toasts by message and kind, or by stable action/item id. Replacement updates the action, restarts the timer ring and adds a shared-spring bump. Collapsed cards keep their own text.
  • Mail Sync now has one busy request per account and stable id mail-sync:<account id>. Test connection and Sync now disable while in flight. The client accepts the route's empty 202 response as success.
  • Journal move toasts use move:<Log id>. The latest toast Undo restores that move; the previous move stays on the keyboard undo stack.
  • Added the requested rapid-click and repeated-move production E2E, store dedupe tests, and a toast replacement bench profile.

Files

apps/web/src/lib/stores/toasts.svelte.ts, apps/web/src/lib/stores/toasts.svelte.test.ts, apps/web/src/lib/components/AppToaster.svelte, apps/web/src/lib/components/ToastBody.svelte, apps/web/src/routes/calendar/[view]/[date]/+page.svelte, apps/web/src/routes/settings/mail/MailSection.svelte, apps/web/src/routes/settings/mail/MailSection.svelte.test.ts, apps/web/e2e/toaststack-616.mjs, apps/web/package.json, bench/toast-ring-539.mjs, docs/perf/baseline.json.

Gates and probes

Verbatim result lines:

$ cargo fmt --check
(exit code 0; no stdout or stderr)

$ cargo clippy -p calternal-plugin-mail --all-targets -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4m 17s

$ cargo test -p calternal-plugin-mail
test result: ok. 39 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 1.31s

$ cargo clippy -p calternal-server --all-targets -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 9m 18s

$ cargo test -p calternal-server
test result: ok. 107 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 10.89s

$ bun run check
User browser caches use userStorage; only documented device/public-link exceptions remain.
Text sizes and UI shape values use shared role tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/toaststack-616/apps/web
Getting Svelte diagnostics...
svelte-check found 0 errors and 0 warnings

$ bun run test
Test Files  148 passed (148)
Tests  1016 passed (1016)
Duration 64.69s (transform 50%, environment 18%, import 17%, tests 11%, setup 3%)

$ bun run test:e2e:toaststack-616
PASS: five Sync now clicks queued once, one non-empty toast rendered, and 12 production screenshots saved to /home/kayg/Developer/calternal-wt/toaststack-616/artifacts/toaststack-616

$ bun tests/adversarial/mail_api.mjs
Mail API probe: remote-content isolation, hostile IDs, cross-User message/thread/attachment isolation, safe attachment names and 24 parallel account/Inbox reads passed

$ cargo test -p calternal-plugin-mail real_tls_provider_backfill -- --ignored --nocapture
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 40 filtered out; finished in 25.08s

$ cargo clean
Removed 15535 files, 7.9GiB total

The real-provider test backfilled 2,000 messages over 159 UID windows. First 100: 893 ms; page p50/p95: 120.96/170.85 ms. The visual evidence comment above attaches all 12 production screenshots across Mail and Calendar, 390/820/1440 px, light/dark.

Performance

The local production profile is in docs/perf/baseline.json under toast_dedupe_616 (host load before: 16.28, 18.32, 16.34; not the perf VM). Average repeated replacement p50/p95 was 18.5/54.2 ms at 390 px and 27.7/69.5 ms at 1440 px; browser CPU was 0.57/0.66 s and RSS after was 469,975,040/547,176,448 bytes.

A 50-update repeat burst kept one toast: 65.3 ms and 470,216,704 bytes peak at 390 px; 168.4 ms and 559,153,152 bytes at 1440 px. The same run's 50 distinct-toast burst rendered 50 cards in 492.4/970.8 ms and peaked at 522,223,616/634,417,152 bytes.

Known gap

bun run test:e2e:calendar stops before its move section at apps/web/e2e/calendar.mjs:1403: the composer assertion .endsWith(' Frozen snapshot') fails with the send carries the snapshot: 13:29 nFrozen snapshot. I left its expected behavior unchanged. The dedicated #616 production E2E covers the real two-move Undo path and passes.

Decisions

  • Equal message and kind share one live toast even if callers pass different ids. Stable item ids replace changed messages and callbacks.
  • A move toast undoes the latest move only. This follows macOS Calendar's one-operation-at-a-time convention; ⌘Z still undoes the previous move.
  • Collapsed stacked cards remain readable with their own contents instead of being fully hidden.
## Finished: toaststack-616 Branch: `job/toaststack-616` Head: `f9fc48abe042474426a12d682f4a15a9aef7cf4c` ### Built - The toast adapter dedupes live toasts by message and kind, or by stable action/item id. Replacement updates the action, restarts the timer ring and adds a shared-spring bump. Collapsed cards keep their own text. - Mail Sync now has one busy request per account and stable id `mail-sync:<account id>`. Test connection and Sync now disable while in flight. The client accepts the route's empty `202` response as success. - Journal move toasts use `move:<Log id>`. The latest toast Undo restores that move; the previous move stays on the keyboard undo stack. - Added the requested rapid-click and repeated-move production E2E, store dedupe tests, and a toast replacement bench profile. ### Files `apps/web/src/lib/stores/toasts.svelte.ts`, `apps/web/src/lib/stores/toasts.svelte.test.ts`, `apps/web/src/lib/components/AppToaster.svelte`, `apps/web/src/lib/components/ToastBody.svelte`, `apps/web/src/routes/calendar/[view]/[date]/+page.svelte`, `apps/web/src/routes/settings/mail/MailSection.svelte`, `apps/web/src/routes/settings/mail/MailSection.svelte.test.ts`, `apps/web/e2e/toaststack-616.mjs`, `apps/web/package.json`, `bench/toast-ring-539.mjs`, `docs/perf/baseline.json`. ### Gates and probes Verbatim result lines: ```text $ cargo fmt --check (exit code 0; no stdout or stderr) $ cargo clippy -p calternal-plugin-mail --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 4m 17s $ cargo test -p calternal-plugin-mail test result: ok. 39 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 1.31s $ cargo clippy -p calternal-server --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 9m 18s $ cargo test -p calternal-server test result: ok. 107 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 10.89s $ bun run check User browser caches use userStorage; only documented device/public-link exceptions remain. Text sizes and UI shape values use shared role tokens. UI transitions and animation options use shared motion tokens or documented exceptions. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/toaststack-616/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings $ bun run test Test Files 148 passed (148) Tests 1016 passed (1016) Duration 64.69s (transform 50%, environment 18%, import 17%, tests 11%, setup 3%) $ bun run test:e2e:toaststack-616 PASS: five Sync now clicks queued once, one non-empty toast rendered, and 12 production screenshots saved to /home/kayg/Developer/calternal-wt/toaststack-616/artifacts/toaststack-616 $ bun tests/adversarial/mail_api.mjs Mail API probe: remote-content isolation, hostile IDs, cross-User message/thread/attachment isolation, safe attachment names and 24 parallel account/Inbox reads passed $ cargo test -p calternal-plugin-mail real_tls_provider_backfill -- --ignored --nocapture test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 40 filtered out; finished in 25.08s $ cargo clean Removed 15535 files, 7.9GiB total ``` The real-provider test backfilled 2,000 messages over 159 UID windows. First 100: 893 ms; page p50/p95: 120.96/170.85 ms. The visual evidence comment above attaches all 12 production screenshots across Mail and Calendar, 390/820/1440 px, light/dark. ### Performance The local production profile is in `docs/perf/baseline.json` under `toast_dedupe_616` (host load before: 16.28, 18.32, 16.34; not the perf VM). Average repeated replacement p50/p95 was 18.5/54.2 ms at 390 px and 27.7/69.5 ms at 1440 px; browser CPU was 0.57/0.66 s and RSS after was 469,975,040/547,176,448 bytes. A 50-update repeat burst kept one toast: 65.3 ms and 470,216,704 bytes peak at 390 px; 168.4 ms and 559,153,152 bytes at 1440 px. The same run's 50 distinct-toast burst rendered 50 cards in 492.4/970.8 ms and peaked at 522,223,616/634,417,152 bytes. ### Known gap `bun run test:e2e:calendar` stops before its move section at `apps/web/e2e/calendar.mjs:1403`: the composer assertion `.endsWith(' Frozen snapshot')` fails with `the send carries the snapshot: 13:29 nFrozen snapshot`. I left its expected behavior unchanged. The dedicated #616 production E2E covers the real two-move Undo path and passes. ### Decisions - Equal message and kind share one live toast even if callers pass different ids. Stable item ids replace changed messages and callbacks. - A move toast undoes the latest move only. This follows macOS Calendar's one-operation-at-a-time convention; ⌘Z still undoes the previous move. - Collapsed stacked cards remain readable with their own contents instead of being fully hidden.
Author
Owner

Fixed in 585451b79 (merge-round-6, now in origin/dev); covered by apps/web/e2e/toaststack-616.mjs and toasts.svelte.test.ts.

Fixed in 585451b79 (merge-round-6, now in origin/dev); covered by apps/web/e2e/toaststack-616.mjs and toasts.svelte.test.ts.
kayg closed this issue 2026-10-03 12:48:27 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#616
No description provided.