Skip to content

Nested paused GSAP timelines report duration 0, producing a silent all-black render #3419

Description

@miguel-heygen

Describe the bug

Nesting paused child timelines into a parent makes the parent report duration() === 0.
Every seek then resolves to t=0, so the render is entirely blank — and lint, check and
validate all pass.

This matters because our own composition contract requires paused timelines
("GSAP timelines must be paused and registered on window.__timelines"). An author who
follows that guidance for each of two scenes and combines them with .add() gets a black
video and no warning.

Steps to reproduce

Reproduced against gsap 3.15.0, the version in the workspace (reported independently on
3.14.2):

import { gsap } from "gsap";

const mk = (paused) => {
  const t = gsap.timeline({ paused });
  t.to({ x: 0 }, { x: 1, duration: 3 }, 0);
  return t;
};

for (const paused of [true, false]) {
  const a = mk(paused), b = mk(paused);
  const parent = gsap.timeline({ paused: true });
  parent.add(a, 0);
  parent.add(b, 0);
  console.log(`children paused=${paused}: child.duration=${a.duration()} parent.duration=${parent.duration()}`);
}
children paused=true:  child.duration=3 parent.duration=0
children paused=false: child.duration=3 parent.duration=3

Each child's own duration() is correct and getChildren() returns them with correct
start/duration. Only the parent's duration collapses.

Impact

packages/core/src/runtime/init.ts:675 and :721, startResolver.ts:99 and
timeline.ts:170 all derive composition duration from timeline.duration(). A registered
parent built this way reports 0, so seek(t > 0) renders the t=0 state — every element
still hidden — and the output is black.

Nothing catches it. This was found from a real composition (a matrix-rain background and a
text-decode foreground, each on its own paused timeline, combined via .add()) that
produced an all-black render and passed every gate.

Suggested fix

This is arguably GSAP behaving as designed — a paused child does not advance — so the fix
belongs on our side, as an invariant we can state exactly:

A registered timeline with getChildren().length > 0 and duration() === 0 is always
wrong.

There is no legitimate composition with children spanning time and zero total duration, so
checking it has no false-positive case. Failing loudly there converts a silent black render
into an actionable error naming the composition id.

A lint rule could catch the common authored spelling (gsap.timeline({ paused: true })
passed to .add()), but the runtime check is the one that cannot be evaded, since the
timeline may be assembled dynamically.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions