diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 19:29:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 19:29:35 +0900 |
| commit | 0d68ae9a17cec26848bee6101415ebe042d78dd9 (patch) | |
| tree | 4e4da4daa710f1f12d9e71a57118d53f6c85ae38 | |
| parent | 19b6b29b1a82b11c64c8b05c97cf8f687fd495f6 (diff) | |
| download | dispatch-web-0d68ae9a17cec26848bee6101415ebe042d78dd9.tar.gz dispatch-web-0d68ae9a17cec26848bee6101415ebe042d78dd9.zip | |
fix(concurrency): silent background refresh — no loading flicker
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.
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 49 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 32 |
2 files changed, 59 insertions, 22 deletions
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<readonly ConcurrencyLimitEntry[]>([]); - let limitsLoading = $state(false); let limitsError = $state<string | null>(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<void> { - 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<readonly ConcurrencyStatusEntry[]>([]); - let statusLoading = $state(false); let statusError = $state<string | null>(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<void> { - 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 @@ <button type="button" class="btn btn-ghost btn-xs" - disabled={limitsLoading} onclick={() => refreshLimits()} aria-label="Refresh concurrency limits" > - {#if limitsLoading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} + Refresh </button> </div> <p class="text-xs opacity-60"> @@ -257,7 +267,7 @@ {#if limitsError} <p class="text-xs text-error">{limitsError}</p> - {:else if hasLoadedLimits && limitViews.length === 0 && !limitsLoading} + {:else if hasLoadedLimits && limitViews.length === 0} <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p> {:else} <ul class="flex flex-col gap-2"> @@ -277,15 +287,10 @@ <button type="button" class="btn btn-ghost btn-xs" - disabled={statusLoading} onclick={() => refreshStatus()} aria-label="Refresh concurrency status" > - {#if statusLoading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} + Refresh </button> </div> <p class="text-xs opacity-60"> @@ -297,7 +302,7 @@ {#if statusError} <p class="text-xs text-error">{statusError}</p> - {:else if hasLoadedStatus && statusViews.length === 0 && !statusLoading} + {:else if hasLoadedStatus && statusViews.length === 0} <p class="text-xs opacity-60">No limits configured — nothing to report.</p> {:else} <ul class="flex flex-col gap-2"> 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: [] }); |
