diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts index af9b893b1..fab857dd2 100644 --- a/packages/studio/src/hooks/gsapDragCommit.test.ts +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -3,6 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { commitGsapPositionFromDrag, + commitStaticGsapPosition, + commitStaticGsapRotation, parkPlayheadOnKeyframe, type GsapDragCommitCallbacks, } from "./gsapDragCommit"; @@ -235,6 +237,181 @@ describe("commitGsapPositionFromDrag — from() tween dragged outside its range" }); }); +// Captures the OPTIONS each commit carries (not just the mutation) so we can +// assert which value-only commits attach the `instantPatch` fast path. +type RecordedCommit = { mutation: Record; options: Record }; +function optionRecordingCallbacks(): { + commits: RecordedCommit[]; + callbacks: GsapDragCommitCallbacks; +} { + const commits: RecordedCommit[] = []; + return { + commits, + callbacks: { + commitMutation: async (_sel, mutation, options) => { + commits.push({ mutation, options: options as Record }); + }, + fetchAnimations: async () => [convertedTween()], + }, + }; +} + +const existingPositionSet = (): GsapAnimation => + ({ + id: "#puck-a-set", + targetSelector: "#puck-a", + method: "set", + properties: { x: 10, y: 20 }, + }) as unknown as GsapAnimation; + +const existingRotationSet = (): GsapAnimation => + ({ + id: "#puck-a-rot-set", + targetSelector: "#puck-a", + method: "set", + properties: { rotation: 15 }, + }) as unknown as GsapAnimation; + +describe("commitStaticGsapPosition — instantPatch (value-only set)", () => { + beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); + + it("attaches an instantPatch to BOTH coalesced commits, each derived from its own mutation", async () => { + const { commits, callbacks } = optionRecordingCallbacks(); + + await commitStaticGsapPosition( + selection(), + { x: -50, y: 30 }, // studioOffset → newX/newY off a zero base + { x: 0, y: 0 }, + "#puck-a", + existingPositionSet(), + callbacks, + ); + + expect(commits).toHaveLength(2); + // First (x) commit is the intermediate skipReload one — it now carries an + // instantPatch for just {x}, so if the SECOND POST fails the preview still + // reflects the x that DID persist (no reload, instant feedback). + expect(commits[0].options.skipReload).toBe(true); + expect(commits[0].options.instantPatch).toEqual({ + selector: "#puck-a", + change: { kind: "set", props: { x: -50 } }, + }); + // Final (y) commit triggers the reload and carries the full {x,y} patch. + expect(commits[1].options.softReload).toBe(true); + expect(commits[1].options.instantPatch).toEqual({ + selector: "#puck-a", + change: { kind: "set", props: { x: -50, y: 30 } }, + }); + }); + + it("derives each instantPatch's props from the value in the SAME mutation that's POSTed", async () => { + const { commits, callbacks } = optionRecordingCallbacks(); + + await commitStaticGsapPosition( + selection(), + { x: -50, y: 30 }, + { x: 0, y: 0 }, + "#puck-a", + existingPositionSet(), + callbacks, + ); + + // The patch values must equal the mutation values — they're read out of the + // same object, so a clean mutation can't ship alongside a stale patch. + const xMutation = commits[0].mutation as { property: string; value: number }; + const yMutation = commits[1].mutation as { property: string; value: number }; + const xPatch = commits[0].options.instantPatch as { + change: { props: Record }; + }; + const yPatch = commits[1].options.instantPatch as { + change: { props: Record }; + }; + expect(xPatch.change.props[xMutation.property]).toBe(xMutation.value); + expect(yPatch.change.props[yMutation.property]).toBe(yMutation.value); + // The y commit's combined patch also carries the x mutation's value. + expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value); + }); + + it("does NOT attach instantPatch when ADDING a new set (structural — new tween)", async () => { + const { commits, callbacks } = optionRecordingCallbacks(); + + await commitStaticGsapPosition( + selection(), + { x: -50, y: 30 }, + { x: 0, y: 0 }, + "#puck-a", + null, // no existing set → `add` a new tween + callbacks, + ); + + expect(commits).toHaveLength(1); + expect(commits[0].mutation.type).toBe("add"); + expect(commits[0].options.instantPatch).toBeUndefined(); + }); +}); + +describe("commitStaticGsapRotation — instantPatch (value-only set)", () => { + beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); + + it("attaches instantPatch {kind:set, props:{rotation}} when updating an existing rotation set", async () => { + const { commits, callbacks } = optionRecordingCallbacks(); + + await commitStaticGsapRotation(selection(), 42, "#puck-a", existingRotationSet(), callbacks); + + expect(commits).toHaveLength(1); + expect(commits[0].mutation.type).toBe("update-property"); + expect(commits[0].options.instantPatch).toEqual({ + selector: "#puck-a", + change: { kind: "set", props: { rotation: 42 } }, + }); + // Patch value derived from the SAME mutation that's POSTed (one source). + const m = commits[0].mutation as { property: string; value: number }; + const patch = commits[0].options.instantPatch as { + change: { props: Record }; + }; + expect(patch.change.props[m.property]).toBe(m.value); + }); + + it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => { + const { commits, callbacks } = optionRecordingCallbacks(); + + await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks); + + expect(commits).toHaveLength(1); + expect(commits[0].mutation.type).toBe("add"); + expect(commits[0].options.instantPatch).toBeUndefined(); + }); +}); + +describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => { + beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); + + it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => { + usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe + const { commits, callbacks } = optionRecordingCallbacks(); + + await commitGsapPositionFromDrag( + selection(), + flatTween(), + { x: -100, y: 0 }, + { x: 0, y: 0 }, + null, + "#puck-a", + callbacks, + ); + + // The keyframe path is structural here (convert + add-keyframe) and must rely + // on the soft reload — none of its commits opt into the instant patch. + expect(commits.length).toBeGreaterThan(0); + for (const c of commits) { + expect(c.options.instantPatch).toBeUndefined(); + } + const types = commits.map((c) => c.mutation.type); + expect(types).toContain("convert-to-keyframes"); + expect(types).toContain("add-keyframe"); + }); +}); + describe("parkPlayheadOnKeyframe", () => { beforeEach(() => usePlayerStore.setState({ requestedSeekTime: null })); diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index b201d5f7a..cab3cee39 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -10,6 +10,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp import { roundTo3 } from "../utils/rounding"; import { computeElementPercentage } from "./gsapShared"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; +import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch"; export interface GsapDragCommitCallbacks { commitMutation: ( selection: DomEditSelection, @@ -20,6 +21,13 @@ export interface GsapDragCommitCallbacks { softReload?: boolean; skipReload?: boolean; beforeReload?: () => void; + /** + * Value-only fast path: when set, `runCommit` patches the changed tween in + * the preview runtime in place (instant, no re-run) and only falls back to + * the soft reload if the patch can't be safely applied. Attached only to + * value-only `set` commits; structural/keyframe commits omit it. + */ + instantPatch?: { selector: string; change: RuntimeTweenChange }; }, ) => Promise; fetchAnimations?: () => Promise; @@ -373,6 +381,37 @@ async function commitFlatViaKeyframes( // without importing the GSAP commit graph (store/runtime/core). export { computeDraggedGsapPosition }; +/** The shape of an `update-property` mutation a static-set nudge POSTs. */ +interface UpdatePropertyMutation { + type: "update-property"; + animationId: string; + property: string; + value: number; +} + +/** + * Build the `instantPatch` for a value-only `tl.set` from the SAME + * `update-property` mutation(s) that are POSTed — so the patch can never carry a + * value the source write didn't (one source of truth). Each mutation contributes + * its `{property: value}` channel to the patch's props. + */ +function setPatchFromUpdateProperties( + selector: string, + mutations: UpdatePropertyMutation[], +): { selector: string; change: RuntimeTweenChange } { + const props: SetPatchProps = {}; + for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value; + return { selector, change: { kind: "set", props } }; +} + +/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */ +function setPatchFromUpdateProperty( + selector: string, + mutation: UpdatePropertyMutation, +): { selector: string; change: RuntimeTweenChange } { + return setPatchFromUpdateProperties(selector, [mutation]); +} + /** * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})` * with no duration. This is what a static-element nudge writes/updates. @@ -412,16 +451,41 @@ export async function commitStaticGsapPosition( // Update in place — two single-property mutations (the API updates one prop // per call). Coalesce them and reload only after the second lands. const coalesceKey = `gsap:set-nudge:${existingSet.id}`; - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: existingSet.id, property: "x", value: newX }, - { label: "Move layer", skipReload: true, coalesceKey }, - ); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: existingSet.id, property: "y", value: newY }, - { label: "Move layer", softReload: true, coalesceKey }, - ); + // Build each mutation FIRST, then derive its instantPatch from the SAME + // object that's POSTed — so a future caller can't ship a clean mutation with + // a stale/malformed patch (the validated `value` flows straight into the + // patch). `findUnsafeMutationValues` validates the mutation upstream. + const xMutation = { + type: "update-property", + animationId: existingSet.id, + property: "x", + value: newX, + } as const; + const yMutation = { + type: "update-property", + animationId: existingSet.id, + property: "y", + value: newY, + } as const; + // Patch BOTH coalesced commits. If the SECOND POST fails server-side, the + // first (x) already persisted — patching its commit too means the live + // preview still reflects what DID persist. The x commit carries skipReload + // (no reload), so its instantPatch gives instant feedback without a reload; + // the y commit triggers the soft reload (skipped when the patch applies). + await callbacks.commitMutation(selection, xMutation, { + label: "Move layer", + skipReload: true, + coalesceKey, + instantPatch: setPatchFromUpdateProperty(selector, xMutation), + }); + await callbacks.commitMutation(selection, yMutation, { + label: "Move layer", + softReload: true, + coalesceKey, + // Final commit of the coalesced x/y pair: carry both channels so the + // runtime `tl.set` lands the complete {x,y} pose in place. + instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]), + }); return; } await callbacks.commitMutation( @@ -466,16 +530,21 @@ export async function commitStaticGsapRotation( callbacks: GsapDragCommitCallbacks, ): Promise { if (existingSet) { - await callbacks.commitMutation( - selection, - { - type: "update-property", - animationId: existingSet.id, - property: "rotation", - value: newRotation, - }, - { label: "Rotate layer", softReload: true }, - ); + // Derive the instantPatch from the SAME mutation object that's POSTed (single + // source of truth — see commitStaticGsapPosition), so the validated `value` + // flows into the patch and the two can't drift. + const rotationMutation = { + type: "update-property", + animationId: existingSet.id, + property: "rotation", + value: newRotation, + } as const; + await callbacks.commitMutation(selection, rotationMutation, { + label: "Rotate layer", + softReload: true, + // Value-only rotation set: patch the runtime `tl.set` rotation in place. + instantPatch: setPatchFromUpdateProperty(selector, rotationMutation), + }); return; } await callbacks.commitMutation( diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts index 45108d526..e96efea7c 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -89,6 +89,44 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele expect(mutation.type).not.toBe("add-keyframe"); }); + it("forwards instantPatch on BOTH coalesced commits when updating an existing static set", async () => { + const commitMutation = vi.fn(); + const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path + // An existing position-hold `set` for the selector → update-in-place (not add). + const existingSet = { + id: "#puck-b-set", + targetSelector: "#puck-b", + method: "set", + // Tagged as a position group so resolveGroupTween returns it directly + // (no split commit), exercising the in-place update path cleanly. + propertyGroup: "position", + properties: { x: 0, y: 0 }, + } as unknown as GsapAnimation; + + const handled = await tryGsapDragIntercept( + selection, + { x: -50, y: 30 }, + [existingSet], + iframe, + commitMutation, + ); + + expect(handled).toBe(true); + // The coalesced update-property pair both carry an instantPatch so a partial + // (second-POST) failure still leaves the preview patched for what persisted: + // the x commit patches {x}, the final y commit patches the full {x,y}. + const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-property"); + expect(updates).toHaveLength(2); + expect(updates[0][2].instantPatch).toEqual({ + selector: "#puck-b", + change: { kind: "set", props: { x: -50 } }, + }); + expect(updates[1][2].instantPatch).toEqual({ + selector: "#puck-b", + change: { kind: "set", props: { x: -50, y: 30 } }, + }); + }); + it("does not trip the stale-parse guard when the runtime still has the tween", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const liveTween = { diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 13136bd84..e60b1282b 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; +import type { RuntimeTweenChange } from "./gsapRuntimePatch"; export interface MutationResult { ok: boolean; @@ -26,6 +27,15 @@ export interface CommitMutationOptions { * (and under distinct keys) run concurrently as before. */ serializeKey?: string; + /** + * Value-only edit fast path. When present, `runCommit` first tries to patch the + * one changed tween in the preview's runtime timeline in place + * (`patchRuntimeTweenInPlace`) — instant, no composition re-run, no iframe + * remount. On a successful patch the reload is skipped entirely (panels still + * refresh); when the patch can't be confidently applied it falls back to the + * existing soft/full reload path. Structural edits omit this and reload as before. + */ + instantPatch?: { selector: string; change: RuntimeTweenChange }; } export type CommitMutation = ( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx new file mode 100644 index 000000000..f2a10c491 --- /dev/null +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -0,0 +1,292 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the two preview-sync primitives so we can assert which path runCommit took. +// `patchRuntimeTweenInPlace` is the instant in-place patch; `applySoftReload` is +// the existing fallback. `extractGsapScriptText` is re-exported from the same +// module and used elsewhere in the hook — keep it a harmless stub. +const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>(); +const applySoftReload = vi.fn<(...args: unknown[]) => boolean>(); + +vi.mock("./gsapRuntimePatch", () => ({ + patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args), +})); +vi.mock("../utils/gsapSoftReload", () => ({ + applySoftReload: (...args: unknown[]) => applySoftReload(...args), + extractGsapScriptText: () => "", +})); + +// Tell React this is an act-capable environment so act(...) flushes effects +// without warning (React reads this global at call time). +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { MutationResult } from "./gsapScriptCommitTypes"; +import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits"; + +// ── applyPreviewSync (pure preview-sync decision) ──────────────────────────── + +const FAKE_IFRAME = {} as HTMLIFrameElement; + +function result(over: Partial = {}): MutationResult { + return { ok: true, scriptText: "tl.set('#a',{})", ...over }; +} + +describe("applyPreviewSync", () => { + beforeEach(() => { + patchRuntimeTweenInPlace.mockReset(); + applySoftReload.mockReset(); + }); + + it("instantPatch + patch succeeds: skips both soft reload and full reload", () => { + patchRuntimeTweenInPlace.mockReturnValue(true); + const reloadPreview = vi.fn(); + + applyPreviewSync( + FAKE_IFRAME, + result(), + { + label: "drag", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, + }, + reloadPreview, + ); + + expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", { + kind: "set", + props: { x: 10 }, + }); + expect(applySoftReload).not.toHaveBeenCalled(); + expect(reloadPreview).not.toHaveBeenCalled(); + }); + + it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => { + patchRuntimeTweenInPlace.mockReturnValue(false); + applySoftReload.mockReturnValue(true); + const reloadPreview = vi.fn(); + + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT" }), + { + label: "drag", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, + }, + reloadPreview, + ); + + // 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(reloadPreview).not.toHaveBeenCalled(); + }); + + it("instantPatch + patch fails + soft reload returns false: does NOT sync-escalate (U4)", () => { + patchRuntimeTweenInPlace.mockReturnValue(false); + applySoftReload.mockReturnValue(false); + const reloadPreview = vi.fn(); + + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT" }), + { + label: "drag", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, + }, + reloadPreview, + ); + + // U4: the synchronous false return means the soft reload couldn't run, NOT + // that the preview is broken — escalation happens only via onAsyncFailure. + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(reloadPreview).not.toHaveBeenCalled(); + }); + + it("no instantPatch + softReload + scriptText: soft reloads, passing onAsyncFailure", () => { + applySoftReload.mockReturnValue(true); + const reloadPreview = vi.fn(); + + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT" }), + { label: "x", softReload: true }, + reloadPreview, + ); + + expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(reloadPreview).not.toHaveBeenCalled(); + }); + + it("no instantPatch + softReload that returns false: does NOT sync-escalate (U4)", () => { + applySoftReload.mockReturnValue(false); + const reloadPreview = vi.fn(); + + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT" }), + { label: "x", softReload: true }, + reloadPreview, + ); + + // onAsyncFailure is wired, but the sync false return does not trigger it. + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview); + expect(reloadPreview).not.toHaveBeenCalled(); + }); + + it("no instantPatch + no softReload: full reload (today's behavior)", () => { + const reloadPreview = vi.fn(); + + applyPreviewSync(FAKE_IFRAME, result(), { label: "x" }, reloadPreview); + + expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); + expect(applySoftReload).not.toHaveBeenCalled(); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); +}); + +// ── runCommit (full hook path: persist + preview sync) ─────────────────────── + +type HookApi = ReturnType; + +let cleanup: (() => void) | null = null; + +function renderCommitHook() { + const reloadPreview = vi.fn(); + const onCacheInvalidate = vi.fn(); + const forceReloadSdkSession = vi.fn(); + const recordEdit = vi.fn(async () => {}); + const showToast = vi.fn(); + + const captured: { api: HookApi | null } = { api: null }; + function Probe() { + captured.api = useGsapScriptCommits({ + projectIdRef: { current: "proj-1" }, + activeCompPath: "index.html", + previewIframeRef: { current: FAKE_IFRAME }, + editHistory: { recordEdit }, + domEditSaveTimestampRef: { current: 0 }, + reloadPreview, + onCacheInvalidate, + onFileContentChanged: undefined, + showToast, + sdkSession: null, + writeProjectFile: undefined, + forceReloadSdkSession, + }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + act(() => { + root.render(); + }); + cleanup = () => act(() => root.unmount()); + const hookApi = captured.api; + if (!hookApi) throw new Error("hook did not initialize"); + return { + api: hookApi, + reloadPreview, + onCacheInvalidate, + forceReloadSdkSession, + recordEdit, + showToast, + }; +} + +const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection; + +function mockFetchResult(over: Partial = {}): void { + const body: MutationResult = { + ok: true, + changed: true, + before: "BEFORE", + after: "AFTER", + scriptText: "SCRIPT", + ...over, + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => body }) as unknown as Response), + ); +} + +describe("runCommit — instantPatch wiring", () => { + beforeEach(() => { + patchRuntimeTweenInPlace.mockReset(); + applySoftReload.mockReset(); + }); + afterEach(() => { + cleanup?.(); + cleanup = null; + vi.unstubAllGlobals(); + }); + + it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => { + patchRuntimeTweenInPlace.mockReturnValue(true); + mockFetchResult(); + const deps = renderCommitHook(); + + await act(async () => { + await deps.api.commitMutation( + selection, + { x: 10 }, + { + label: "drag", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, + }, + ); + }); + + expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted + expect(deps.recordEdit).toHaveBeenCalledTimes(1); + expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1); + expect(applySoftReload).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); + + it("instantPatch fails: persists AND falls back to soft reload", async () => { + patchRuntimeTweenInPlace.mockReturnValue(false); + applySoftReload.mockReturnValue(true); + mockFetchResult(); + const deps = renderCommitHook(); + + await act(async () => { + await deps.api.commitMutation( + selection, + { x: 10 }, + { + label: "drag", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, + }, + ); + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1); + }); + + it("no instantPatch: identical to today — soft reload when softReload+scriptText", async () => { + applySoftReload.mockReturnValue(true); + mockFetchResult(); + const deps = renderCommitHook(); + + await act(async () => { + await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true }); + }); + + expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); + expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 6752351f4..cac7c732d 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -4,6 +4,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; import type { CutoverDeps } from "../utils/sdkCutover"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; +import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch"; import { createKeyedSerializer } from "./serializeByKey"; import { GsapMutationHttpError, @@ -43,6 +44,41 @@ async function mutateGsapScript( return result; } +/** + * Sync the preview after a persisted commit. For a value-only edit + * (`options.instantPatch`), try the in-place runtime patch first: on success the + * preview is already correct, so we skip the reload entirely (instant). On `false` + * — or when no `instantPatch` is supplied — fall back to the existing soft/full + * reload. Pure (no React) so `runCommit`'s preview-sync decision is unit-testable. + */ +export function applyPreviewSync( + iframe: HTMLIFrameElement | null, + result: MutationResult, + options: CommitMutationOptions, + reloadPreview: () => void, +): void { + if (options.instantPatch) { + const patched = patchRuntimeTweenInPlace( + iframe, + options.instantPatch.selector, + options.instantPatch.change, + ); + // Patched in place — element is already correct on screen; no reload needed. + if (patched) return; + // Fall through to the soft/full reload path below. + } + if (options.softReload && result.scriptText) { + // Per U4, do NOT escalate on the synchronous `false` return (it means + // "soft-reload couldn't run; the value is unchanged on screen, not broken" + // — a full reload would re-flash the WebGL context for nothing). Only the + // async MotionPath-plugin load failure escalates, via `onAsyncFailure`, + // which fires after a soft reload that already returned true optimistically. + applySoftReload(iframe, result.scriptText, reloadPreview); + } else { + reloadPreview(); + } +} + // oxfmt-ignore // fallow-ignore-next-line complexity export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { @@ -82,15 +118,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra if (options.skipReload) return; if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); options.beforeReload?.(); - let applied: "soft" | "full" = "full"; - if (options.softReload && result.scriptText) { - applied = applySoftReload(previewIframeRef.current, result.scriptText, reloadPreview) - ? "soft" - : "full"; - if (applied === "full") reloadPreview(); - } else { - reloadPreview(); - } + applyPreviewSync(previewIframeRef.current, result, options, reloadPreview); onCacheInvalidate(); }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]); // Every GSAP-script commit is a read-modify-write of one file. Overlapping