Skip to content

perf(web-ui): cut player re-render cost when EPG arrives - #733

Merged
stackia merged 10 commits into
mainfrom
cursor/epg-render-jank-3fe7
Sep 2, 2026
Merged

perf(web-ui): cut player re-render cost when EPG arrives#733
stackia merged 10 commits into
mainfrom
cursor/epg-render-jank-3fe7

Conversation

@stackia

@stackia stackia commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Reduces main-thread work in the web player at the moment the EPG worker posts its result, keeps the guide render interruptible, removes the per-second whole-page re-render, and consolidates the page's clocks.

Changes

  • EPGView renders from deferred values — the programme list (hundreds of memo'd rows) is derived from useDeferredValue(channelId / epgData / currentPlayingProgram), so its work always runs in a non-urgent, interruptible lane: EPG arrival is already a startTransition and passes straight through; urgent updates (channel zap, programme boundary) commit the cheap parts first and React renders the rows in the background, yielding to user input between rows. Row time labels are precomputed once per EPG update with a cached Intl.DateTimeFormat. (An earlier revision of this branch used hand-maintained lazy day sections with an IntersectionObserver; that was dropped in favour of this simpler model.)
  • First guide positioning is instant — the timer arming smooth auto-scroll ran before the "no programmes yet" early return, so with the guide open before EPG arrival the very first positioning animated (75 layouts / ~80 scroll events in the 1.5 s after arrival). Smooth is now armed only after a scroll target has actually existed; tab switches already request instant.
  • Two explicit clocks
    • Media clock: PlaybackClock (components/player/playback-clock.ts) owns the playback position semantics: raw position, a snapshot that changes at whole seconds, reset(). VideoPlayer feeds it from the active backend's time-update and publishes it to its own controls via PlaybackTimeProvider (useSyncExternalStore); useCurrentVideoProgram binds the current programme to it, bumping its lookup time only on programme boundaries (with a self-correcting effect for streamStartTime recalibration and reset). PlayerPage no longer holds any per-second state, refs or callbacks.
    • Wall clock: EPGView, ChannelList and the top-left overlay clock each ran their own 60 s timer (ChannelList's not minute-aligned, and stale after its tab was hidden). They now subscribe to one module-level, minute-aligned ticker (useWallClockMinute) that starts with the first subscriber, refreshes on subscribe, re-aligns on visibilitychange and stops when unused. The media clock cannot stand in for it: it can be paused, stalled or point into the past during catchup.
  • Gap-fill runs exactly once, in the workerfillEPGGaps used to run twice on the main thread (startup placeholder + again on arrival, 200–300 ms for catchup playlists). The worker now receives channel descriptors, parses, gap-fills and posts final data; the UI shows no EPG until it arrives. Without x-tvg-url (or on fetch failure) the worker still returns fallback-only data so catchup channels stay seekable. generateFallbackPrograms replaces the per-slot .some() scan with two cursors over the start-sorted list + a linear merge: ~300 ms → ~11 ms on the 海南 EPG with all channels catchup-enabled (山西 output byte-identical to before; 海南 differs only for one channel with 9 malformed programmes whose stop precedes start, where the old scan accidentally suppressed a day of gap-fill).
  • PlayerPage computes the EPG channel id once and shares it.
  • VideoPlayer is deliberately not memo()-wrapped: react-dom 19.2 only refreshes useEffectEvent implementations for FunctionComponent fibers, not the SimpleMemoComponent fiber memo() creates. An earlier commit on this branch added memo and it froze every Effect Event at mount-time props (playMode stayed "live", streamStartTime at page load), so the first playing event of a catchup stream recalibrated the live session and rebuilt the catchup URL at "now" in a loop. Reverted with an explanatory comment.

Measurements (taken on the lazy-section revision; the last three revisions have not been re-profiled)

Headless Chrome, 4× CPU throttle, median of 3, window = EPG arrival −100 ms … +1500 ms; "busy" = sum of renderer main-thread RunTask durations (details in epg_arrival_profile_results.md artifact):

scenario build main-thread busy React/JS Layout+Style
海南, guide hidden main 377 ms 183 ms 49 ms
海南, guide hidden branch 320 ms 121 ms 51 ms
山西, guide hidden main 354 ms 154 ms 52 ms
山西, guide hidden branch 302 ms 109 ms 54 ms
海南, guide open main 1059 ms 216 ms 248 ms
海南, guide open branch 342 ms 145 ms 77 ms

Not captured by headless: the removed 2×/s PlayerPage+VideoPlayer re-render during playback, and the 200–300 ms → ~11 ms gap-fill for catchup playlists.

Verification

  • tsc --noEmit, biome check, vite build pass on the final revision. Earlier revisions: ./scripts/run-e2e.sh test_pages.py test_epg.py (12 passed); scripted CDP runs against devlab (catchup from a guide row starts at 0:00, Go Live returns, keyboard zapping identical to main); manual GUI walkthrough. The last three revisions are to be verified manually by the author.

big_epg_lazy_program_guide_demo.mp4
Catchup started from a Yesterday guide row: SEEK 2026-09-01 02-00-00, row highlighted

Open in Web Open in Cursor 

cursoragent and others added 3 commits September 2, 2026 02:38
Render each day of the programme guide only once it approaches the scroll
viewport (shared IntersectionObserver, fixed-height placeholders), always
rendering the section holding the playing programme so auto-scroll still
works. Row time labels are formatted once per EPG update with a cached
Intl.DateTimeFormat instead of toLocaleTimeString per render, and the
on-air clock ticks on minute boundaries instead of every second.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
All VideoPlayer props are primitives or stable callbacks and the 1 Hz
playback clock flows through PlaybackTimeContext, so memo() lets it skip
re-rendering on unrelated PlayerPage updates. The EPG channel id for the
current channel is now computed once and shared by the programme lookup
and the EPG view.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
fillEPGGaps used to run twice on the main thread (an empty-EPG placeholder
at startup and again on the full EPG when the worker finished, the latter
costing 200-300 ms for catchup playlists). The worker now receives channel
descriptors, derives the valid id set, parses and gap-fills, and posts the
final data; the main thread shows no EPG until it arrives. When there is
no x-tvg-url or the fetch fails the worker still returns fallback-only
data so catchup channels stay seekable. generateFallbackPrograms replaces
the per-slot .some() scan with two cursors over the start-sorted list and
a linear merge, taking the all-catchup case from ~300 ms to ~11 ms.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Documentation preview

The documentation preview has been deployed for this pull request.

cursoragent and others added 3 commits September 2, 2026 02:44
PlayerPage used to setState (plus a deferred re-render) every second from
the player's time updates, re-rendering the whole page and everything not
memoized beneath it, and interrupting in-flight transitions such as the
EPG arrival render. The clock now lives in a small external store behind
PlaybackTimeProvider, consumed via useSyncExternalStore only by the
timeline and time readout. The current programme is still derived in
render, but its time input is only bumped when the clock actually crosses
a programme boundary (or the lookup inputs change), so the page re-renders
on programme changes, channel switches, seeks and EPG arrival instead of
every second. Seek/reset paths still zero the clock as before.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
…hydrated

Observing against the scroll container depended on when its ref was
attached relative to the observer effect; observe the viewport instead
(the list fills the sidebar height, so the result is identical). Also
eagerly render the sections adjacent to the playing programme so the
auto-scrolled viewport is full on reveal, and latch hydration so a
section never falls back to a placeholder once it has been rendered.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
react-dom 19.2 refreshes useEffectEvent implementations in the
before-mutation phase only for FunctionComponent fibers; the
SimpleMemoComponent fiber created by memo() is skipped, so every Effect
Event in VideoPlayer kept its mount-time props. In practice playMode
stayed "live" and streamStartTime stayed at page load, so the first
"playing" event of a catchup stream recalibrated the live session and
rebuilt the catchup URL at "now" in a loop. Revert to the plain export;
the 1 Hz page re-render memo() was meant to absorb is gone since the
playback clock moved to an external store.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
@stackia
stackia marked this pull request as ready for review September 2, 2026 04:51
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 48cc2ce. Configure here.

Comment thread web-ui/src/components/player/epg-view.tsx Outdated
cursoragent and others added 4 commits September 2, 2026 05:18
The timer that switches the guide's auto-scroll to smooth ran before the
"no programmes yet" early return, so when the guide was open before the
EPG arrived the very first positioning animated. Smooth scrolling is meant
only for programme changes while the guide is already showing, so arm it
only after a scroll target has actually existed. Tab switches already
request instant via nextScrollBehaviorRef.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
…f lazy sections

Drop the hand-maintained section hydration (IntersectionObserver,
fixed-height placeholders, eager set, latch). Every day renders in full
again; instead the list is derived from useDeferredValue(channelId /
epgData / currentPlayingProgram) so its work always runs in a non-urgent,
interruptible lane: EPG arrival is already a transition and passes
straight through, while urgent updates such as a channel zap or a
programme boundary commit the cheap parts first and let React render the
rows in the background, yielding to user input between them. Precomputed
row labels, the minute-aligned clock and the instant first positioning
are kept.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
…idgets

EPGView, ChannelList and the top-left overlay clock each ran their own
60 s timer (ChannelList's not even minute-aligned), so their notion of
"now" could disagree by up to a minute and ChannelList showed a stale
value after its tab had been hidden. Replace them with a single
module-level, minute-aligned ticker exposed through useSyncExternalStore
(useWallClockMinute). It starts with the first subscriber, refreshes the
value immediately on subscribe (tab reveal), re-aligns on visibilitychange
and stops when nobody listens. The media clock (video position) stays a
separate, event-driven source: it can be paused, stalled or point into the
past during catchup, so it cannot stand in for real time.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
PlaybackTimeStore was a dumb value store written from a PlayerPage
callback; the whole-second throttling, the two position refs and the
programme-boundary detection lived in PlayerPage around it. Replace it
with PlaybackClock, which owns the media-clock semantics (raw position,
1 Hz snapshot, reset) and is fed directly by VideoPlayer from the active
backend's time-update; VideoPlayer also publishes it to its own controls
through PlaybackTimeProvider, so the onCurrentVideoTimeChange prop and the
provider in PlayerPage go away. The programme-follows-the-clock logic
moves into useCurrentVideoProgram, leaving PlayerPage with a single hook
call. Seek/reset paths still zero the clock via resetCurrentVideoTime.

Co-authored-by: Stackie Jia <jsq2627@gmail.com>
@stackia
stackia merged commit f44c12e into main Sep 2, 2026
11 checks passed
@stackia
stackia deleted the cursor/epg-render-jank-3fe7 branch September 2, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants