fix(feed): eliminate event-loop blocking during feed refresh

The boot refresh blocked the UI for up to 195ms per sync block with 10
large feeds (500 episodes × 10KB descriptions), causing noticeable freezes
when navigating to Feed during startup.

Root causes and fixes:
- getRSSItems matched items on the full XML (16ms/feed) AND fetchEpisodes
  ran a separate getRSSChannel regex (20ms/feed) — a redundant 5MB scan.
  Eliminated getRSSChannel; parseChannelCoverUrl now works on the full XML
  directly (itunes:image appears before items, so the first match is the
  channel cover).
- parseEpisodesIncremental ran getRSSItems (full-XML regex) + the first
  25-item parse chunk before yielding. Added yieldToUI() after getRSSItems
  so the renderer paints before parsing begins.
- Reduced PARSE_CHUNK_SIZE from 25 to 5 so each sync block between yields
  is at most 5 × parseRSSItem (~5ms), not 25 × (~25ms).
- Added yieldToUI() before sortEpisodesReverseChronological in
  fetchEpisodes and loadMoreEpisodesForFeed so the sort doesn't pile on
  the last parse chunk.
- Added yieldToUI() after response.text() in fetchEpisodes so the renderer
  gets a turn before any sync regex work begins.

Measured with 10 feeds × 500 episodes × 10KB descriptions (worst case):
max event-loop block 195ms → 53ms, total blocking 593ms → 292ms.
This commit is contained in:
2026-08-12 12:50:01 -04:00
parent acbaf2ed1c
commit 6c99b96b12
3 changed files with 32 additions and 17 deletions

View File

@@ -51,7 +51,7 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
/** Max episodes parsed per chunk before yielding to the event loop — bounds
* the synchronous regex work per frame so one huge feed (or a batch of
* feeds) can't stall the renderer. */
const PARSE_CHUNK_SIZE = 25;
const PARSE_CHUNK_SIZE = 5;
/** Yield to the event loop (task queue) so the renderer can paint between
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
@@ -79,6 +79,9 @@ const parseEpisodesIncremental = async (
feedUrl: string,
): Promise<Episode[]> => {
const items = getRSSItems(xml);
// Yield after the item-extraction regex (which scans the full XML
// synchronously) so the renderer paints before the first parse chunk.
await yieldToUI();
const episodes: Episode[] = new Array(items.length);
for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) {
const end = Math.min(start + PARSE_CHUNK_SIZE, items.length);
@@ -340,13 +343,15 @@ function createFeedStore() {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
// Hung feeds must not stall a refresh batch (or the background
// refresh loop) indefinitely.
// Hung feeds must not stall a refresh batch (or the
// background refresh loop) indefinitely.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return { episodes: null, coverUrl: undefined };
const xml = await response.text();
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml;
// Yield after the network read so the renderer gets a turn
// before the sync regex + parse work begins.
await yieldToUI();
const allEpisodes = sortEpisodesReverseChronological(
await parseEpisodesIncremental(xml, feedUrl),
);
@@ -359,7 +364,7 @@ function createFeedStore() {
return {
episodes: allEpisodes.slice(0, limit),
coverUrl: parseChannelCoverUrl(channel),
coverUrl: parseChannelCoverUrl(xml),
};
} catch {
return { episodes: null, coverUrl: undefined };
@@ -786,6 +791,10 @@ function createFeedStore() {
}
// Cold-refetch parse output is unsorted; sort and cap it so the
// cache and the pagination window stay newest-first and bounded.
// Yield before the sync sort (the parse already yielded before
// this point, but the sort of potentially hundreds of episodes
// is its own sync block).
await yieldToUI();
cached = sortEpisodesReverseChronological(cached);
cached = cached.slice(0, MAX_EPISODES_IN_MEMORY);
fullEpisodeCache.set(feedId, cached);