From ed02a9b53601579abba622531f12203834c4609f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 18:58:41 -0400 Subject: [PATCH 1/2] fix(studio): soft-reload a batch when one of its writes has no in-place patch A corner resize saves size and position in one batch; only the position had an in-place patch, so the preview patched it and skipped the reload, and the new size snapped back until reload. --- .../src/hooks/useGsapScriptCommits.test.tsx | 42 ++++++++++++++++++- .../studio/src/hooks/useGsapScriptCommits.ts | 13 +++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index caba304cfa2..f602948f139 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -30,7 +30,11 @@ vi.mock("../utils/studioTelemetry", () => ({ (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 type { + CommitMutationCall, + CommitMutationOptions, + MutationResult, +} from "./gsapScriptCommitTypes"; import { persistSdkSerialize } from "../utils/sdkCutover"; import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits"; import { hasStudioPendingEdits } from "../utils/studioPendingEdits"; @@ -416,6 +420,16 @@ function renderCommitHook( const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection; +async function commitBatch(calls: CommitMutationCall[], options: CommitMutationOptions) { + const deps = renderCommitHook(); + const batch = deps.api.commitMutation.batch; + if (!batch) throw new Error("batch capability missing"); + await act(async () => { + await batch(calls, options); + }); + return deps; +} + function mockFetchResult(over: Partial = {}): void { const body: MutationResult = { ok: true, @@ -553,6 +567,32 @@ describe("runCommit — instantPatch wiring", () => { expect(deps.reloadPreview).not.toHaveBeenCalled(); }); + it("batch where a write without an instant patch soft-reloads instead of patching the rest", async () => { + // A corner resize saves size (no in-place patch) and position (patched) in one batch. + patchRuntimeTweenInPlace.mockReturnValue(true); + applySoftReload.mockReturnValue("applied"); + mockFetchResult({ changed: true }); + const position = { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } }; + const deps = await commitBatch( + [ + { + selection, + mutation: { type: "update-property", property: "width", value: 300 }, + options: { label: "Resize layer" }, + }, + { + selection, + mutation: { type: "update-property", property: "x", value: 10 }, + options: { label: "Resize layer", instantPatch: position }, + }, + ], + { label: "Resize layer", softReload: true }, + ); + + expect(applySoftReload).toHaveBeenCalledTimes(1); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); + it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => { // Server contract: gsap-mutations returns scriptText on EVERY response, // including changed:false — so the fallback re-runs the identical script diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 10fc5e2622d..099aed16281 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -410,13 +410,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra if (!result) return; trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options)); options.onResult?.(result); - // Each call brings its own fast-path patch; the batch wrote them all, so the - // preview sync applies them all rather than just the last call's. - const instantPatches = calls - .map(({ options: callOptions }) => callOptions.instantPatch) - .filter((patch) => patch !== undefined); + // The batch wrote every call, so patch in place only when every call brought a patch; one + // without (a resize's size write) needs the reload, or the preview keeps the old value. + const instantPatches = calls.flatMap(({ options: callOptions }) => + callOptions.instantPatch ? [callOptions.instantPatch] : [], + ); const { instantPatch: _instantPatch, ...batchOptions } = options; - await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, instantPatches.length > 0 ? { ...batchOptions, instantPatches } : batchOptions); + const allPatched = instantPatches.length === calls.length; + await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions); }, [showToast, finalizeSuccessfulMutation]); // Every GSAP-script commit is a read-modify-write of one file. Overlapping From 16f74dc55a33f0ab39e17f29d4c0a68c0cbbd3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 3 Oct 2026 05:11:47 -0400 Subject: [PATCH 2/2] fix(studio): a deferred write without an in-place patch still reloads at the group's end A group drag defers each member's preview sync and carries a missed patch to the final render. A write with no patch at all never set that latch, so a last member that patched in place skipped the reload and the earlier member kept its old position on screen. --- .../studio/src/hooks/gsapScriptCommitTypes.ts | 2 +- .../src/hooks/useGsapScriptCommits.test.tsx | 28 +++++++++++++++++++ .../studio/src/hooks/useGsapScriptCommits.ts | 3 +- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 3604dcf4aef..acbf5a9ae06 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -44,7 +44,7 @@ export interface CommitMutationOptions { * error handling — a failed write still throws. */ deferPreviewSync?: boolean; - /** Shares an in-place patch miss with the final render of one multi-write action. */ + /** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */ previewFallbackLatch?: { pending: boolean }; beforeReload?: () => void; /** diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index f602948f139..5bff506acb7 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -215,6 +215,34 @@ describe("applyPreviewSync", () => { expect(applySoftReload).toHaveBeenCalledTimes(1); }); + it("carries a deferred write with no instant patch into the final batch render", () => { + const previewFallbackLatch = { pending: false }; + applySoftReload.mockReturnValue("applied"); + patchRuntimeTweenInPlace.mockReturnValue(true); + const group = { + label: "Move animated layer (group)", + softReload: true, + previewFallbackLatch, + }; + + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT" }), + { ...group, deferPreviewSync: true }, + vi.fn(), + ); + expect(previewFallbackLatch.pending).toBe(true); + expect(applySoftReload).not.toHaveBeenCalled(); + + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT" }), + { ...group, instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } } }, + vi.fn(), + ); + expect(applySoftReload).toHaveBeenCalledTimes(1); + }); + it("falls back immediately when a deferred patch miss has no final-render latch", () => { patchRuntimeTweenInPlace.mockReturnValue(false); applySoftReload.mockReturnValue("applied"); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 099aed16281..cbe5d21dd2d 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -268,7 +268,8 @@ export function applyPreviewSync( nestedFiles?: Map | null, ): void { const patches = instantPatchesFor(options); - let needsFallback = options.previewFallbackLatch?.pending === true; + const writtenWithoutPatch = patches.length === 0; + let needsFallback = options.previewFallbackLatch?.pending === true || writtenWithoutPatch; if (patches.length > 0) { const deferSeek = options.deferPreviewSync === true; const missed = patches.find(