Skip to content

fix(runtime): composition loading stuck indefinitely on multi-slide decks#694

Merged
miguel-heygen merged 1 commit into
nextfrom
fix/composition-loading-stuck
May 9, 2026
Merged

fix(runtime): composition loading stuck indefinitely on multi-slide decks#694
miguel-heygen merged 1 commit into
nextfrom
fix/composition-loading-stuck

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Problem

The apple-presentation (and any composition with external sub-compositions loaded via data-composition-src) shows "Loading composition — Preparing the Studio preview" indefinitely in the Studio.

Root cause

Multi-slide compositions load child compositions via fetch() in loadExternalCompositions. The root GSAP timeline is only available after all children finish loading. But the TransportClock duration was only set once during initial runtime setup (line 1978):

if (state.capturedTimeline) {
  const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
  if (dur > 0) clock.setDuration(dur);
}

At this point, state.capturedTimeline is null (external compositions haven't loaded yet), so clock.getDuration() stays at 0.

When loadExternalCompositions finally resolves and bindRootTimelineIfAvailable() captures the root timeline, it sets state.capturedTimeline but never updates the clock. player.getDuration() continues returning 0, so the player's probe interval never fires the ready event (which requires adapter.getDuration() > 0), and the loading overlay stays forever.

Fix

bindRootTimelineIfAvailable now calls clock.setDuration(boundDuration) when it late-binds the root timeline. Guarded with try/catch for the early call site (line 1403) where clock is in the temporal dead zone.

Test plan

  • All 6 runtime init tests pass
  • Core typecheck clean
  • Pre-commit hooks pass

🤖 Generated with Claude Code

Compositions with external sub-compositions (like apple-presentation
with 7 slides) load child compositions via fetch(). The root GSAP
timeline is only bound after all external compositions finish loading,
but the TransportClock duration was only set during initial setup.

When bindRootTimelineIfAvailable runs after the external compositions
load, it captures the root timeline but never updates the clock.
player.getDuration() continues returning 0, so the player's probe
interval never fires the 'ready' event, and the Studio shows "Loading
composition" indefinitely.

Now bindRootTimelineIfAvailable updates clock.setDuration when the
root timeline is late-bound. Guarded with try/catch for the early call
site where clock is not yet initialized (temporal dead zone).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@miguel-heygen miguel-heygen merged commit bb02479 into next May 9, 2026
23 checks passed
@miguel-heygen miguel-heygen deleted the fix/composition-loading-stuck branch May 9, 2026 21:43
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.

Post-merge advisory review (this PR is already merged into next). Findings flagged for follow-up, not gating.

Verdict — Approve, 2 follow-ups

Surgical 9-LOC fix with a textbook root cause analysis: state.capturedTimeline is null at initial setup, the clock duration never gets updated when the root timeline late-binds after loadExternalCompositions resolves, the probe interval never fires ready, and the loading overlay hangs. The fix is the minimal correct change.

Follow-ups

  1. Test added: it("plays without captured root timeline when audio has failed") — but no test for the specific late-bind path this PR fixes. I'd want a test that asserts: "when bindRootTimelineIfAvailable runs after state.capturedTimeline is null at initial setup, the clock duration gets updated and getDuration() returns >0." The current test coverage in PR #694's referenced init.test.ts doesn't explicitly cover this path. Reason: the bug was hard to find precisely because there was no test for the late-bind interaction; adding one now codifies the invariant.

  2. The try/catch around clock.setDuration(boundDuration) swallows a real error case. Comment says "clock not yet initialized — duration will be set during TransportClock setup" — that's the intended swallow, but if setDuration throws for a different reason (e.g. invalid duration value, internal clock state corruption), it gets silently lost. Reason: hard to debug later. Suggest narrowing the catch: only swallow if it's the specific TDZ error you expect, else re-throw or log.

Important

  • getSafeTimelineDurationSeconds(state.capturedTimeline, 0) is called twice in the touched function — once existing, once in the new code block. Cache the result.

Nits

  • The if (boundDuration > 0) guard is repeated from the original code path — would be cleaner as a single helper that sets duration and pauses the timeline.

Praise

  • The root cause writeup in the PR body is excellent. Line-numbered references to the failing logic, exact explanation of why state.capturedTimeline is null at line 1978 but bound later, and the precise consequence (player.getDuration() === 0 → probe interval never fires ready). This is the gold standard for runtime bug PRs.
  • Minimal scope: 1 file, 9 LOC. Right size for a critical-path runtime fix.

— 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.

2 participants