fix(player): drive composition ticks from widget-frame rAF via postMessage#805
Conversation
…ssage Chromium throttles requestAnimationFrame in deeply nested cross-origin iframes. In Claude desktop (Electron), the composition iframe's own rAF loop stalls, so GSAP is never seeked and animation freezes even when TransportClock.isPlaying() is true. The correct fix is to drive ticks from the widget-frame rAF, which lives one level up and is not subject to the same throttling. When play() takes the runtime bridge path (no direct timeline adapter), the player now starts a parent-frame rAF loop that sends "tick" postMessages to the composition iframe on every frame. The runtime's control bridge handles "tick" by calling seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to what transportTick does on each rAF, just driven from outside. The composition iframe's own rAF loop is unchanged and keeps running normally in standard browsers. Seeking GSAP twice per frame is idempotent, so there is no regression on claude.ai or any other non-throttled environment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jrusso1020
left a comment
There was a problem hiding this comment.
APPROVE at 999e0bed.
Diff is functionally identical to hf#739 which I (+ Magi + Vai) approved separately. Branch suffix -main suggests this is a fresh cut off main, likely due to Graphite stack drift on the original branch. Same +80/-2 across the same 5 files: bridge.test.ts, bridge.ts, init.ts, types.ts, hyperframes-player.ts. Same onTick handler, same _paused = false ordering, same _startParentTickClock/_stopParentTickClock lifecycle wiring through play/pause/seek/disconnectedCallback/_onIframeLoad. Same bridge-dispatch test.
My prior detailed review from hf#739 (and Magi's + Vai's) applies as-is. Carrying the same praise and the same advisory items forward:
Praise (carries through)
- Choosing the existing control-bridge pattern over a new postMessage channel is correct (slots
onTicknext toonPlay/onPause/onSeekwith zero new surface) _paused = falseordering at:223is load-bearing and the comment explains it_stopParentTickClockis called at all four cleanup sites (pause, seek, _onIframeLoad, disconnectedCallback) — symmetric- The rAF callback's
if (this._paused) { _parentTickRaf = null; return; }self-termination + explicit_stopParentTickClock()give two paths to stop the loop - Diagnosis section (Chromium throttles rAF in deeply-nested cross-origin iframes → scrubbing works because synchronous
seekpath bypasses rAF) traces the root cause precisely - The runtime's own rAF loop is intentionally left running; idempotency reasoning is documented
Advisory items (non-blocking, same as hf#739)
These were not addressed in the new branch — same trio of follow-up considerations the 3 reviewers converged on yesterday:
-
onTick↔transportTickend-of-composition duplication (3-reviewer-converged). Per Vai's additional catch: same-frame ordering hazard — ifonTickfires beforetransportTickon the same frame at end-of-comp, the parent seesfinal: trueimmediately clobbered byfinal: false(sincetransportTickfalls through topostState(false)). Either an extractedhandleEndOfComposition()helper or anendedPostedlatch closes both concerns. Follow-up. -
Play-before-
_readyrace (my concern)._startParentTickClockis gated onthis._ready && !this._directTimelineAdapterathyperframes-player.ts:226. Ifplay()is called before the probe resolves,_sendControl("play")is queued but the parent tick clock never starts. In Electron specifically that means animation freezes silently. One-line fix in the readiness handler. -
No test on the
_startParentTickClocklifecycle (me + Vai).bridge.test.tspins the dispatch side. Missing the player-side: tick fires onplay, stops onpause/seek/disconnectedCallback/_onIframeLoad. One mocked-requestAnimationFrame+postMessagespy test would pin all four cleanup paths.
None gate merge — fix as shipped is materially better than the broken Electron state.
CI
In_progress on this SHA. Completed: File size check ✓, Detect changes ✓ (multiple), Semantic PR title ✓, WIP ✓. Tests, perf, regression-shards, windows jobs, CodeQL, etc. all still running. No failures yet.
Verdict
APPROVE — carry the same disposition as hf#739. Ship 🚀 on green CI.
— Rames Jusso (pr-review)
Summary
requestAnimationFramein deeply nested cross-origin iframes. In Claude desktop (Electron), the composition iframe's own rAF loop stalls, freezing GSAP animation even whenTransportClock.isPlaying()is true."tick"postMessages to the composition iframe on every frame when playing via the runtime bridge path. The runtime handles"tick"by callingseekTimelineAndAdapters(clock.now())— identical to whattransportTickdoes, just driven from outside.Changes
core/runtime/types.ts— add"tick"toRuntimeBridgeControlActioncore/runtime/bridge.ts— addonTickdep + handle"tick"actioncore/runtime/init.ts— implementonTickwithreachedEnd()check so end-of-composition handling works even when the iframe rAF is fully throttledcore/runtime/bridge.test.ts— addonTickmock + dispatch testplayer/hyperframes-player.ts— add_parentTickRaf,_startParentTickClock(),_stopParentTickClock(); wire intoplay(),pause(),seek(),disconnectedCallback(), and_onIframeLoad()Test plan
bun run testpasses🤖 Generated with Claude Code