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