Skip to content

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

Open
terencecho wants to merge 2 commits into
mainfrom
fix/electron-nested-iframe-raf-tick
Open

fix(player): drive composition ticks from widget-frame rAF via postMessage#739
terencecho wants to merge 2 commits into
mainfrom
fix/electron-nested-iframe-raf-tick

Conversation

@terencecho
Copy link
Copy Markdown
Contributor

@terencecho terencecho commented May 12, 2026

Problem

The HyperFrames composition preview widget works on claude.ai but fails in Claude desktop (Electron):

  • No autoplay — composition stays frozen
  • Play button does nothing
  • Scrubbing works correctly

Root Cause

The runtime drives all animation by calling seekTimelineAndAdapters(clock.now()) on every requestAnimationFrame tick. 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 when TransportClock.isPlaying() is true.

Scrubbing works because player.seek(t) calls seekTimelineAndAdapters(t) synchronously via window.__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 calling seekTimelineAndAdapters(clock.now()) — exactly what transportTick does on each RAF, just driven from outside.

Widget iframe RAF (unthrottled)
  → postMessage("tick") every frame
    → composition iframe control bridge
      → seekTimelineAndAdapters(clock.now())  ← animation advances

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

File Change
packages/player/src/hyperframes-player.ts _startParentTickClock() / _stopParentTickClock() wired into play(), pause(), seek(), disconnectedCallback(), _onIframeLoad(); _paused set before clock starts; readiness guard before starting ticks
packages/core/src/runtime/init.ts onTick in 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.ts onTick added to BridgeDeps; action === "tick" handler
packages/core/src/runtime/types.ts "tick" added to RuntimeBridgeControlAction union
packages/core/src/runtime/bridge.test.ts onTick added to mock deps; tick dispatch test added

Test Plan

  • Open a HyperFrames composition in Claude desktop MCP widget — verify autoplay works
  • Press play — verify animation advances
  • Scrub then press play — verify it works
  • Open same composition on claude.ai — verify no regression (autoplay and play still work, no double-ticking visible)
  • bun run --cwd packages/core test — 379 tests pass
  • bun run --cwd packages/player test — 109 tests pass

🤖 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>
- _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>
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.

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; same t is a no-op, different t (a few microseconds later) just advances normally
  • adapter.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 — onTick slots in next to onPlay/onPause/onSeek with zero new surface.
  • _stopParentTickClock is called in all four places it needs to be: pause(), seek(), _onIframeLoad (fresh iframe), and disconnectedCallback() (component teardown). Symmetric cleanup.
  • The rAF callback's if (this._paused) self-termination + the explicit _stopParentTickClock() from pause() 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)

Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

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

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 = false correctly 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:

  1. Duplicated end-of-composition logic — The onTick handler in init.ts:1594-1611 duplicates the reachedEnd handling from transportTick. If transportTick'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).

  2. Root cause description — The PR still references "deeply nested cross-origin iframes" as the throttling trigger. Per Chromium's FrameThrottlingTest.cpp and 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

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

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. If onTick (parent-driven) executes BEFORE transportTick on the same frame at end-of-comp, onTick calls clock.pause() + runAdapters("pause") + postState(true). Then transportTick runs same frame: its end branch is gated on clock.isPlaying() && clock.reachedEnd(), so end-handling is correctly skipped — but transportTick still falls through to postState(false) at line 1814. Net effect on the parent: a final: true state immediately followed by a final: false state for the same frame. Any consumer that uses final as a one-shot end-of-comp signal sees it get clobbered. The fix Rames suggested (extract a shared handleEndOfComposition() and a same-frame guard, e.g. a state.endedPosted latch) 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-119 pins the dispatch contract on the receiver side, but there's nothing asserting _startParentTickClock actually fires on play(), stops on pause() / seek() / disconnectedCallback() / _onIframeLoad(). Given those four cleanup sites are the whole point of the change, one test with a mocked requestAnimationFrame + postMessage spy 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 — no event.origin check on incoming control messages. Also pre-existing, not introduced here. Mentioning because tick is 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 as play/pause/seek (so no new exposure), but worth tracking as a follow-up since tick makes the bridge a continuous control surface, not a discrete one.

  • 120Hz / ProMotion displays. requestAnimationFrame runs at display refresh rate, so on 120Hz monitors this doubles the postMessage rate (and the duplicate seekTimelineAndAdapters work 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.debug when _startParentTickClock first 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 — onTick slots in next to the existing actions with zero new surface area.
  • _stopParentTickClock correctly called from all four cleanup sites (pause, seek, _onIframeLoad, disconnectedCallback). Symmetric.
  • The _paused = false ordering 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

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.

4 participants