perf: replace svelte-sonner with our own toast stack #836

Closed
kayg wants to merge 0 commits from perf/own-toast into dev
Owner

Replaces svelte-sonner (first-paint JS) with a small toast stack of our own (stack.svelte.ts + Toasts.svelte). Same capsule (ToastCapsule/ToastRow), same notify/toast() API, same bar path.

bundle (brotli, 55f35fcd → this) before after
first-paint JS 240 KB 233 KB
total JS 312 KB 306 KB
first-paint CSS 20.9 KB 18.8 KB
total CSS 35.2 KB 33.2 KB

Stack: aria-live polite region, label "Notifications"; Undo and actions are real buttons (keyboard); pause on mouse hover; swipe down to dismiss; reduced motion turns off the rise-in. Removed svelte-sonner, its patch, runed from bun.lock; [data-sonner-toast] selectors are now [data-toast]. Mockup Toasts no longer takes theme.

Gates (dev-01): bundle gate ok; check 0 errors; apps/web unit 78, ui primitives unit 46; lint ok. Toast-asserting specs (14 files), --workers=1: chromium-1440 91 passed, chromium-390 82, webkit-1440 91, webkit-390 80 (rest skipped by project). Full chromium-1440: 417 passed. Full webkit-390: 856 passed, 2 failed in phone-edges with ENOENT on trace files (my two full runs shared one test-results dir); phone-edges alone on webkit-390: 144 passed.

Screenshots before/after (Undo toast): /tmp/claude-30033/scratch-toast/ on the control host.
Decision: the replaced-toast quick cut is now just removal from the list. Replacement size not measured separately against the 1.5 KB target; total first-paint saving is 7 KB.

Replaces svelte-sonner (first-paint JS) with a small toast stack of our own (`stack.svelte.ts` + `Toasts.svelte`). Same capsule (ToastCapsule/ToastRow), same `notify`/`toast()` API, same bar path. | bundle (brotli, 55f35fcd → this) | before | after | |---|---|---| | first-paint JS | 240 KB | 233 KB | | total JS | 312 KB | 306 KB | | first-paint CSS | 20.9 KB | 18.8 KB | | total CSS | 35.2 KB | 33.2 KB | Stack: aria-live polite region, label "Notifications"; Undo and actions are real buttons (keyboard); pause on mouse hover; swipe down to dismiss; reduced motion turns off the rise-in. Removed svelte-sonner, its patch, `runed` from bun.lock; `[data-sonner-toast]` selectors are now `[data-toast]`. Mockup `Toasts` no longer takes `theme`. Gates (dev-01): bundle gate ok; `check` 0 errors; apps/web unit 78, ui primitives unit 46; lint ok. Toast-asserting specs (14 files), `--workers=1`: chromium-1440 91 passed, chromium-390 82, webkit-1440 91, webkit-390 80 (rest skipped by project). Full chromium-1440: 417 passed. Full webkit-390: 856 passed, 2 failed in phone-edges with ENOENT on trace files (my two full runs shared one test-results dir); phone-edges alone on webkit-390: 144 passed. Screenshots before/after (Undo toast): /tmp/claude-30033/scratch-toast/ on the control host. Decision: the replaced-toast quick cut is now just removal from the list. Replacement size not measured separately against the 1.5 KB target; total first-paint saving is 7 KB.
Replace svelte-sonner with our own small toast stack
Some checks failed
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled
c98b6ee9e6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Independent review

Reviewed c98b6ee9 (perf/own-toast) against dev 55f35fcd. I read the code and ran the specs on dev-01 with separate --output dirs. There were no ENOENT failures this time.

Verdict: changes requested. One P1 is a regression in a spec that passes on dev. Everything else is parity polish.

Test runs (dev-01, ~/bin/slot, --workers=1)

  • apps/web: toast-bar, trash, select-rows, swipe-rows on webkit-390 (36 passed, 10 skipped) and on chromium-1440 (40 passed, 6 skipped).
  • packages/ui primitives (playwright test -c src/primitives/playwright.config.ts toast): chromium 48 passed, 1 failed. webkit 48 passed, 1 failed.
    • Failing in both browsers: toast.spec.ts:41 a bulk toast with a working Undo is never replaced: the next one waits, and the Undo still works. It times out at line 53, waiting for "Export B ready".
    • The same spec on dev 55f35fcd (chromium): 13 passed. So this is a regression from the PR.
  • The web runs used the bm5-origin-dev backend binary. The toast code is frontend-only.

P1

Hover pause never ends after a click on a toast, so "Undone" never leaves and the queue behind it stalls.

  • stack.svelte.ts keeps one global hovered flag. It only clears on pointerleave or when the stack empties.
  • You click Undo with the mouse, so the pointer stays on the toast. The toast then changes to "Undone" with show(id, 1800), which calls start(). start() returns early because hovered is true, so no timer runs.
  • "Undone" stays up until the pointer moves off. The queued toast B never appears. That is what the spec catches.
  • Pausing on a plain "Undone" has no point, because nothing is left to act on. Pick one fix:
    • Don't pause toasts without an action.
    • Make a toast that changes in place start its clock regardless of hovered.
    • Pause only the toast under the pointer.

P2

  1. No pause on keyboard focus or on touch hold. Hover is the only pause, and it is mouse-only (e.pointerType === "mouse"). As far as I remember, svelte-sonner also paused while the toaster had focus or a pointer was down. I did not verify that in its source. A keyboard user who tabs to Undo can lose the toast mid-reach (8 s), and a finger resting on a toast doesn't hold it.
    • Add onfocusin/onfocusout on the section, calling the same pause.
    • Also treat pointerdown/pointerup as a pause for touch.
  2. The stack is unbounded. Sonner had visibleToasts={3}. notify keeps one toast on screen, except waits (loading/undoing toasts are exempt from replacement) and track() calls. Many parallel track() calls stack up with no cap and push toasts off the top of a phone screen.
    • Cap ids (for example 3 or 4), or let waits share one toast.
    • No leak: clocks, ids and timers are removed in dismiss, and show reuses the same entry by id.
  3. No test covers stack.svelte.ts. The timers and the hover pause have only the e2e spec, and that spec is what caught P1. Add a vitest with fake timers for these cases:
    • hover then leave
    • a re-show while hovered
    • an infinite wait
    • dismiss clears the timer

P3

  • Swipe-to-dismiss cuts out instantly. Sonner slid the toast away. The transform translate is reset and the item is removed on the same frame, so the toast jumps back for a frame in some cases. TASTE says springy and physical. A short slide-down or fade on dismiss would fit. Swipe is also distance-only (40 px), with no flick velocity.
  • hovered is reset when the stack empties. If a replacement toast appears under a pointer that is still there, it times out without a pause until the pointer re-enters.
  • ToastCapsule cleanup: onMount schedules setTimeout(…, 90) and does not clear it on destroy. It is harmless because it guards on inner, but it should be cleared. The unused closeToast? prop type is still there.
  • The sonner focus hotkey (Alt+T) is gone. I expect that's fine, since nothing in the UI documents it.
  • notify.test.ts is left with a double blank line where the mocks were.

Parity checked by reading and judged OK

  • Replacement vs stacking: same rules in notify. The old data-removed fast cut becomes an immediate removal, and the new item gets the arrive animation.
  • Durations: result 5 s, error 9 s, Undo 8 s, settle 1.8 s, wait infinite. All unchanged.
  • Hover pause: works for the stack, with remaining time kept correctly (left -= elapsed).
  • Hidden tab: timers keep running, the same as sonner's default. The Undo window can pass while the tab is in the background. This is the old behaviour, but "pause while hidden" is a cheap improvement.
  • Toast during a page transition: the stack is mounted in App.svelte, outside routes, so it survives.
  • Undo after the toast started leaving: undo() returns early when the view is gone. Double Undo is guarded. An Undo in flight when the toast is dismissed does not show "Undone".
  • Many toasts quickly: bounded by the one-at-a-time replacement, except waits (see P2.2).
  • A11y: one polite region (section with aria-live), and the sentence is announced once via .sr in ToastRow. It is debounced, and the visible letters are aria-hidden. No double announce. The action buttons sit in normal tab order, and swipe capture skips button,a, so nothing traps focus. Reduced motion removes the arrive animation and the item transition, and the capsule snaps instead of springing.
  • Memory: dismiss clears the timer and map entries. Toasts unmount leaves finite timers that run out by themselves (module state).
## Independent review Reviewed c98b6ee9 (perf/own-toast) against dev 55f35fcd. I read the code and ran the specs on dev-01 with separate `--output` dirs. There were no ENOENT failures this time. **Verdict: changes requested.** One P1 is a regression in a spec that passes on dev. Everything else is parity polish. ### Test runs (dev-01, `~/bin/slot`, `--workers=1`) - `apps/web`: `toast-bar`, `trash`, `select-rows`, `swipe-rows` on `webkit-390` (36 passed, 10 skipped) and on `chromium-1440` (40 passed, 6 skipped). - `packages/ui` primitives (`playwright test -c src/primitives/playwright.config.ts toast`): chromium 48 passed, 1 failed. webkit 48 passed, 1 failed. - Failing in both browsers: `toast.spec.ts:41 a bulk toast with a working Undo is never replaced: the next one waits, and the Undo still works`. It times out at line 53, waiting for "Export B ready". - The same spec on dev 55f35fcd (chromium): 13 passed. So this is a regression from the PR. - The web runs used the `bm5-origin-dev` backend binary. The toast code is frontend-only. ### P1 **Hover pause never ends after a click on a toast, so "Undone" never leaves and the queue behind it stalls.** - `stack.svelte.ts` keeps one global `hovered` flag. It only clears on `pointerleave` or when the stack empties. - You click Undo with the mouse, so the pointer stays on the toast. The toast then changes to "Undone" with `show(id, 1800)`, which calls `start()`. `start()` returns early because `hovered` is true, so no timer runs. - "Undone" stays up until the pointer moves off. The queued toast B never appears. That is what the spec catches. - Pausing on a plain "Undone" has no point, because nothing is left to act on. Pick one fix: - Don't pause toasts without an action. - Make a toast that changes in place start its clock regardless of `hovered`. - Pause only the toast under the pointer. ### P2 1. **No pause on keyboard focus or on touch hold.** Hover is the only pause, and it is mouse-only (`e.pointerType === "mouse"`). As far as I remember, svelte-sonner also paused while the toaster had focus or a pointer was down. I did not verify that in its source. A keyboard user who tabs to Undo can lose the toast mid-reach (8 s), and a finger resting on a toast doesn't hold it. - Add `onfocusin`/`onfocusout` on the section, calling the same pause. - Also treat `pointerdown`/`pointerup` as a pause for touch. 2. **The stack is unbounded.** Sonner had `visibleToasts={3}`. `notify` keeps one toast on screen, except waits (loading/undoing toasts are exempt from replacement) and `track()` calls. Many parallel `track()` calls stack up with no cap and push toasts off the top of a phone screen. - Cap `ids` (for example 3 or 4), or let waits share one toast. - No leak: `clocks`, `ids` and timers are removed in `dismiss`, and `show` reuses the same entry by id. 3. **No test covers `stack.svelte.ts`.** The timers and the hover pause have only the e2e spec, and that spec is what caught P1. Add a vitest with fake timers for these cases: - hover then leave - a re-show while hovered - an infinite wait - dismiss clears the timer ### P3 - **Swipe-to-dismiss cuts out instantly.** Sonner slid the toast away. The transform `translate` is reset and the item is removed on the same frame, so the toast jumps back for a frame in some cases. TASTE says springy and physical. A short slide-down or fade on dismiss would fit. Swipe is also distance-only (40 px), with no flick velocity. - **`hovered` is reset when the stack empties.** If a replacement toast appears under a pointer that is still there, it times out without a pause until the pointer re-enters. - **`ToastCapsule` cleanup:** `onMount` schedules `setTimeout(…, 90)` and does not clear it on destroy. It is harmless because it guards on `inner`, but it should be cleared. The unused `closeToast?` prop type is still there. - The sonner focus hotkey (Alt+T) is gone. I expect that's fine, since nothing in the UI documents it. - `notify.test.ts` is left with a double blank line where the mocks were. ### Parity checked by reading and judged OK - **Replacement vs stacking:** same rules in `notify`. The old `data-removed` fast cut becomes an immediate removal, and the new item gets the `arrive` animation. - **Durations:** result 5 s, error 9 s, Undo 8 s, settle 1.8 s, wait infinite. All unchanged. - **Hover pause:** works for the stack, with remaining time kept correctly (`left -= elapsed`). - **Hidden tab:** timers keep running, the same as sonner's default. The Undo window can pass while the tab is in the background. This is the old behaviour, but "pause while hidden" is a cheap improvement. - **Toast during a page transition:** the stack is mounted in `App.svelte`, outside routes, so it survives. - **Undo after the toast started leaving:** `undo()` returns early when the view is gone. Double Undo is guarded. An Undo in flight when the toast is dismissed does not show "Undone". - **Many toasts quickly:** bounded by the one-at-a-time replacement, except waits (see P2.2). - **A11y:** one polite region (`section` with `aria-live`), and the sentence is announced once via `.sr` in `ToastRow`. It is debounced, and the visible letters are `aria-hidden`. No double announce. The action buttons sit in normal tab order, and swipe capture skips `button,a`, so nothing traps focus. Reduced motion removes the arrive animation and the item transition, and the capsule snaps instead of springing. - **Memory:** `dismiss` clears the timer and map entries. `Toasts` unmount leaves finite timers that run out by themselves (module state).
Author
Owner

Review fixes pushed (head 5f7d1a59).

  • P1: holds are per toast. A toast changed in place (Undo → Undone) starts unheld; a dismissed toast drops its hold; a toast mounted under a resting pointer checks :hover. toast.spec.ts:41 passes again.
  • P2: hold also on keyboard focus-within and touch/pen press; only the newest 3 are drawn (older ones keep their clocks); new stack.test.ts (9 cases).
  • P3: swipe-dismiss slides down and fades 160 ms (instant under reduced motion); ToastCapsule's 90 ms timer is cleared on unmount; double blank line removed.

Gates (dev-01, separate --output dirs, --workers=1): check 0 errors; unit 78 + 55; lint ok; primitives toast specs chromium 49 passed, webkit toast.spec 13 passed (CI=1 skips it on webkit by config, so run without CI); app toast specs chromium-1440 91 passed, webkit-390 80 passed (backend binary from bm5-origin-dev, frontend-only change).
Bundle (brotli): first-paint JS 233 KB, total JS 306 KB (dev: 240 / 312).

Review fixes pushed (head 5f7d1a59). - P1: holds are per toast. A toast changed in place (Undo → Undone) starts unheld; a dismissed toast drops its hold; a toast mounted under a resting pointer checks `:hover`. `toast.spec.ts:41` passes again. - P2: hold also on keyboard focus-within and touch/pen press; only the newest 3 are drawn (older ones keep their clocks); new `stack.test.ts` (9 cases). - P3: swipe-dismiss slides down and fades 160 ms (instant under reduced motion); ToastCapsule's 90 ms timer is cleared on unmount; double blank line removed. Gates (dev-01, separate `--output` dirs, `--workers=1`): check 0 errors; unit 78 + 55; lint ok; primitives toast specs chromium 49 passed, webkit `toast.spec` 13 passed (CI=1 skips it on webkit by config, so run without CI); app toast specs chromium-1440 91 passed, webkit-390 80 passed (backend binary from bm5-origin-dev, frontend-only change). Bundle (brotli): first-paint JS 233 KB, total JS 306 KB (dev: 240 / 312).
Author
Owner

Closing: head 5f7d1a59 is already in dev (reached via a4a06aa5); nothing left to merge. Branch kept.

Closing: head 5f7d1a59 is already in dev (reached via a4a06aa5); nothing left to merge. Branch kept.
kayg closed this pull request 2026-11-04 13:04:57 +00:00
Some checks failed
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
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!836
No description provided.