From b99fdffd1f05822810b304aa93b880c6fbd51ec5 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 6 Jul 2026 15:10:40 +0200 Subject: [PATCH 1/3] Stop debounced entry resets surfacing "Cancelled" error toast runed's useDebounce rejects pending promises with the string "Cancelled" when cancel() supersedes them. In EntryLoaderService these rejections went unhandled (notably the fire-and-forget reset in the watch), so the global error handler surfaced a red "Cancelled" toast during rapid filter typing or entry switching. Swallow only that specific rejection at the debounced call sites, rethrowing anything else so real errors still surface. Co-Authored-By: Claude Fable 5 --- .../src/lib/services/entry-loader-service.svelte.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts b/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts index 84ea9b65c1..f4ed80433d 100644 --- a/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts +++ b/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts @@ -27,6 +27,14 @@ const EVENT_DEBOUNCE_MS = 600; // void (not Promise) to get the correct runtime return type. type DebouncedVoidFn = ReturnType>; +// useDebounce rejects a pending promise with the string "Cancelled" when its +// cancel() supersedes it (a newer reset took over). That's expected control flow, +// not an error; anything else is rethrown so real failures still surface. +function ignoreDebounceCancelled(reason: unknown): void { + if (reason === 'Cancelled') return; + throw reason; +} + export class EntryLoaderService { static readonly DEFAULT_GENERATION = 0; @@ -122,7 +130,7 @@ export class EntryLoaderService { // Filter resets take precedence; any entry events that arrive mid-reset are // replayed afterward via the eventPendingAfterFilterReset flag to ensure all updates are // eventually applied to the shown data. - const filterResetPromise = this.#debouncedFilterReset(); + const filterResetPromise = this.#debouncedFilterReset().catch(ignoreDebounceCancelled); this.#filterResetInFlight = filterResetPromise; await this.#filterResetInFlight; if (this.#filterResetInFlight === filterResetPromise) { @@ -135,7 +143,7 @@ export class EntryLoaderService { } #scheduleEventReset(): Promise { - return this.#debouncedEventReset(); + return this.#debouncedEventReset().catch(ignoreDebounceCancelled); } async #executeReset(isQuiet: boolean): Promise { From 40858cdf44a05f14e1cf275c1dddc2a5068c2d25 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 6 Jul 2026 15:17:23 +0200 Subject: [PATCH 2/3] Test ignoreDebounceCancelled swallow/rethrow branches Export the helper and assert "Cancelled" is swallowed while any other rejection still propagates, per CodeRabbit review on #2415. The service-level reset path cannot exercise the rethrow branch since #executeReset swallows its own fetch errors, so this covers the helper directly. Co-Authored-By: Claude Fable 5 --- .../services/entry-loader-service.svelte.test.ts | 14 +++++++++++++- .../lib/services/entry-loader-service.svelte.ts | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts b/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts index 3fe32c3ddf..3434098c34 100644 --- a/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts +++ b/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts @@ -1,6 +1,6 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {EntryLoaderService} from '$lib/services/entry-loader-service.svelte'; +import {EntryLoaderService, ignoreDebounceCancelled} from '$lib/services/entry-loader-service.svelte'; import type {IEntry} from '$lib/dotnet-types'; import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; import {defaultEntry} from '$lib/utils'; @@ -204,6 +204,18 @@ describe('EntryLoaderService', () => { }); }); + describe('ignoreDebounceCancelled', () => { + it('swallows the "Cancelled" rejection useDebounce.cancel() produces', () => { + expect(ignoreDebounceCancelled('Cancelled')).toBeUndefined(); + }); + + it('rethrows any other rejection so genuine errors still surface', () => { + const failure = new Error('boom'); + expect(() => ignoreDebounceCancelled(failure)).toThrow(failure); + expect(() => ignoreDebounceCancelled('other')).toThrow('other'); + }); + }); + describe('race conditions', () => { it('discards stale count when reset invalidates in-flight fetch', async () => { const entries = makeEntries(200); diff --git a/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts b/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts index f4ed80433d..e81f182afd 100644 --- a/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts +++ b/frontend/viewer/src/lib/services/entry-loader-service.svelte.ts @@ -30,7 +30,7 @@ type DebouncedVoidFn = ReturnType>; // useDebounce rejects a pending promise with the string "Cancelled" when its // cancel() supersedes it (a newer reset took over). That's expected control flow, // not an error; anything else is rethrown so real failures still surface. -function ignoreDebounceCancelled(reason: unknown): void { +export function ignoreDebounceCancelled(reason: unknown): void { if (reason === 'Cancelled') return; throw reason; } From dcf713189fd05145e3900ab495b5bc40894117f0 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 6 Jul 2026 15:23:58 +0200 Subject: [PATCH 3/3] Fix debounce test broken by Cancelled-rejection wrapper The .catch(ignoreDebounceCancelled) wrapper makes quietReset() return a fresh promise per call, so the old expect(p1).toBe(p2) identity checks no longer hold. Collapse behavior is unchanged and already covered by the single-API-call assertion; assert that directly instead of promise identity. Co-Authored-By: Claude Fable 5 --- .../lib/services/entry-loader-service.svelte.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts b/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts index 3434098c34..3708515a41 100644 --- a/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts +++ b/frontend/viewer/src/lib/services/entry-loader-service.svelte.test.ts @@ -189,15 +189,8 @@ describe('EntryLoaderService', () => { await service.getOrLoadEntryByIndex(0); const countBefore = api.countEntries.mock.calls.length; - // Multiple rapid calls - const p1 = service.quietReset(); - const p2 = service.quietReset(); - const p3 = service.quietReset(); - - expect(p1).toBe(p2); - expect(p2).toBe(p3); - - await p3; + // Multiple rapid calls collapse into a single reset after the debounce delay + await Promise.all([service.quietReset(), service.quietReset(), service.quietReset()]); // Should only have called the API once (after the debounce delay) expect(api.countEntries.mock.calls.length).toBe(countBefore + 1);