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();