From 0b4a55174452971649b16cf4fdfa2bc20948e9c7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 12 Aug 2026 10:47:02 -0400 Subject: [PATCH] fix(feed): unblock UI during fetch-more and drop indicator label text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadMoreEpisodesForFeed's hot-cache path ran fully synchronously (no await between getFeed and setFeeds), so the isLoadingMore spinner never painted and keyboard input froze through every feed in a loadMoreAllFeeds batch. Add await yieldToUI() before the setFeeds so the renderer gets a macrotask turn to paint and process input between feeds. Drop the "Loading…/Refreshing…/Downloading N" label text from the global activity indicator — render the braille spinner only, per request. --- src/components/GlobalActivityIndicator.tsx | 20 +------------------- src/stores/feed.ts | 7 +++++++ tests/global-activity-indicator.test.tsx | 13 ++++--------- 3 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/components/GlobalActivityIndicator.tsx b/src/components/GlobalActivityIndicator.tsx index fa779e6..5ce41df 100644 --- a/src/components/GlobalActivityIndicator.tsx +++ b/src/components/GlobalActivityIndicator.tsx @@ -25,28 +25,10 @@ export function GlobalActivityIndicator() { downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive(); - /** Label priority: downloads in flight > latest tracked activity > - * generic loading (only reachable when an isLoading/isSearching flag is - * on but nothing else is). */ - const label = () => { - const activeCount = downloadStore.getActiveCount(); - const queueLength = downloadStore.getQueue().length; - if (activeCount + queueLength > 0) { - return `Downloading ${activeCount}${ - queueLength > 0 ? ` +${queueLength} queued` : "" - }`; - } - if (activity.isActive()) { - const latest = activity.labels().at(-1); - return `${latest ?? "Loading"}…`; - } - return "Loading…"; - }; - return ( - + ); diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 93f33ed..28ded69 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -781,6 +781,13 @@ function createFeedStore() { episodeLoadCount.set(feedId, newCount); const episodes = cached.slice(0, newCount); + // Yield a real macrotask turn before the sync state update so the + // renderer paints the spinner and processes keyboard input before the + // (potentially large, per-feed in loadMoreAllFeeds) setFeeds + sort + // runs. Without this, the whole body executes in one microtask batch + // and the UI freezes through every feed in the batch. + await yieldToUI(); + setFeeds((prev) => { const updated = prev.map((f) => f.id === feedId ? { ...f, episodes } : f, diff --git a/tests/global-activity-indicator.test.tsx b/tests/global-activity-indicator.test.tsx index 1f87ab0..67d138c 100644 --- a/tests/global-activity-indicator.test.tsx +++ b/tests/global-activity-indicator.test.tsx @@ -141,17 +141,15 @@ test("track re-throws rejection and returns isActive() to its prior value", asyn expect(activity.labels()).toEqual([]); }); -test("idle: renders nothing, no spinner, no label", async () => { +test("idle: renders nothing, no spinner", async () => { const setup = await renderIndicator(); const text = frameText(setup); expect(text).not.toMatch(SPINNER_RE); - expect(text).not.toContain("…"); - expect(text).not.toContain("Downloading"); expect(text.trim()).toBe(""); setup.renderer.destroy(); }); -test("tracked activity: spinner + label appear while active, vanish on end", async () => { +test("tracked activity: spinner appears while active, vanishes on end", async () => { const activity = useActivityStore(); const setup = await renderIndicator(); expect(frameText(setup)).not.toMatch(SPINNER_RE); @@ -160,18 +158,16 @@ test("tracked activity: spinner + label appear while active, vanish on end", asy await setup.renderOnce(); const active = frameText(setup); expect(active).toMatch(SPINNER_RE); - expect(active).toContain("Refreshing…"); end(); await setup.renderOnce(); const done = frameText(setup); expect(done).not.toMatch(SPINNER_RE); - expect(done).not.toContain("Refreshing…"); expect(done.trim()).toBe(""); setup.renderer.destroy(); }); -test("active download: 'Downloading' label appears and disappears", async () => { +test("active download: spinner appears and disappears", async () => { const dl = useDownloadStore(); const setup = await renderIndicator(); @@ -184,7 +180,7 @@ test("active download: 'Downloading' label appears and disappears", async () => await setup.renderOnce(); const during = frameText(setup); - expect(during).toContain("Downloading 1"); + expect(during).toMatch(SPINNER_RE); // Cancel: the abort settles the fetch and activeCount returns to 0. dl.cancelDownload(episode.id); @@ -197,7 +193,6 @@ test("active download: 'Downloading' label appears and disappears", async () => const after = frameText(setup); expect(dl.getActiveCount() + dl.getQueue().length).toBe(0); expect(after).not.toMatch(SPINNER_RE); - expect(after).not.toContain("Downloading"); audioDelayMs = 0; setup.renderer.destroy();