fix(web): toasts are announced, errors linger, hover holds them, and an unreachable server says so (TASK-2202) (#1914)

* fix(web): toasts are announced, errors linger and are assertive, hover and focus hold them, and an unreachable server says so (TASK-2202)

* fix(web): the direct-fetch writes report an unreachable server too (TASK-2202 codex r1)
This commit is contained in:
xarmian
2026-10-08 19:17:28 -04:00
committed by GitHub
parent 89d9298ad0
commit bb38641ef7
12 changed files with 700 additions and 50 deletions
@@ -0,0 +1,47 @@
import { test, expect } from './fixtures';
import { browserLogin } from './lib/collab-helpers';
import { createWorkspace, deleteWorkspace } from './lib/attachment-viewer';
/**
* TASK-2202: when the server cannot be reached, the app says so, the error is
* announced assertively, and it stays long enough to read.
*
* The page loads normally; then every API request is aborted the way an
* offline network aborts it (`internetdisconnected`), and the command palette
* fires a search. The search's own UI may handle its failure however it likes;
* the "can't reach the server" toast comes from the API client's seam, so it
* appears whatever the call site does.
*/
test.describe('network unreachable toast (TASK-2202)', () => {
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'one browser is enough');
});
test('an unreachable server raises an assertive error toast that outlasts the old 3s', async ({ page, fixture, request }) => {
const { slug: ws } = await createWorkspace(fixture, request, 'Toast outage');
try {
await browserLogin(page);
await page.goto(`/${fixture.adminUsername}/${ws}`);
const announcer = page.locator('[data-toast-region="assertive"]');
await expect(announcer, 'the assertive region exists before any toast').toHaveCount(1);
await expect(announcer).toHaveAttribute('aria-live', 'assertive');
await expect(announcer.locator('.toast')).toHaveCount(0);
await page.route('**/api/v1/**', (route) => route.abort('internetdisconnected'));
await page.keyboard.press('ControlOrMeta+k');
await page.keyboard.type('anything');
const toast = page.locator('.toast-error', { hasText: "Can't reach the server" });
await expect(toast).toBeVisible();
await expect(announcer).toContainText("Can't reach the server");
// Errors linger: still up well past the old 3s default.
await page.waitForTimeout(4000);
await expect(toast).toBeVisible();
} finally {
await page.unroute('**/api/v1/**');
await deleteWorkspace(fixture, request, ws);
}
});
});
+87 -10
View File
@@ -522,6 +522,70 @@ function notifyRateLimited(retryAfterMs?: number): void {
}
}
type NetworkUnreachableHandler = () => void;
let networkUnreachableHandler: NetworkUnreachableHandler | null = null;
/**
* Register a single callback fired when a request cannot reach the server at
* all (TASK-2202): the browser's fetch rejected before any response, which is
* what an offline laptop or a dead route looks like. +layout.svelte wires it to
* a deduped "can't reach the server" toast, the same split as
* setRateLimitHandler, so client.ts stays free of the toast store.
*/
export function setNetworkUnreachableHandler(handler: NetworkUnreachableHandler | null): void {
networkUnreachableHandler = handler;
}
function notifyNetworkUnreachable(): void {
if (!networkUnreachableHandler) return;
try {
networkUnreachableHandler();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('network-unreachable handler threw', err);
}
}
/**
* The error a request throws when the server could not be reached (TASK-2202).
* Its own code, so call sites and the toast can tell it from a refusal the
* server made. A WRITE's message does not claim the change was lost: a fetch
* can fail after the request left (a connection reset mid-response), so, as
* with request_timeout, "not saved" is a claim nobody can make.
*/
export function networkUnreachableError(isIdempotent: boolean): PadApiError {
return new PadApiError({
code: 'network_unreachable',
message: isIdempotent
? "Can't reach the server. Check your connection and try again."
: "Can't reach the server, so your change may not have been saved. Check your connection and try again.",
});
}
/**
* `fetch` for a direct WRITE that bypasses request() (TASK-2202, codex r1):
* attachment upload and transform, artifact import, checkout. Same rule as
* requestAttempt: an abort keeps its own path (each site's deadline turns its
* timeout into request_timeout), and any other rejection, which happened
* before a response existed, reports the outage and becomes
* network_unreachable instead of a raw TypeError.
*/
async function fetchWrite(input: string, init: RequestInit): Promise<Response> {
try {
return await fetch(input, init);
} catch (err) {
if (init.signal?.aborted) throw err;
notifyNetworkUnreachable();
throw networkUnreachableError(false);
}
}
/** True for the error a request throws when the server could not be reached. */
export function isNetworkUnreachable(err: unknown): boolean {
return err instanceof PadApiError && err.code === 'network_unreachable';
}
/**
* Parse a URL path and decide whether a 403 on it indicates that the
* caller's READ access to the workspace's item set has been revoked.
@@ -904,12 +968,25 @@ async function requestAttempt<T>(
issuedAs: ReturnType<typeof currentIdentity>,
deadline: ReturnType<typeof requestDeadline>,
): Promise<T> {
const resp = await fetch(BASE + path, {
headers,
credentials: 'same-origin',
...options,
signal: deadline.signal,
});
let resp: Response;
try {
resp = await fetch(BASE + path, {
headers,
credentials: 'same-origin',
...options,
signal: deadline.signal,
});
} catch (err) {
// An abort, the caller's or our deadline's, keeps its own path:
// request() turns a timeout into request_timeout, and a caller's abort
// stays the AbortError it asked for. Anything else rejected before a
// response existed, so the server was not reached (TASK-2202). Only
// the fetch is wrapped: a TypeError from code below is a bug, not an
// outage, and must not be reported as one.
if (deadline.signal.aborted) throw err;
notifyNetworkUnreachable();
throw networkUnreachableError(isIdempotent);
}
// Every response, error statuses included, is a reading of the server's
// clock; the sync cursor is stamped from these, never from Date.now()
// (BUG-3207).
@@ -2984,7 +3061,7 @@ export const api = {
if (csrf) headers['X-CSRF-Token'] = csrf;
const qs = itemId ? `?item_id=${encodeURIComponent(itemId)}` : '';
const resp = await fetch(`${BASE}/workspaces/${workspaceSlug}/attachments${qs}`, {
const resp = await fetchWrite(`${BASE}/workspaces/${workspaceSlug}/attachments${qs}`, {
method: 'POST',
headers,
credentials: 'same-origin',
@@ -3052,7 +3129,7 @@ export const api = {
// created.
const deadline = requestDeadline(TRANSFORM_TIMEOUT_MS);
try {
const resp = await fetch(
const resp = await fetchWrite(
`${BASE}/workspaces/${workspaceSlug}/attachments/${attachmentId}/transform`,
{
method: 'POST',
@@ -3323,7 +3400,7 @@ export const api = {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown' };
const csrf = getCSRFToken();
if (csrf) headers['X-CSRF-Token'] = csrf;
const resp = await fetch(`${BASE}/workspaces/${ws}/import-artifact`, {
const resp = await fetchWrite(`${BASE}/workspaces/${ws}/import-artifact`, {
method: 'POST',
headers,
credentials: 'same-origin',
@@ -3427,7 +3504,7 @@ export const api = {
// disabled on "Redirecting…" until it settles.
const deadline = requestDeadline(requestTimeoutMs);
try {
const r = await fetch('/billing/checkout', {
const r = await fetchWrite('/billing/checkout', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
+119
View File
@@ -0,0 +1,119 @@
// TASK-2202 — a request that cannot reach the server says so.
//
// A fetch that rejects before any response (offline, a dead route) used to
// surface as a raw TypeError, so every call site showed its generic failure
// text and nothing said the server was unreachable. It is now a PadApiError
// with its own code, and the registered handler fires once per such request.
// Aborts keep their own paths, and an error thrown AFTER a response exists is
// not relabelled.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
api,
PadApiError,
isNetworkUnreachable,
setNetworkUnreachableHandler,
setRequestTimeoutForTests
} from './client';
let notified = 0;
beforeEach(() => {
vi.unstubAllGlobals();
notified = 0;
setNetworkUnreachableHandler(() => {
notified++;
});
});
afterEach(() => {
vi.unstubAllGlobals();
setNetworkUnreachableHandler(null);
setRequestTimeoutForTests();
});
describe('network_unreachable (TASK-2202)', () => {
it('a GET whose fetch rejects is network_unreachable, and the handler fires once', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch'))));
const err = await api.workspaces.get('ws').catch((e) => e);
expect(err).toBeInstanceOf(PadApiError);
expect(err.code).toBe('network_unreachable');
expect(isNetworkUnreachable(err)).toBe(true);
expect(err.message).toBe("Can't reach the server. Check your connection and try again.");
expect(notified).toBe(1);
});
it("a WRITE's message does not claim the change was lost", async () => {
const fetchMock = vi.fn(async () => Promise.reject(new TypeError('Failed to fetch')));
vi.stubGlobal('fetch', fetchMock);
const err = await api.workspaces.create({ name: 'x' } as never).catch((e) => e);
expect(err.code).toBe('network_unreachable');
expect(err.message).toContain('may not have been saved');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("a caller's abort stays an AbortError and does not report an outage", async () => {
vi.stubGlobal(
'fetch',
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal;
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
})
)
);
const ctl = new AbortController();
const p = api.items.copyPreflight('ws', 'item', {} as never, { signal: ctl.signal }).catch((e) => e);
ctl.abort();
const err = await p;
expect(err).not.toBeInstanceOf(PadApiError);
expect((err as DOMException).name).toBe('AbortError');
expect(notified).toBe(0);
});
it('a timeout stays request_timeout and does not report an outage', async () => {
setRequestTimeoutForTests(30);
vi.stubGlobal(
'fetch',
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal;
signal.addEventListener('abort', () => reject(new TypeError('Failed to fetch')), { once: true });
})
)
);
const err = await api.workspaces.get('ws').catch((e) => e);
expect(err.code).toBe('request_timeout');
expect(notified).toBe(0);
});
it('the direct-fetch WRITES (attachment upload, artifact import) report it too (codex r1)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch'))));
const upload = await api.attachments.upload('ws', new Blob(['x'])).catch((e) => e);
expect(upload.code).toBe('network_unreachable');
expect(upload.message).toContain('may not have been saved');
const imported = await api.importArtifact('ws', '# body').catch((e) => e);
expect(imported.code).toBe('network_unreachable');
expect(notified).toBe(2);
});
it('a TypeError after a response arrived is not relabelled as an outage', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
({
status: 200,
ok: true,
headers: new Headers(),
json: async () => {
throw new TypeError('body went wrong');
}
}) as unknown as Response
)
);
const err = await api.workspaces.get('ws').catch((e) => e);
expect(isNetworkUnreachable(err)).toBe(false);
expect(notified).toBe(0);
});
});
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the rune-based toast store so this stays a node test, like the busy
// toast's test.
vi.mock('$lib/stores/toast.svelte', () => ({
toastStore: { show: vi.fn() }
}));
import { toastStore } from '$lib/stores/toast.svelte';
import {
notifyNetworkUnreachable,
NETWORK_UNREACHABLE_MESSAGE,
__resetNetworkUnreachableToastForTest
} from './networkUnreachableToast';
const showMock = toastStore.show as ReturnType<typeof vi.fn>;
describe('notifyNetworkUnreachable (TASK-2202)', () => {
beforeEach(() => {
showMock.mockClear();
__resetNetworkUnreachableToastForTest();
});
it('shows one ERROR toast with the unreachable message (an error, so it lingers and is assertive)', () => {
expect(notifyNetworkUnreachable(0)).toBe(true);
expect(showMock).toHaveBeenCalledTimes(1);
expect(showMock).toHaveBeenCalledWith(NETWORK_UNREACHABLE_MESSAGE, 'error');
});
it('an outage burst shows one toast, and a later outage shows another', () => {
notifyNetworkUnreachable(0);
notifyNetworkUnreachable(100);
notifyNetworkUnreachable(14_999);
expect(showMock).toHaveBeenCalledTimes(1);
expect(notifyNetworkUnreachable(15_000)).toBe(true);
expect(showMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,39 @@
import { toastStore } from '$lib/stores/toast.svelte';
/**
* "Can't reach the server" toast for requests whose fetch rejected before any
* response (TASK-2202). The API client fires `setNetworkUnreachableHandler`
* once per such request; +layout.svelte wires that seam here, the same split
* as serverBusyToast, so client.ts stays free of the toast store.
*
* It exists because most call sites show a fixed string ("Failed to save")
* whatever went wrong, so an outage and a validation refusal looked the same.
* This says WHY, once, beside whatever the call site shows.
*
* Deduped: an outage fails every in-flight request at once, and a stack of
* identical toasts helps nobody. At most one inside DEDUPE_WINDOW_MS.
*/
const DEDUPE_WINDOW_MS = 15_000;
export const NETWORK_UNREACHABLE_MESSAGE =
"Can't reach the server. Check your connection; changes made now may not be saved.";
let lastShownAt = Number.NEGATIVE_INFINITY;
/**
* Show the deduped error toast. An error, so it is announced assertively and
* lingers (toast store defaults). Returns whether a toast was shown; `now` is
* injectable for tests.
*/
export function notifyNetworkUnreachable(now: number = Date.now()): boolean {
if (now - lastShownAt < DEDUPE_WINDOW_MS) return false;
lastShownAt = now;
toastStore.show(NETWORK_UNREACHABLE_MESSAGE, 'error');
return true;
}
/** Test-only: reset the dedupe clock. */
export function __resetNetworkUnreachableToastForTest(): void {
lastShownAt = Number.NEGATIVE_INFINITY;
}
@@ -18,39 +18,84 @@
goto(toast.link);
}
}
// ANNOUNCEMENT (TASK-2202). The stacks below ARE the live regions, and
// both are mounted for the page's whole life, empty until a toast arrives:
// screen readers announce a node added to a region that already exists,
// and miss one that arrives together with its region. The single stack
// used to be wrapped in {#if toasts.length}, so a toast after an idle
// spell mounted with its region and was usually never read.
//
// Errors go in an assertive region (they interrupt), the rest in a polite
// one. aria-atomic is left false, so only the added toast is read, not the
// whole stack. Each toast is its own keyed node, so the same text twice is
// still a new node and is read again. The text lives in the toast itself,
// once: no hidden copy for a test or a screen reader to find twice.
const errors = $derived(toastStore.toasts.filter((t) => t.type === 'error'));
const others = $derived(toastStore.toasts.filter((t) => t.type !== 'error'));
// HOLD (TASK-2202): a toast stays while the pointer is over it or focus is
// inside it. The two are separate holds, so leaving with the mouse while a
// button inside still has focus keeps it up.
function holdOnFocusOut(e: FocusEvent, id: string) {
const next = e.relatedTarget as Node | null;
if (next && (e.currentTarget as HTMLElement).contains(next)) return;
toastStore.resume(id);
}
function holdOnFocusIn(e: FocusEvent, id: string) {
const prev = e.relatedTarget as Node | null;
if (prev && (e.currentTarget as HTMLElement).contains(prev)) return;
toastStore.pause(id);
}
</script>
{#if toastStore.toasts.length > 0}
<div class="toast-container" role="status" aria-live="polite">
{#each toastStore.toasts as toast (toast.id)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="toast toast-{toast.type}"
class:clickable={!!toast.link}
in:fly={{ x: 80, duration: 250 }}
out:fade={{ duration: 150 }}
onclick={() => handleClick(toast)}
role={toast.link ? 'button' : undefined}
tabindex={toast.link ? 0 : -1}
onkeydown={(e) => { if (toast.link && e.key === 'Enter') handleClick(toast); }}
>
<span class="toast-icon">{iconForType(toast.type)}</span>
<span class="toast-message">{toast.message}{#if toast.link}<span class="toast-link-hint"> →</span>{/if}</span>
{#if toast.action}
<button
class="toast-action"
onclick={(e) => { e.stopPropagation(); toast.action?.onAction(); toastStore.dismiss(toast.id); }}
>{toast.action.label}</button>
{/if}
<button
class="toast-dismiss"
onclick={(e) => { e.stopPropagation(); toastStore.dismiss(toast.id); }}
aria-label="Dismiss notification"
>&times;</button>
</div>
{/each}
{#snippet toastItem(toast: Toast)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<div
class="toast toast-{toast.type}"
class:clickable={!!toast.link}
data-toast-id={toast.id}
in:fly={{ x: 80, duration: 250 }}
out:fade={{ duration: 150 }}
onclick={() => handleClick(toast)}
onmouseenter={() => toastStore.pause(toast.id)}
onmouseleave={() => toastStore.resume(toast.id)}
onfocusin={(e) => holdOnFocusIn(e, toast.id)}
onfocusout={(e) => holdOnFocusOut(e, toast.id)}
role={toast.link ? 'button' : undefined}
tabindex={toast.link ? 0 : -1}
onkeydown={(e) => {
if (!toast.link || e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick(toast);
}
}}
>
<span class="toast-icon">{iconForType(toast.type)}</span>
<span class="toast-message">{toast.message}{#if toast.link}<span class="toast-link-hint"> →</span>{/if}</span>
{#if toast.action}
<button
class="toast-action"
onclick={(e) => { e.stopPropagation(); toast.action?.onAction(); toastStore.dismiss(toast.id); }}
>{toast.action.label}</button>
{/if}
<button
class="toast-dismiss"
onclick={(e) => { e.stopPropagation(); toastStore.dismiss(toast.id); }}
aria-label="Dismiss notification"
>&times;</button>
</div>
{/if}
{/snippet}
<div class="toast-container">
<div class="toast-stack" aria-live="assertive" data-toast-region="assertive">
{#each errors as toast (toast.id)}{@render toastItem(toast)}{/each}
</div>
<div class="toast-stack" aria-live="polite" data-toast-region="polite">
{#each others as toast (toast.id)}{@render toastItem(toast)}{/each}
</div>
</div>
<style>
.toast-container {
@@ -65,6 +110,12 @@
max-width: 360px;
}
.toast-stack {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.toast {
display: flex;
align-items: center;
@@ -0,0 +1,142 @@
// TASK-2202 — the toast live regions exist before any toast does, errors go to
// the assertive one, hover and focus hold a toast, and a link toast answers
// Space as well as Enter (but not keys pressed on its own buttons).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render } from '@testing-library/svelte';
import { flushSync, tick } from 'svelte';
const nav = vi.hoisted(() => ({ goto: [] as string[] }));
vi.mock('$app/navigation', () => ({
goto: vi.fn(async (url: string) => {
nav.goto.push(url);
})
}));
import ToastContainer from './ToastContainer.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
const regions = (c: HTMLElement) => ({
polite: c.querySelector('[aria-live="polite"]') as HTMLElement | null,
assertive: c.querySelector('[aria-live="assertive"]') as HTMLElement | null
});
// jsdom has no Web Animations API, which Svelte's fly/fade transitions use.
// A stand-in that finishes at once: these tests are about announcements and
// timers, not motion.
if (!('animate' in Element.prototype)) {
(Element.prototype as unknown as { animate: unknown }).animate = function () {
const animation = {
onfinish: null as null | (() => void),
cancel() {},
finish() {},
currentTime: 0,
addEventListener() {},
finished: Promise.resolve()
};
queueMicrotask(() => animation.onfinish?.());
return animation;
};
}
beforeEach(() => {
nav.goto.length = 0;
toastStore.clearAll();
});
afterEach(() => {
cleanup();
toastStore.clearAll();
vi.useRealTimers();
});
describe('ToastContainer announcements (TASK-2202)', () => {
it('both live regions are mounted and empty before any toast exists', () => {
const { container } = render(ToastContainer);
const r = regions(container);
expect(r.polite, 'polite region exists with no toasts').not.toBeNull();
expect(r.assertive, 'assertive region exists with no toasts').not.toBeNull();
expect(r.polite!.textContent?.trim()).toBe('');
expect(r.assertive!.textContent?.trim()).toBe('');
});
it('an error is announced in the assertive region, a success in the polite one, each once', async () => {
const { container } = render(ToastContainer);
const r = regions(container);
const politeBefore = r.polite;
toastStore.show('Saved', 'success');
await tick();
toastStore.show('Could not save', 'error');
await tick();
expect(r.polite!.textContent).toContain('Saved');
expect(r.polite!.textContent).not.toContain('Could not save');
expect(r.assertive!.textContent).toContain('Could not save');
// The SAME region element: content was added to a region that already
// existed, which is what screen readers announce.
expect(regions(container).polite).toBe(politeBefore);
// The toast itself sits in its region, once: no hidden copy of the
// text that a screen reader (or a test) would find twice.
expect(r.assertive!.querySelector('.toast-error')).not.toBeNull();
expect(r.polite!.querySelector('.toast-error')).toBeNull();
expect(container.textContent!.split('Could not save').length - 1).toBe(1);
});
it('the same text twice in a row is a new node, so it is read again', async () => {
const { container } = render(ToastContainer);
toastStore.show('Saved', 'success');
await tick();
toastStore.show('Saved', 'success');
await tick();
expect(regions(container).polite!.querySelectorAll('.toast').length).toBe(2);
});
});
describe('ToastContainer hold and keys (TASK-2202)', () => {
it('hovering a toast holds it past its duration; leaving lets it go', async () => {
vi.useFakeTimers();
const { container } = render(ToastContainer);
const id = toastStore.show('Saved', 'success', 3000);
flushSync();
const el = container.querySelector(`[data-toast-id="${id}"]`) as HTMLElement;
await fireEvent.mouseEnter(el);
vi.advanceTimersByTime(10_000);
expect(toastStore.toasts.some((t) => t.id === id)).toBe(true);
await fireEvent.mouseLeave(el);
vi.advanceTimersByTime(3000);
expect(toastStore.toasts.some((t) => t.id === id)).toBe(false);
});
it('focus inside a toast holds it, and moving focus between its own buttons keeps the hold', async () => {
vi.useFakeTimers();
const { container } = render(ToastContainer);
const id = toastStore.show('Archived', 'success', 3000, undefined, { label: 'Undo', onAction: () => {} });
flushSync();
const el = container.querySelector(`[data-toast-id="${id}"]`) as HTMLElement;
const undo = el.querySelector('.toast-action') as HTMLElement;
const dismiss = el.querySelector('.toast-dismiss') as HTMLElement;
await fireEvent.focusIn(undo, { relatedTarget: null });
await fireEvent.focusOut(undo, { relatedTarget: dismiss });
await fireEvent.focusIn(dismiss, { relatedTarget: undo });
vi.advanceTimersByTime(10_000);
expect(toastStore.toasts.some((t) => t.id === id), 'held while focus stays inside').toBe(true);
await fireEvent.focusOut(dismiss, { relatedTarget: null });
vi.advanceTimersByTime(3000);
expect(toastStore.toasts.some((t) => t.id === id)).toBe(false);
});
it('a link toast navigates on Space as well as Enter', async () => {
const { container } = render(ToastContainer);
const id = toastStore.show('Open it', 'info', 3000, '/somewhere');
flushSync();
const el = container.querySelector(`[data-toast-id="${id}"]`) as HTMLElement;
await fireEvent.keyDown(el, { key: ' ' });
expect(nav.goto).toEqual(['/somewhere']);
});
it('Enter on the Dismiss button dismisses without navigating', async () => {
const { container } = render(ToastContainer);
const id = toastStore.show('Open it', 'info', 3000, '/somewhere');
flushSync();
const dismiss = container.querySelector(`[data-toast-id="${id}"] .toast-dismiss`) as HTMLElement;
await fireEvent.keyDown(dismiss, { key: 'Enter' });
expect(nav.goto).toEqual([]);
});
});
+56 -9
View File
@@ -27,6 +27,12 @@ export interface HistoryEntry {
const MAX_TOASTS = 5;
const MAX_HISTORY = 20;
const DEFAULT_DURATION = 3000;
/**
* Errors linger (TASK-2202): an error is the toast a person most needs to read,
* and at 3s it vanished before most could. Applied when the caller passes no
* duration; an explicit duration still wins.
*/
export const ERROR_DURATION = 10000;
/**
* Test-surface kill switch for CROSS-ACTOR notification toasts (BUG-2334).
@@ -56,13 +62,30 @@ export function quietExternalToasts(): boolean {
let toasts = $state<Toast[]>([]);
let history = $state<HistoryEntry[]>([]);
let unreadCount = $state(0);
const timers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* Each toast's auto-dismiss clock. `holds` counts the reasons it is paused
* (hover, keyboard focus: TASK-2202); the clock runs only at zero holds, and
* `remaining` is what is left of the duration when it was last paused.
*/
interface ToastClock {
timer: ReturnType<typeof setTimeout> | null;
startedAt: number;
remaining: number;
holds: number;
}
const timers = new Map<string, ToastClock>();
function startClock(id: string, clock: ToastClock): void {
clock.startedAt = Date.now();
clock.timer = setTimeout(() => dismiss(id), clock.remaining);
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function show(message: string, type: Toast['type'] = 'info', duration: number = DEFAULT_DURATION, link?: string, action?: ToastAction): string {
function show(message: string, type: Toast['type'] = 'info', duration?: number, link?: string, action?: ToastAction): string {
duration ??= type === 'error' ? ERROR_DURATION : DEFAULT_DURATION;
const id = generateId();
const toast: Toast = { id, message, type, duration, link, action };
@@ -84,14 +107,36 @@ function show(message: string, type: Toast['type'] = 'info', duration: number =
}
// Auto-dismiss after duration
const timer = setTimeout(() => {
dismiss(id);
}, duration);
timers.set(id, timer);
const clock: ToastClock = { timer: null, startedAt: 0, remaining: duration, holds: 0 };
timers.set(id, clock);
startClock(id, clock);
return id;
}
/**
* Hold a toast on screen while the pointer is over it or focus is inside it
* (TASK-2202): a person reading or reaching for its button should not have it
* vanish. Holds nest, so a hover that ends while focus stays keeps it held.
*/
function pause(id: string): void {
const clock = timers.get(id);
if (!clock) return;
clock.holds++;
if (clock.holds > 1 || clock.timer === null) return;
clearTimeout(clock.timer);
clock.timer = null;
clock.remaining = Math.max(0, clock.remaining - (Date.now() - clock.startedAt));
}
/** Release one hold; the clock resumes with what was left when the last one goes. */
function resume(id: string): void {
const clock = timers.get(id);
if (!clock || clock.holds === 0) return;
clock.holds--;
if (clock.holds === 0) startClock(id, clock);
}
function dismiss(id: string): void {
clearTimerFor(id);
const idx = toasts.findIndex((t) => t.id === id);
@@ -101,9 +146,9 @@ function dismiss(id: string): void {
}
function clearTimerFor(id: string): void {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
const clock = timers.get(id);
if (clock) {
if (clock.timer !== null) clearTimeout(clock.timer);
timers.delete(id);
}
}
@@ -142,6 +187,8 @@ export const toastStore = {
},
show,
dismiss,
pause,
resume,
markAllRead,
clearHistory,
clearAll
@@ -0,0 +1,81 @@
// TASK-2202 — errors linger, and a toast someone is reading or reaching for
// does not vanish under them. Fake timers, so durations are exact.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toastStore, ERROR_DURATION } from './toast.svelte';
const present = (id: string) => toastStore.toasts.some((t) => t.id === id);
beforeEach(() => {
vi.useFakeTimers();
toastStore.clearAll();
});
afterEach(() => {
toastStore.clearAll();
vi.useRealTimers();
});
describe('toast durations (TASK-2202)', () => {
it('an error with no duration stays for ERROR_DURATION, well past the 3s default', () => {
expect(ERROR_DURATION).toBeGreaterThanOrEqual(8000);
const id = toastStore.show('Could not save', 'error');
vi.advanceTimersByTime(3000);
expect(present(id), 'still up at the old 3s').toBe(true);
vi.advanceTimersByTime(ERROR_DURATION - 3000 - 1);
expect(present(id)).toBe(true);
vi.advanceTimersByTime(1);
expect(present(id)).toBe(false);
});
it('success keeps the 3s default, and an explicit duration still wins for errors', () => {
const ok = toastStore.show('Saved', 'success');
const brief = toastStore.show('Brief error', 'error', 2000);
vi.advanceTimersByTime(2000);
expect(present(brief)).toBe(false);
vi.advanceTimersByTime(1000);
expect(present(ok)).toBe(false);
});
});
describe('toast hold (TASK-2202)', () => {
it('a paused toast stays, and resumes with only what was left', () => {
const id = toastStore.show('Saved', 'success', 3000);
vi.advanceTimersByTime(2000);
toastStore.pause(id);
vi.advanceTimersByTime(60_000);
expect(present(id), 'held while paused').toBe(true);
toastStore.resume(id);
vi.advanceTimersByTime(999);
expect(present(id)).toBe(true);
vi.advanceTimersByTime(1);
expect(present(id), 'gone after the remaining 1s').toBe(false);
});
it('holds nest: hover ending while focus stays inside keeps it up', () => {
const id = toastStore.show('Saved', 'success', 3000);
toastStore.pause(id); // hover
toastStore.pause(id); // focus
toastStore.resume(id); // hover ends
vi.advanceTimersByTime(10_000);
expect(present(id)).toBe(true);
toastStore.resume(id); // focus leaves
vi.advanceTimersByTime(3000);
expect(present(id)).toBe(false);
});
it('an unmatched resume does not start a second clock', () => {
const id = toastStore.show('Saved', 'success', 3000);
toastStore.resume(id);
vi.advanceTimersByTime(3000);
expect(present(id)).toBe(false);
});
it('dismissing a paused toast leaves nothing behind', () => {
const id = toastStore.show('Saved', 'success', 3000);
toastStore.pause(id);
toastStore.dismiss(id);
toastStore.resume(id);
vi.advanceTimersByTime(5000);
expect(present(id)).toBe(false);
expect(toastStore.toasts.length).toBe(0);
});
});
+8 -1
View File
@@ -14,8 +14,9 @@
} from '$lib/stores/identityReload.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import { titleStore } from '$lib/stores/title.svelte';
import { setAccessRevokedHandler, setRateLimitHandler, setIdentityProvider } from '$lib/api/client';
import { setAccessRevokedHandler, setRateLimitHandler, setIdentityProvider, setNetworkUnreachableHandler } from '$lib/api/client';
import { notifyServerBusy } from '$lib/api/serverBusyToast';
import { notifyNetworkUnreachable } from '$lib/api/networkUnreachableToast';
import { localIndex } from '$lib/stores/localIndex.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import TopBar from '$lib/components/layout/TopBar.svelte';
@@ -125,6 +126,12 @@
setRateLimitHandler((retryAfterMs) => {
notifyServerBusy(retryAfterMs);
});
// A request whose fetch rejected before any response (TASK-2202): one
// deduped "can't reach the server" error toast, beside whatever the call
// site shows. Same split as the busy toast above.
setNetworkUnreachableHandler(() => {
notifyNetworkUnreachable();
});
onMount(() => {
// Re-arm the one-shot workspace-load latch whenever the signed-in user
@@ -35,6 +35,7 @@ vi.mock('$lib/api/client', () => {
// without it throws before any assertion runs.
setIdentityProvider: () => {},
setRateLimitHandler: () => {},
setNetworkUnreachableHandler: () => {},
isPlanLimitError: () => false,
planLimitMessage: () => '',
};
@@ -27,6 +27,7 @@ vi.mock('$lib/api/client', () => ({
// without it throws before any assertion runs.
setIdentityProvider: () => {},
setRateLimitHandler: () => {},
setNetworkUnreachableHandler: () => {},
isPlanLimitError: () => false,
planLimitMessage: () => '',
}));