summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 19:29:35 +0900
committerAdam Malczewski <[email protected]>2026-06-27 19:29:35 +0900
commit0d68ae9a17cec26848bee6101415ebe042d78dd9 (patch)
tree4e4da4daa710f1f12d9e71a57118d53f6c85ae38 /src/features
parent19b6b29b1a82b11c64c8b05c97cf8f687fd495f6 (diff)
downloaddispatch-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.
Diffstat (limited to 'src/features')
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte49
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts32
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: [] });