fix(player): drive composition ticks from widget-frame rAF via postMessage#739
fix(player): drive composition ticks from widget-frame rAF via postMessage#739terencecho wants to merge 2 commits into
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>
- _paused must be false before _startParentTickClock runs; otherwise the first RAF callback sees _paused=true and self-terminates immediately - Guard _startParentTickClock behind this._ready && !this._directTimelineAdapter so tick messages aren't sent into an uninitialized iframe when play() is called before the composition probe has resolved - Add clock.reachedEnd() check to onTick so end-of-composition handling (pause, seek-to-end, postState) runs even when the composition iframe RAF is fully throttled - Stop the parent tick clock in seek() alongside _stopDirectTimelineClock to avoid burning CPU frames while paused after a scrub - Add onTick to bridge.test.ts createMockDeps() and add a dispatch test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jrusso1020
left a comment
There was a problem hiding this comment.
Verdict: APPROVE — targeted fix, correct diagnosis, clean mechanism
Read all 5 changed files end-to-end + traced the rAF/seek loop in init.ts and the play/pause/seek state machine in hyperframes-player.ts. The fix is the right shape: drive ticks from the widget frame (one level above the throttled composition iframe) and route them through the existing control bridge.
Bug verified
init.ts:1698-1816 shows transportTick is a requestAnimationFrame-driven loop that calls seekTimelineAndAdapters(clock.now()) every frame. GSAP is paused via tl.totalTime(t, false) — the runtime is steering the timeline frame-by-frame. When the composition iframe's rAF is throttled (deeply-nested cross-origin in Electron / Claude desktop), this loop stalls and animation freezes even though clock.isPlaying() is true. Diagnosis is correct.
Scrubbing works pre-fix because player.seek invokes seekTimelineAndAdapters synchronously, no rAF needed.
Fix is correct
The widget frame's rAF is unthrottled. _startParentTickClock schedules requestAnimationFrame(tick) in the widget frame, sends postMessage({action: "tick"}) to the composition iframe every frame. Control bridge at bridge.ts:43-46 dispatches "tick" → onTick → calls seekTimelineAndAdapters(clock.now()) at init.ts:1598. Same call the composition's own rAF would have made.
The _paused = false ordering at hyperframes-player.ts:462 is load-bearing and the comment explains it: the rAF callback's if (this._paused) { ... return; } guard would self-terminate on the first frame if _paused were still true. Moving it before _startParentTickClock is correct.
Idempotency claim is true
seekTimelineAndAdapters at init.ts:1669-1696:
tl.totalTime(t, false)— second arg suppresses callback fire; sametis a no-op, differentt(a few microseconds later) just advances normallyadapter.seek({time: t})— deterministic adapters already get called every existing rAF tick; their idempotency is already the existing contract
So GSAP plus all deterministic adapters seek twice per frame in standard browsers (composition rAF + widget rAF). Tightly bounded extra work — both calls happen within the same frame (~16ms), and the work is two cheap timeline-position-sets. PR description's "idempotent" framing is accurate.
End-of-composition logic is duplicated
onTick at init.ts:1594-1614 and transportTick at init.ts:1792-1807 both implement end-of-composition handling with the same 8 lines:
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
const dur = clock.getDuration();
if (Number.isFinite(dur)) {
clock.seek(dur);
state.currentTime = dur;
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
postState(true);If someone fixes a bug in one path (say, adds a new adapter notification), they could easily miss the other — transportTick and onTick would drift behaviorally. Worth extracting a small handleEndOfComposition() helper that both call, so the shared invariants stay co-located.
Not blocking — both copies are correct today. But the duplication is a maintainability liability.
Subtle race: play() before iframe-ready
hyperframes-player.ts:475-477 guards _startParentTickClock behind this._ready && !this._directTimelineAdapter:
if (this._ready && !this._directTimelineAdapter) {
this._startParentTickClock();
}But — nothing retroactively starts the tick clock when _ready flips to true later. If user clicks play before the iframe-ready probe resolves (rare but possible during slow iframe loads), _sendControl("play") is queued and eventually delivered, BUT the parent tick clock never starts. In Electron, that means the composition iframe receives "play" and starts its own throttled rAF — which is the very thing this PR is fixing. Animation stays frozen.
In standard browsers this race is invisible because the composition iframe's own rAF works.
Fix would be one line in the readiness handler: if !_paused && !_directTimelineAdapter, call _startParentTickClock(). Worth considering — likely never seen in dev but could surface as flake in slow-network Electron sessions.
Tests pin the bridge contract
bridge.test.ts:114-119 — new dispatches tick command test pins the action === "tick" → deps.onTick() routing. Good. Worth adding a counterpart test for the _startParentTickClock lifecycle (start/stop on play/pause/seek) — hyperframes-player.test.ts already has a test suite for the player; one assertion that the rAF loop fires postMessage({action: "tick"}) while playing and stops after pause() would pin the integration.
Praise
- Choosing the existing control-bridge pattern over inventing a new postMessage channel is the right call —
onTickslots in next toonPlay/onPause/onSeekwith zero new surface. _stopParentTickClockis called in all four places it needs to be:pause(),seek(),_onIframeLoad(fresh iframe), anddisconnectedCallback()(component teardown). Symmetric cleanup.- The rAF callback's
if (this._paused)self-termination + the explicit_stopParentTickClock()frompause()give two paths to stop the loop. Defense-in-depth without overhead. - Diagnosis section in the PR description is exemplary — names the exact rAF throttling mechanism (Chromium's nested cross-origin policy), explains why scrubbing works (synchronous path), and traces the fix end-to-end.
- The runtime's own rAF loop is intentionally left running, with the explicit idempotency reasoning. No regression on claude.ai or any non-throttled environment.
mergeable_state: "blocked"
Likely Graphite stack — not a concern.
Review by Rames Jusso (pr-review)
miguel-heygen
left a comment
There was a problem hiding this comment.
Much cleaner approach than #738. Instead of hacking the transport loop with a setInterval fallback, this drives ticks from the parent widget frame's RAF — which isn't subject to the same throttling since it's one level up. Good architectural separation.
Verified: All 764 core tests pass, all 109 player tests pass. (2 test file-level failures are the pre-existing generated/runtime-inline build artifact issue, unrelated.)
Code review:
The implementation is solid:
_startParentTickClock()/_stopParentTickClock()are clean RAF loop management_paused = falsecorrectly moved before_startParentTickClock()to prevent immediate self-termination- Readiness guard (
this._ready && !this._directTimelineAdapter) prevents ticking into an uninitialized iframe - Cleanup paths are thorough: pause, seek, disconnect, iframe reload all stop the tick clock
- Bridge handler guards correctly:
state.tornDown || !clock.isPlaying()skips when not applicable
Two non-blocking observations:
-
Duplicated end-of-composition logic — The
onTickhandler ininit.ts:1594-1611duplicates thereachedEndhandling fromtransportTick. IftransportTick's end logic changes later, this copy could silently diverge. Consider extracting to a shared helper (not blocking — the duplication is small and well-commented). -
Root cause description — The PR still references "deeply nested cross-origin iframes" as the throttling trigger. Per Chromium's
FrameThrottlingTest.cppand Blink README, RAF throttling is visibility-based (out-of-viewport, display:none), not depth-based. The fix works regardless of the exact trigger mechanism, but the description could be more precise. Again, not blocking.
Clean PR, correct approach, tests green. Ship it.
— Magi
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: COMMENT — additive notes on top of Rames & Magi's APPROVE. No new blockers. Mechanism is correct, ship it.
Read the full diff + traced transportTick / onTick / bridge end-to-end. Rames covered the play-before-_ready race and the transportTick/onTick duplication; Magi flagged the duplication and the throttling-trigger framing. Adding angles below that I didn't see in either review.
important
packages/core/src/runtime/init.ts:1594-1614&:1772-1807— duplicated end-of-comp logic is not just a maintainability concern; it's a same-frame ordering hazard. In non-throttled environments both paths run on every frame. IfonTick(parent-driven) executes BEFOREtransportTickon the same frame at end-of-comp,onTickcallsclock.pause()+runAdapters("pause")+postState(true). ThentransportTickruns same frame: its end branch is gated onclock.isPlaying() && clock.reachedEnd(), so end-handling is correctly skipped — buttransportTickstill falls through topostState(false)at line 1814. Net effect on the parent: afinal: truestate immediately followed by afinal: falsestate for the same frame. Any consumer that usesfinalas a one-shot end-of-comp signal sees it get clobbered. The fix Rames suggested (extract a sharedhandleEndOfComposition()and a same-frame guard, e.g. astate.endedPostedlatch) addresses both the divergence risk AND this ordering. Worth folding in.
nits
-
packages/player/src/hyperframes-player.ts:984-993— no test covering tick-clock lifecycle.bridge.test.ts:114-119pins the dispatch contract on the receiver side, but there's nothing asserting_startParentTickClockactually fires onplay(), stops onpause()/seek()/disconnectedCallback()/_onIframeLoad(). Given those four cleanup sites are the whole point of the change, one test with a mockedrequestAnimationFrame+postMessagespy would pin the state machine. Higher value than the existing dispatch test. -
**
packages/player/src/hyperframes-player.ts:988 —_sendControl("tick")usestargetOrigin: "*"60× per second per player.** Pre-existing pattern, not a regression — but the cost calculus changes when one of the actions is high-frequency. Cheap to fix (cache the iframe's effective origin once on load and pass it in); useful guardrail against the sametick` payload being delivered to whatever document later occupies that frame after a cross-origin navigation. Not blocking. -
packages/core/src/runtime/bridge.ts:25-30— noevent.origincheck on incoming control messages. Also pre-existing, not introduced here. Mentioning becausetickis now a control action whose receipt advances the timeline — i.e. an attacker who can postMessage into the iframe can drive playback. Same threat model asplay/pause/seek(so no new exposure), but worth tracking as a follow-up sincetickmakes the bridge a continuous control surface, not a discrete one. -
120Hz / ProMotion displays.
requestAnimationFrameruns at display refresh rate, so on 120Hz monitors this doubles the postMessage rate (and the duplicateseekTimelineAndAdapterswork in non-throttled envs). Not a correctness issue — just a heads-up that the "harmless second tick" cost is display-bound, not capped at 60Hz. -
No observability that the parent tick clock is the driving force. When this regresses (Electron behavior changes, embed surface adds a new throttling layer, etc.), there's no signal — animation just freezes silently. A one-time
console.debugwhen_startParentTickClockfirst fires, or a counter exposed on the player, would make future "did the fix work?" answerable without bisecting.
praise
- Reusing the control-bridge rather than minting a new postMessage channel is the right call —
onTickslots in next to the existing actions with zero new surface area. _stopParentTickClockcorrectly called from all four cleanup sites (pause,seek,_onIframeLoad,disconnectedCallback). Symmetric.- The
_paused = falseordering comment at line 460 is exactly the kind of load-bearing context that survives a future refactor — keep doing this. - Diagnosis in the PR description is strong (modulo Magi's note on the precise throttling-trigger mechanism). Naming the synchronous-scrub baseline as proof the runtime is otherwise healthy is a clean way to localize the bug.
— Vai
Problem
The HyperFrames composition preview widget works on claude.ai but fails in Claude desktop (Electron):
Root Cause
The runtime drives all animation by calling
seekTimelineAndAdapters(clock.now())on everyrequestAnimationFrametick. GSAP is always paused; the runtime manually seeks it each frame. Chromium throttles RAF in deeply nested cross-origin iframes, so in Claude desktop the composition iframe's tick loop stalls — animation never advances even whenTransportClock.isPlaying()is true.Scrubbing works because
player.seek(t)callsseekTimelineAndAdapters(t)synchronously viawindow.__player.seek()— no RAF needed.Fix
Drive ticks from the widget-frame RAF, which lives one level above the composition iframe and is not subject to the same throttling.
When
play()takes the runtime bridge path (no direct timeline adapter),<hyperframes-player>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 callingseekTimelineAndAdapters(clock.now())— exactly whattransportTickdoes on each RAF, just driven from outside.The composition iframe's own RAF loop is unchanged — it keeps running normally in standard browsers. Seeking GSAP twice per frame is idempotent, so there is no regression on claude.ai or any non-throttled environment.
Changes
packages/player/src/hyperframes-player.ts_startParentTickClock()/_stopParentTickClock()wired intoplay(),pause(),seek(),disconnectedCallback(),_onIframeLoad();_pausedset before clock starts; readiness guard before starting tickspackages/core/src/runtime/init.tsonTickin control bridge: seeks GSAP to current clock time when playing; includes full end-of-composition handling (reachedEnd→ pause, seek-to-end,postState)packages/core/src/runtime/bridge.tsonTickadded toBridgeDeps;action === "tick"handlerpackages/core/src/runtime/types.ts"tick"added toRuntimeBridgeControlActionunionpackages/core/src/runtime/bridge.test.tsonTickadded to mock deps; tick dispatch test addedTest Plan
bun run --cwd packages/core test— 379 tests passbun run --cwd packages/player test— 109 tests pass🤖 Generated with Claude Code