From 0d68ae9a17cec26848bee6101415ebe042d78dd9 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 19:29:35 +0900 Subject: fix(concurrency): silent background refresh — no loading flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2s status poll (and post-mutation limit reloads) toggled a visible loading state, but since the refresh is near-instant the spinner flashed for <1 frame — a visible flicker/jerk every poll. The polling is now SILENT (mirrors the heartbeat runs list): - refreshLimits/refreshStatus: re-entrancy guard (limitsInFlight/statusInFlight, no UI), no loading-state toggle. Error cleared only on success so it stays visible + stable during an in-flight retry (no vanish-mid-poll). - Removed limitsLoading/statusLoading: the Refresh buttons are plain-text (no spinner, not disabled); the empty-state guards use hasLoaded* only (no && !loading), so the list area never reflows mid-refresh. +1 regression-guard test (Refresh buttons never surface a spinner). typecheck 0/0, 926 tests green (x2), biome clean, build OK. --- src/features/concurrency/ui/ConcurrencyView.svelte | 49 ++++++++++++---------- .../concurrency/ui/ConcurrencyView.test.ts | 32 ++++++++++++++ 2 files changed, 59 insertions(+), 22 deletions(-) (limited to 'src/features') diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index 1b52f55..af38e83 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -43,9 +43,14 @@ // ── Limits (config: list / add / update / remove) ──────────────────────────── let limits = $state([]); - let limitsLoading = $state(false); let limitsError = $state(null); + /** True after the first load settles (gates the empty state). */ let hasLoadedLimits = $state(false); + /** Re-entrancy guard for background/silent refreshes (no UI — prevents + * overlapping fetches). The refresh is near-instant, so a visible loading + * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the + * heartbeat runs list). */ + let limitsInFlight = false; // Add-form state. The provider id is chosen from a dropdown of known providers // (derived from the available models + any already-configured limit providers). @@ -81,13 +86,16 @@ }); async function refreshLimits(): Promise { - limitsLoading = true; - limitsError = null; + if (limitsInFlight) return; + limitsInFlight = true; const result = await loadLimits(); - limitsLoading = false; + limitsInFlight = false; hasLoadedLimits = true; if (result.ok) { limits = result.limits; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-refresh. + limitsError = null; } else { limitsError = result.error; } @@ -130,9 +138,13 @@ // ── Live status (polls while mounted) ─────────────────────────────────────── let statusEntries = $state([]); - let statusLoading = $state(false); let statusError = $state(null); + /** True after the first load settles (gates the empty state). */ let hasLoadedStatus = $state(false); + /** Re-entrancy guard for the 2s background poll (no UI — a visible loading + * indicator flickered every poll because the refresh is near-instant; it stays + * INVISIBLE, mirroring the heartbeat runs list). */ + let statusInFlight = false; let now = $state(Date.now()); // A 1s clock so a `paused — resumes in Ns` countdown ticks live between polls. @@ -147,13 +159,16 @@ const statusSummary = $derived(summarizeStatus(statusEntries, now)); async function refreshStatus(): Promise { - statusLoading = true; - statusError = null; + if (statusInFlight) return; + statusInFlight = true; const result = await loadStatus(); - statusLoading = false; + statusInFlight = false; hasLoadedStatus = true; if (result.ok) { statusEntries = result.providers; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-poll. + statusError = null; } else { statusError = result.error; } @@ -184,15 +199,10 @@

@@ -257,7 +267,7 @@ {#if limitsError}

{limitsError}

- {:else if hasLoadedLimits && limitViews.length === 0 && !limitsLoading} + {:else if hasLoadedLimits && limitViews.length === 0}

No limits configured — providers run unlimited.

{:else}
    @@ -277,15 +287,10 @@

    @@ -297,7 +302,7 @@ {#if statusError}

    {statusError}

    - {:else if hasLoadedStatus && statusViews.length === 0 && !statusLoading} + {:else if hasLoadedStatus && statusViews.length === 0}

    No limits configured — nothing to report.

    {:else}
      diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index 14882f1..3dc8e78 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -81,6 +81,38 @@ describe("ConcurrencyView", () => { expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); }); + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { + // The 2s status poll + post-mutation reloads are SILENT: they never toggle a + // visible loading state, so the Refresh buttons are plain-text (no spinner) + // and the list area never reflows mid-refresh. Regression guard for the + // flicker fix (mirrors the heartbeat runs list). + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + models: MODELS, + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + + const statusRefresh = screen.getByLabelText("Refresh concurrency status"); + expect(statusRefresh).toHaveTextContent("Refresh"); + expect(statusRefresh.querySelector(".loading-spinner")).toBeNull(); + expect(statusRefresh).not.toBeDisabled(); + + const limitsRefresh = screen.getByLabelText("Refresh concurrency limits"); + expect(limitsRefresh).toHaveTextContent("Refresh"); + expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull(); + + // A manual refresh stays silent too (no spinner appears). + await fakes.loadStatus(); + expect(statusRefresh.querySelector(".loading-spinner")).toBeNull(); + }); + it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); -- cgit v1.2.3