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..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 @@ -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'; @@ -189,21 +189,26 @@ 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); }); }); + 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 84ea9b65c1..e81f182afd 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. +export 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 {