diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index 5285c5940..ee192997f 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -86,7 +86,7 @@ describe("applyPreviewSync", () => { // reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin // CDN load failure escalates to a full reload — but it is NOT called eagerly. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); expect(reloadPreview).not.toHaveBeenCalled(); // A successful instant patch is the fast path; here it missed → fallback event. expect(trackStudioEvent).toHaveBeenCalledWith( @@ -113,7 +113,7 @@ describe("applyPreviewSync", () => { // U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state // is correct, so we must NOT escalate to a full reload. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); expect(reloadPreview).not.toHaveBeenCalled(); // Telemetry records the suppressed transient (escalated: false). expect(trackStudioEvent).toHaveBeenCalledWith( @@ -143,7 +143,7 @@ describe("applyPreviewSync", () => { ); // Structural failure: the preview is genuinely stale/broken → full reload. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); expect(reloadPreview).toHaveBeenCalledTimes(1); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", @@ -167,7 +167,7 @@ describe("applyPreviewSync", () => { ); expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); expect(reloadPreview).not.toHaveBeenCalled(); // "applied" emits no telemetry (only the failure paths do). expect(trackStudioEvent).not.toHaveBeenCalled(); @@ -185,7 +185,7 @@ describe("applyPreviewSync", () => { ); // onAsyncFailure is wired, but the transient result does not trigger it. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); expect(reloadPreview).not.toHaveBeenCalled(); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", @@ -204,7 +204,7 @@ describe("applyPreviewSync", () => { reloadPreview, ); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); expect(reloadPreview).toHaveBeenCalledTimes(1); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", @@ -345,7 +345,7 @@ describe("runCommit — instantPatch wiring", () => { }); expect(fetch).toHaveBeenCalledTimes(1); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0); expect(deps.reloadPreview).not.toHaveBeenCalled(); expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1); }); @@ -360,7 +360,7 @@ describe("runCommit — instantPatch wiring", () => { }); expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0); expect(deps.reloadPreview).not.toHaveBeenCalled(); }); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index a0fa4495f..e490f6ee5 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useRef } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; import type { SoftReloadResult } from "../utils/gsapSoftReload"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -66,7 +67,11 @@ function softReloadOrEscalate( reloadPreview: () => void, origin: "preview_sync" | "sdk_refresh", ): void { - const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview); + // Seek the rebuilt timeline to the studio's own authoritative scrub position, + // not the iframe's raw `__player.getTime()` — see the comment in + // applySoftReload for why the two can desync after a keyframe-node drag. + const currentTime = usePlayerStore.getState().currentTime; + const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime); if (result === "applied") return; trackStudioEvent("gsap_soft_reload_outcome", { origin, diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 34e168adc..1576a8bc8 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -100,6 +100,18 @@ describe("applySoftReload", () => { expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled(); }); + it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => { + // Regression: the iframe's raw __player.getTime() (2.0 here, per the mock) + // can desync from the studio's authoritative scrub position — e.g. a + // keyframe-node drag parks the playhead via the store before this reload's + // async commit resolves. The rebuilt timeline must re-seek to the caller's + // value, not the iframe's possibly-stale one. + const { iframe, contentWindow } = buildMockIframe(); + const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0); + expect(result).toBe("applied"); + expect(contentWindow.__player.seek).toHaveBeenCalledWith(0); + }); + it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => { // Repro: an element dragged via gsap.set whose keyframes were then removed is // no longer a timeline child, so the timeline-children sweep misses it. Its diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index a96de9703..1cea9b789 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -175,6 +175,7 @@ export function applySoftReload( iframe: HTMLIFrameElement | null, scriptText: string, onAsyncFailure?: () => void, + currentTimeOverride?: number, ): SoftReloadResult { if (!iframe || !scriptText) return "cannot-soft-reload"; @@ -210,7 +211,14 @@ export function applySoftReload( // rather than killing the target timeline and appending an orphan script. if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload"; - const currentTime = win.__player?.getTime?.() ?? 0; + // Prefer the caller-supplied scrub position (the studio's own authoritative + // currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`: + // the two can desync (a keyframe-node drag parks the playhead via the store + // BEFORE this reload's async commit resolves, and the iframe's own GSAP clock + // doesn't reliably reflect that yet), which re-seeks the freshly rebuilt + // timeline to the wrong frame and leaves the element (and its overlay) + // rendered at a stale/unrelated position. + const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0; // Track whether the MotionPath async path was taken. When it is, the script // executes inside pluginScript.onload — after applySoftReload has already @@ -300,8 +308,13 @@ export function applySoftReload( const s = doc.createElement("script"); s.textContent = `(function(){${scriptText}\n})();`; doc.body.appendChild(s); - win.__hfForceTimelineRebind?.(); + // Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render + // (see init.ts) renders the freshly-created timeline at whatever the + // runtime's internal scrub position already is, not at whatever we pass + // here afterward — a redundant seek() call after rebind can be a GSAP + // no-op if the timeline already reports being at that time internally. win.__player?.seek?.(currentTime); + win.__hfForceTimelineRebind?.(); win.__hfStudioManualEditsApply?.(); };