fix(feed): unblock UI during fetch-more and drop indicator label text
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.
This commit is contained in:
@@ -25,28 +25,10 @@ export function GlobalActivityIndicator() {
|
|||||||
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||||
activity.isActive();
|
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 (
|
return (
|
||||||
<Show when={isActive()}>
|
<Show when={isActive()}>
|
||||||
<box position="absolute" top={0} right={0} paddingRight={1}>
|
<box position="absolute" top={0} right={0} paddingRight={1}>
|
||||||
<LoadingIndicator label={label()} />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -781,6 +781,13 @@ function createFeedStore() {
|
|||||||
episodeLoadCount.set(feedId, newCount);
|
episodeLoadCount.set(feedId, newCount);
|
||||||
const episodes = cached.slice(0, 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) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, episodes } : f,
|
f.id === feedId ? { ...f, episodes } : f,
|
||||||
|
|||||||
@@ -141,17 +141,15 @@ test("track re-throws rejection and returns isActive() to its prior value", asyn
|
|||||||
expect(activity.labels()).toEqual([]);
|
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 setup = await renderIndicator();
|
||||||
const text = frameText(setup);
|
const text = frameText(setup);
|
||||||
expect(text).not.toMatch(SPINNER_RE);
|
expect(text).not.toMatch(SPINNER_RE);
|
||||||
expect(text).not.toContain("…");
|
|
||||||
expect(text).not.toContain("Downloading");
|
|
||||||
expect(text.trim()).toBe("");
|
expect(text.trim()).toBe("");
|
||||||
setup.renderer.destroy();
|
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 activity = useActivityStore();
|
||||||
const setup = await renderIndicator();
|
const setup = await renderIndicator();
|
||||||
expect(frameText(setup)).not.toMatch(SPINNER_RE);
|
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();
|
await setup.renderOnce();
|
||||||
const active = frameText(setup);
|
const active = frameText(setup);
|
||||||
expect(active).toMatch(SPINNER_RE);
|
expect(active).toMatch(SPINNER_RE);
|
||||||
expect(active).toContain("Refreshing…");
|
|
||||||
|
|
||||||
end();
|
end();
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
const done = frameText(setup);
|
const done = frameText(setup);
|
||||||
expect(done).not.toMatch(SPINNER_RE);
|
expect(done).not.toMatch(SPINNER_RE);
|
||||||
expect(done).not.toContain("Refreshing…");
|
|
||||||
expect(done.trim()).toBe("");
|
expect(done.trim()).toBe("");
|
||||||
setup.renderer.destroy();
|
setup.renderer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("active download: 'Downloading' label appears and disappears", async () => {
|
test("active download: spinner appears and disappears", async () => {
|
||||||
const dl = useDownloadStore();
|
const dl = useDownloadStore();
|
||||||
const setup = await renderIndicator();
|
const setup = await renderIndicator();
|
||||||
|
|
||||||
@@ -184,7 +180,7 @@ test("active download: 'Downloading' label appears and disappears", async () =>
|
|||||||
|
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
const during = frameText(setup);
|
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.
|
// Cancel: the abort settles the fetch and activeCount returns to 0.
|
||||||
dl.cancelDownload(episode.id);
|
dl.cancelDownload(episode.id);
|
||||||
@@ -197,7 +193,6 @@ test("active download: 'Downloading' label appears and disappears", async () =>
|
|||||||
const after = frameText(setup);
|
const after = frameText(setup);
|
||||||
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
|
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
|
||||||
expect(after).not.toMatch(SPINNER_RE);
|
expect(after).not.toMatch(SPINNER_RE);
|
||||||
expect(after).not.toContain("Downloading");
|
|
||||||
|
|
||||||
audioDelayMs = 0;
|
audioDelayMs = 0;
|
||||||
setup.renderer.destroy();
|
setup.renderer.destroy();
|
||||||
|
|||||||
Reference in New Issue
Block a user