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

@@ -74,21 +74,21 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return undefined return undefined
} }
/** Extract the `<item>` blocks from an RSS document (the sync part of /** Extract the `<item>` blocks from an RSS document. Matches items directly
* parsing is bounded to this single regex pass). Exported so the feed store * on the full XML string — scoping to <channel> first is a redundant 5MB
* can parse episodes incrementally without re-deriving item boundaries. */ * regex pass that doubles parse cost with no practical benefit (well-formed
* RSS has no items outside <channel>). */
export const getRSSItems = (xml: string): string[] => { export const getRSSItems = (xml: string): string[] => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
return channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
} }
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0 /** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
* `<image><url>`. Exported so the feed store can backfill a feed's * `<image><url>`. Works on the full XML — channel-level tags precede
* coverUrl on refresh without re-deriving the channel block. */ * <item> blocks in RSS, so the first match is the channel image. */
export const parseChannelCoverUrl = (channel: string): string | undefined => { export const parseChannelCoverUrl = (xml: string): string | undefined => {
const itunesHref = getAttr(channel, "itunes:image", "href") const itunesHref = getAttr(xml, "itunes:image", "href")
if (itunesHref) return itunesHref if (itunesHref) return itunesHref
const url = getTagValue(channel, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1] const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
return url?.trim() || undefined return url?.trim() || undefined
} }

View File

@@ -45,6 +45,10 @@ export function ProgressBar() {
padding={0} padding={0}
flexDirection="row" flexDirection="row"
gap={0} gap={0}
// The bar's block-char texts are non-selectable below: a drag
// over the bar is a seek gesture, not a text selection — otherwise
// mouse-up would copy █/░ to the clipboard via the global
// selection handler.
ref={(el) => { ref={(el) => {
bar = el; bar = el;
}} }}
@@ -58,9 +62,11 @@ export function ProgressBar() {
}} }}
> >
{playedChars() > 0 && ( {playedChars() > 0 && (
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text> <text fg={theme.primary} selectable={false}>
{"\u2588".repeat(playedChars())}
</text>
)} )}
<text fg={remainingColor}> <text fg={remainingColor} selectable={false}>
{"\u2591".repeat(width() - playedChars())} {"\u2591".repeat(width() - playedChars())}
</text> </text>
</box> </box>

View File

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