Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions frontend/viewer/src/lib/services/entry-loader-service.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ const EVENT_DEBOUNCE_MS = 600;
// void (not Promise<void>) to get the correct runtime return type.
type DebouncedVoidFn = ReturnType<typeof useDebounce<[], void>>;

// 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;

Expand Down Expand Up @@ -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) {
Expand All @@ -135,7 +143,7 @@ export class EntryLoaderService {
}

#scheduleEventReset(): Promise<void> {
return this.#debouncedEventReset();
return this.#debouncedEventReset().catch(ignoreDebounceCancelled);
}

async #executeReset(isQuiet: boolean): Promise<void> {
Expand Down
Loading