Skip to content

fix(player): drive composition ticks from widget-frame rAF via postMessage#805

Merged
terencecho merged 1 commit into
mainfrom
fix/electron-nested-iframe-raf-tick-main
May 13, 2026
Merged

fix(player): drive composition ticks from widget-frame rAF via postMessage#805
terencecho merged 1 commit into
mainfrom
fix/electron-nested-iframe-raf-tick-main

Conversation

@terencecho
Copy link
Copy Markdown
Contributor

Summary

  • Chromium throttles requestAnimationFrame in deeply nested cross-origin iframes. In Claude desktop (Electron), the composition iframe's own rAF loop stalls, freezing GSAP animation even when TransportClock.isPlaying() is true.
  • Adds a parent-frame rAF loop that sends "tick" postMessages to the composition iframe on every frame when playing via the runtime bridge path. The runtime handles "tick" by calling seekTimelineAndAdapters(clock.now()) — identical to what transportTick does, just driven from outside.
  • The composition iframe's own rAF loop is unchanged; seeking GSAP twice per frame is idempotent, so there is no regression on claude.ai or any other non-throttled environment.

Changes

  • core/runtime/types.ts — add "tick" to RuntimeBridgeControlAction
  • core/runtime/bridge.ts — add onTick dep + handle "tick" action
  • core/runtime/init.ts — implement onTick with reachedEnd() check so end-of-composition handling works even when the iframe rAF is fully throttled
  • core/runtime/bridge.test.ts — add onTick mock + dispatch test
  • player/hyperframes-player.ts — add _parentTickRaf, _startParentTickClock(), _stopParentTickClock(); wire into play(), pause(), seek(), disconnectedCallback(), and _onIframeLoad()

Test plan

  • Play a composition in Claude desktop (Electron) — animation should advance smoothly
  • Play/pause/scrub in a standard browser — no regression in behavior
  • bun run test passes

🤖 Generated with Claude Code

…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>
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

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 onTick next to onPlay/onPause/onSeek with zero new surface)
  • _paused = false ordering at :223 is load-bearing and the comment explains it
  • _stopParentTickClock is 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 seek path 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:

  1. onTicktransportTick end-of-composition duplication (3-reviewer-converged). Per Vai's additional catch: same-frame ordering hazard — if onTick fires before transportTick on the same frame at end-of-comp, the parent sees final: true immediately clobbered by final: false (since transportTick falls through to postState(false)). Either an extracted handleEndOfComposition() helper or an endedPosted latch closes both concerns. Follow-up.

  2. Play-before-_ready race (my concern). _startParentTickClock is gated on this._ready && !this._directTimelineAdapter at hyperframes-player.ts:226. If play() 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.

  3. No test on the _startParentTickClock lifecycle (me + Vai). bridge.test.ts pins the dispatch side. Missing the player-side: tick fires on play, stops on pause/seek/disconnectedCallback/_onIframeLoad. One mocked-requestAnimationFrame + postMessage spy 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)

@terencecho terencecho merged commit c08e8b2 into main May 13, 2026
42 checks passed
@terencecho terencecho deleted the fix/electron-nested-iframe-raf-tick-main branch May 13, 2026 22:29
@jrusso1020 jrusso1020 mentioned this pull request May 13, 2026
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