From 0160fbee2127ef885ba637657c117709af7079ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 21 Oct 2026 15:50:13 +0000 Subject: [PATCH] fix(core): exported scenes keep their frames when later scenes are added (#6035) * fix(core): exported scenes keep their frames when later scenes are added * style(core): format the root length test and shorten its fix comment --- .../core/src/runtime/init.rootLength.test.ts | 64 +++++++++++++++++++ packages/core/src/runtime/init.ts | 8 +++ packages/core/src/runtime/types.ts | 1 + 3 files changed, 73 insertions(+) create mode 100644 packages/core/src/runtime/init.rootLength.test.ts diff --git a/packages/core/src/runtime/init.rootLength.test.ts b/packages/core/src/runtime/init.rootLength.test.ts new file mode 100644 index 0000000000..0dc084c4ba --- /dev/null +++ b/packages/core/src/runtime/init.rootLength.test.ts @@ -0,0 +1,64 @@ +import gsap from "gsap"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { initSandboxRuntimeModular } from "./init"; +import type { RuntimeTimelineLike } from "./types"; +import { resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; + +// A render seek must draw each scene once, at its own time, however many scenes follow the playhead. +describe("runtime root length across render seeks", () => { + beforeEach(() => { + resetRuntimeFixtureDom(); + window.gsap = gsap as unknown as typeof window.gsap; + }); + + afterEach(() => { + window.__hfRuntimeTeardown?.(); + delete window.__player; + delete window.__playerReady; + delete window.__hf; + }); + + /** `sceneCount` five-second scenes back to back; returns the local times scene 1 is drawn at. */ + function load(sceneCount: number) { + const hosts = Array.from( + { length: sceneCount }, + (_, i) => `
`, + ).join(""); + document.body.innerHTML = `
${hosts}
`; + const timelines: Record = { + main: gsap.timeline({ paused: true }) as unknown as RuntimeTimelineLike, + }; + const drawnAt: number[] = []; + for (let i = 0; i < sceneCount; i++) { + const target = { v: 0, w: 0 }; + const scene = gsap + .timeline({ paused: true }) + .to(target, { v: 1, duration: 5, ease: "none" }, 0) + .to(target, { w: 1, duration: 1 }, 0) + // Overwrites the tween above on its first render, which marks the root's length stale. + .to(target, { w: 2, duration: 1, overwrite: "auto" }, 0); + if (i === 1) + scene.eventCallback("onUpdate", () => drawnAt.push(Number(scene.time().toFixed(4)))); + timelines[`s${i}`] = scene as unknown as RuntimeTimelineLike; + } + window.__timelines = timelines; + initSandboxRuntimeModular(); + return drawnAt; + } + + function renderFrames(from: number, to: number) { + for (let frame = from; frame <= to; frame++) window.__player?.renderSeek(frame / 30); + } + + it("draws a scene at its own time when no scene follows it", () => { + const drawnAt = load(2); + renderFrames(210, 214); + expect(drawnAt).toEqual([2, 2.0333, 2.0667, 2.1, 2.1333]); + }); + + it("draws a scene at its own time when a later scene first renders during the seek", () => { + const drawnAt = load(3); + renderFrames(210, 214); + expect(drawnAt).toEqual([2, 2.0333, 2.0667, 2.1, 2.1333]); + }); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fab9328bc3..ac9ca35880 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4638,6 +4638,14 @@ export function initSandboxRuntimeModular(): void { swallow("runtime.init.activateSiblings", err); } } + if (rearmed.length === 0) return rearmed; + // GSAP leaves paused scenes out of the root's length and play() keeps that stale length. + try { + masterTimeline.shiftChildren?.(0); + masterTimeline.totalDuration?.(); + } catch (err) { + swallow("runtime.init.remeasureRoot", err); + } return rearmed; }; diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index fe70857f01..35ed38aea7 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -317,6 +317,7 @@ export type RuntimeTimelineLike = { seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown; totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown; totalDuration?: () => number; + shiftChildren?: (amount: number) => unknown; progress?: (value?: number, suppressEvents?: boolean) => unknown; time: () => number; duration: () => number;