fix(core): keep nested scenes in place during a drag when the root has no timeline (#5115)

* fix(core): keep nested scenes in place during a drag when the root has no timeline

* fix(core): reuse the missing-root composite so a late root timeline still binds

* fix(core): count a nested scene in its composite length so a rebind keeps it at the playhead

* fix(core): rebuild a held composite whose length went stale so the player length stays right

* test(core): reuse the no-root-timeline loader for the stale-length case
This commit is contained in:
Miguel Ángel
2026-10-06 21:58:19 +00:00
committed by GitHub
parent b50dead1da
commit d94708e531
2 changed files with 155 additions and 9 deletions
@@ -2,6 +2,7 @@ import gsap from "gsap";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import type { RuntimeTimelineLike } from "./types";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
import { resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers";
// Pins where a running runtime places a sub-composition's timeline, against what a fresh load does.
@@ -14,6 +15,7 @@ describe("runtime sub-composition placement", () => {
afterEach(() => {
window.__hfRuntimeTeardown?.();
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete window.__player;
delete window.__playerReady;
delete window.__hf;
@@ -98,6 +100,123 @@ describe("runtime sub-composition placement", () => {
expect(intro.startTime()).toBe(2);
});
/** Transport frames on demand; the clock moves a second per frame, past the change-service rate limit. */
function stubFrames() {
const frames = new Map<number, FrameRequestCallback>();
let next = 0;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.set(++next, cb);
return next;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
let now = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => now);
return async (count: number) => {
for (let frame = 1; frame <= count; frame++) {
now += 1000;
await Promise.resolve();
const due = [...frames.values()];
frames.clear();
for (const run of due) run(frame * 16);
}
};
}
/** A root with no timeline of its own around one scene clip, whose timeline slides `#s` from 0 to 100 over 4 s. */
function loadWithoutRootTimeline(rootMarkup = "", rootLength = ` data-duration="10"`) {
document.body.innerHTML =
`<div data-composition-id="main" data-root="true"${rootLength}>${rootMarkup}` +
`<div id="host" class="clip" data-composition-id="scene" data-start="1" data-duration="4">` +
`<div id="s"></div></div></div>`;
const scene = gsap
.timeline({ paused: true })
.to("#s", { x: 100, duration: 4, ease: "none" }, 0);
window.__timelines = { scene } as unknown as Record<string, RuntimeTimelineLike>;
initSandboxRuntimeModular();
return scene;
}
it("keeps a nested scene where the playhead left it during a drag when the root has no timeline", async () => {
const runFrames = stubFrames();
loadWithoutRootTimeline();
window.__player?.seek(4);
expect(shownX()).toBe(75);
// Every transform GSAP writes while the drag is held, including ones a later write in the same tick hides.
const shown: string[] = [];
const writes = new MutationObserver((records) =>
shown.push(...records.map((r) => r.oldValue ?? "")),
);
writes.observe(document.getElementById("s")!, {
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
});
// A Studio press marks the element; a timing edit then wakes the transport's rebind mid-drag.
document.getElementById("s")!.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "gesture-1:move");
document.getElementById("host")!.setAttribute("data-duration", "4");
await runFrames(30);
shown.push(
...writes.takeRecords().map((r) => r.oldValue ?? ""),
document.getElementById("s")!.getAttribute("style") ?? "",
);
writes.disconnect();
const xs = shown.map((style) => /translate(?:3d)?\(([-\d.]+)/.exec(style)?.[1]).filter(Boolean);
expect(xs.length).toBeGreaterThan(0);
expect(new Set(xs)).toEqual(new Set(["75"]));
});
it("keeps a dragged scene where the playhead left it when another scene registers mid-drag", async () => {
const runFrames = stubFrames();
loadWithoutRootTimeline(
`<div id="later" class="clip" data-composition-id="later" data-start="0" data-duration="2"><div id="l"></div></div>`,
);
window.__player?.seek(4);
document.getElementById("s")!.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "gesture-1:move");
(window.__timelines as Record<string, unknown>).later = gsap
.timeline({ paused: true })
.to("#l", { x: 100, duration: 2, ease: "none" }, 0);
await runFrames(30);
expect(shownX()).toBe(75);
});
it("reports a scene's new length after a script extends it, when the root declares none", async () => {
const runFrames = stubFrames();
const scene = loadWithoutRootTimeline("", "");
await runFrames(3);
window.__player?.seek(2);
scene.to({}, { duration: 8 }, 0);
await runFrames(5);
expect(window.__player?.getDuration()).toBe(9);
});
it("binds a root timeline that registers after the runtime started on its children", async () => {
const runFrames = stubFrames();
loadWithoutRootTimeline(`<div id="r"></div>`);
const root = gsap
.timeline({ paused: true })
.to("#r", { x: 100, duration: 10, ease: "none" }, 0);
(window.__timelines as Record<string, unknown>).main = root;
await runFrames(30);
window.__player?.seek(5);
expect(Number(gsap.getProperty("#r", "x"))).toBe(50);
expect(shownX()).toBe(100);
});
it("places a scene at its host's new start on a rebind when the root has no timeline", () => {
const scene = loadWithoutRootTimeline();
document.getElementById("host")!.setAttribute("data-start", "4");
window.__hfForceTimelineRebind?.();
expect(scene.startTime()).toBe(4);
window.__player?.seek(5);
expect(shownX()).toBe(25);
});
it("leaves an unmoved scene in place on a rebind, whatever its start rounds to", () => {
const { root, scene } = load("0.33333333");
const remove = vi.spyOn(root, "remove");
+36 -9
View File
@@ -1375,6 +1375,8 @@ export function initSandboxRuntimeModular(): void {
// Sub-composition timelines the runtime nested into the root: the host composition id, and where.
const autoNested = new WeakMap<object, { hostId: string; parent: unknown; at: number }>();
// The composite that holds the children of a root with no timeline, and how many it holds.
let missingRootComposite: { timeline: RuntimeTimelineLike; size: number } | null = null;
const resolveRootTimelineFromDocument = (): TimelineResolution => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
@@ -1430,16 +1432,25 @@ export function initSandboxRuntimeModular(): void {
parent.add(candidate.timeline, at);
autoNested.set(candidate.timeline, { hostId: candidate.compositionId, parent, at });
};
/** Placed by the runtime in `root` and still there; under another root a re-run script owns its place. */
const placedIn = (
root: RuntimeTimelineLike,
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
) => {
const nested = candidate.timeline as RuntimeTimelineLike & RuntimeTimelineChildLike;
const placed = autoNested.get(nested);
return placed && placed.parent === root && nested.parent === (root as unknown)
? placed
: null;
};
const atHostStart = (placed: { at: number }, candidate: { compositionId: string }) =>
Math.abs(placed.at - resolveCompositionStartSeconds(candidate.compositionId)) < 1e-6;
const followHostStart = (
root: RuntimeTimelineLike,
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
): void => {
const nested = candidate.timeline as RuntimeTimelineLike & RuntimeTimelineChildLike;
const placed = autoNested.get(nested);
// Under another root, a re-run script placed it, and owns that place as on a fresh load.
if (!placed || placed.parent !== root || nested.parent !== (root as unknown)) return;
if (Math.abs(placed.at - resolveCompositionStartSeconds(candidate.compositionId)) < 1e-6)
return;
const placed = placedIn(root, candidate);
if (!placed || atHostStart(placed, candidate)) return;
// GSAP takes a child out of its parent before adding it; the rebind's unpause aligns it to the root.
nestAtHostStart(root, candidate);
};
@@ -1454,6 +1465,8 @@ export function initSandboxRuntimeModular(): void {
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
for (const candidate of candidates) nestAtHostStart(compositeTimeline, candidate);
// Before any duration read: GSAP leaves paused children out of the duration it caches.
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, candidates));
return compositeTimeline;
};
const createDurationFloorTimeline = (
@@ -1632,7 +1645,6 @@ export function initSandboxRuntimeModular(): void {
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
if (compositeTimeline && isUsableTimelineDuration(compositeDurationSeconds)) {
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, rootChildCandidates));
return {
timeline: compositeTimeline,
selectedTimelineIds,
@@ -1786,10 +1798,25 @@ export function initSandboxRuntimeModular(): void {
}
if (rootChildCandidates.length > 0) {
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
// A rebuild re-parents every child at time 0 and paints it there, under a paused drag too,
// so the same children, each still at its host's start, keep the composite they sit in.
const held = missingRootComposite;
const reused =
held?.size === rootChildCandidates.length &&
isUsableTimelineDuration(getTimelineDurationSeconds(held.timeline)) &&
rootChildCandidates.every((candidate) => {
const placed = placedIn(held.timeline, candidate);
return placed !== null && atHostStart(placed, candidate);
})
? held.timeline
: null;
const compositeTimeline =
reused ?? createCompositeTimelineFromCandidates(rootChildCandidates);
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
if (compositeTimeline) {
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, rootChildCandidates));
if (!reused) {
missingRootComposite = { timeline: compositeTimeline, size: rootChildCandidates.length };
}
return {
timeline: compositeTimeline,
selectedTimelineIds,