diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 23:37:44 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 23:37:44 +0900 |
| commit | 121fd7280bc3f77c61da7025cca06480798038b7 (patch) | |
| tree | 826f92fd0caa716cea3216011c4201311aa91014 /src | |
| parent | ce1a4415158e6502efc068077c8495314d177cfe (diff) | |
| download | dispatch-web-121fd7280bc3f77c61da7025cca06480798038b7.tar.gz dispatch-web-121fd7280bc3f77c61da7025cca06480798038b7.zip | |
fix(heartbeat): hide runs loading state to prevent sidebar flicker
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 33 |
1 files changed, 20 insertions, 13 deletions
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index d584cb3..d2a7e96 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -133,22 +133,32 @@ // ── Runs list (polls while mounted) ─────────────────────────────────────── let runs = $state<readonly HeartbeatRunView[]>([]); - let runsLoading = $state(false); + /** True after the first successful load (gates the "No runs yet" empty state + * WITHOUT flashing it before the initial fetch resolves). The per-poll + * loading is intentionally INVISIBLE — it's near-instant and a visible + * loading indicator caused the sidebar to flicker every poll (height shift). */ + let hasLoadedRuns = $state(false); let runsError = $state<string | null>(null); let stoppingId = $state<string | null>(null); let stopError = $state<string | null>(null); let pollHandle: ReturnType<typeof setInterval> | null = null; + /** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */ + let refreshInFlight = false; const RUN_POLL_MS = 4000; async function refreshRuns(): Promise<void> { - runsLoading = true; - runsError = null; + if (refreshInFlight) return; + refreshInFlight = true; const result = await loadRuns(); - runsLoading = false; + refreshInFlight = false; if (result === null) return; if (result.ok) { runs = viewRuns(result.runs); + // Clear the error only on success so it stays visible (stable, no + // flicker) during an in-flight retry rather than vanishing mid-poll. + runsError = null; + hasLoadedRuns = true; } else { runsError = result.error; } @@ -373,23 +383,16 @@ <button type="button" class="btn btn-ghost btn-xs" - disabled={runsLoading} onclick={() => refreshRuns()} aria-label="Refresh heartbeat runs" > - {#if runsLoading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} + Refresh </button> </div> {#if runsError} <p class="text-xs text-error">{runsError}</p> - {:else if runs.length === 0 && !runsLoading} - <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p> - {:else} + {:else if runs.length > 0} <ul class="flex max-h-72 flex-col gap-1 overflow-y-auto"> {#each runsView as run (run.id)} <li> @@ -433,6 +436,10 @@ {#if stopError} <p class="text-xs text-error">{stopError}</p> {/if} + {:else if hasLoadedRuns} + <!-- Loaded with zero runs (not the pre-first-load gap). No loading + indicator — polling is near-instant and a visible one flickered. --> + <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p> {/if} </section> </div> |
