Skip to content

Commit ff2be9d

Browse files
committed
fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure
- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)
1 parent 57b49d8 commit ff2be9d

5 files changed

Lines changed: 155 additions & 52 deletions

File tree

packages/studio/src/hooks/gsapDragCommit.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ const existingRotationSet = (): GsapAnimation =>
275275
describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
276276
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
277277

278-
it("attaches instantPatch {kind:set, props:{x,y}} to the FINAL coalesced commit only", async () => {
278+
it("attaches an instantPatch to BOTH coalesced commits, each derived from its own mutation", async () => {
279279
const { commits, callbacks } = optionRecordingCallbacks();
280280

281281
await commitStaticGsapPosition(
@@ -288,9 +288,14 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
288288
);
289289

290290
expect(commits).toHaveLength(2);
291-
// First (x) commit is the intermediate skipReload one — NO instantPatch.
291+
// First (x) commit is the intermediate skipReload one — it now carries an
292+
// instantPatch for just {x}, so if the SECOND POST fails the preview still
293+
// reflects the x that DID persist (no reload, instant feedback).
292294
expect(commits[0].options.skipReload).toBe(true);
293-
expect(commits[0].options.instantPatch).toBeUndefined();
295+
expect(commits[0].options.instantPatch).toEqual({
296+
selector: "#puck-a",
297+
change: { kind: "set", props: { x: -50 } },
298+
});
294299
// Final (y) commit triggers the reload and carries the full {x,y} patch.
295300
expect(commits[1].options.softReload).toBe(true);
296301
expect(commits[1].options.instantPatch).toEqual({
@@ -299,6 +304,34 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
299304
});
300305
});
301306

307+
it("derives each instantPatch's props from the value in the SAME mutation that's POSTed", async () => {
308+
const { commits, callbacks } = optionRecordingCallbacks();
309+
310+
await commitStaticGsapPosition(
311+
selection(),
312+
{ x: -50, y: 30 },
313+
{ x: 0, y: 0 },
314+
"#puck-a",
315+
existingPositionSet(),
316+
callbacks,
317+
);
318+
319+
// The patch values must equal the mutation values — they're read out of the
320+
// same object, so a clean mutation can't ship alongside a stale patch.
321+
const xMutation = commits[0].mutation as { property: string; value: number };
322+
const yMutation = commits[1].mutation as { property: string; value: number };
323+
const xPatch = commits[0].options.instantPatch as {
324+
change: { props: Record<string, number> };
325+
};
326+
const yPatch = commits[1].options.instantPatch as {
327+
change: { props: Record<string, number> };
328+
};
329+
expect(xPatch.change.props[xMutation.property]).toBe(xMutation.value);
330+
expect(yPatch.change.props[yMutation.property]).toBe(yMutation.value);
331+
// The y commit's combined patch also carries the x mutation's value.
332+
expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
333+
});
334+
302335
it("does NOT attach instantPatch when ADDING a new set (structural — new tween)", async () => {
303336
const { commits, callbacks } = optionRecordingCallbacks();
304337

@@ -331,6 +364,12 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
331364
selector: "#puck-a",
332365
change: { kind: "set", props: { rotation: 42 } },
333366
});
367+
// Patch value derived from the SAME mutation that's POSTed (one source).
368+
const m = commits[0].mutation as { property: string; value: number };
369+
const patch = commits[0].options.instantPatch as {
370+
change: { props: Record<string, number> };
371+
};
372+
expect(patch.change.props[m.property]).toBe(m.value);
334373
});
335374

336375
it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => {

packages/studio/src/hooks/gsapDragCommit.ts

Lines changed: 82 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
1010
import { roundTo3 } from "../utils/rounding";
1111
import { computeElementPercentage } from "./gsapShared";
1212
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
13-
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
13+
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
1414
export interface GsapDragCommitCallbacks {
1515
commitMutation: (
1616
selection: DomEditSelection,
@@ -381,6 +381,37 @@ async function commitFlatViaKeyframes(
381381
// without importing the GSAP commit graph (store/runtime/core).
382382
export { computeDraggedGsapPosition };
383383

384+
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
385+
interface UpdatePropertyMutation {
386+
type: "update-property";
387+
animationId: string;
388+
property: string;
389+
value: number;
390+
}
391+
392+
/**
393+
* Build the `instantPatch` for a value-only `tl.set` from the SAME
394+
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
395+
* value the source write didn't (one source of truth). Each mutation contributes
396+
* its `{property: value}` channel to the patch's props.
397+
*/
398+
function setPatchFromUpdateProperties(
399+
selector: string,
400+
mutations: UpdatePropertyMutation[],
401+
): { selector: string; change: RuntimeTweenChange } {
402+
const props: SetPatchProps = {};
403+
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
404+
return { selector, change: { kind: "set", props } };
405+
}
406+
407+
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
408+
function setPatchFromUpdateProperty(
409+
selector: string,
410+
mutation: UpdatePropertyMutation,
411+
): { selector: string; change: RuntimeTweenChange } {
412+
return setPatchFromUpdateProperties(selector, [mutation]);
413+
}
414+
384415
/**
385416
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
386417
* with no duration. This is what a static-element nudge writes/updates.
@@ -420,24 +451,41 @@ export async function commitStaticGsapPosition(
420451
// Update in place — two single-property mutations (the API updates one prop
421452
// per call). Coalesce them and reload only after the second lands.
422453
const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
423-
await callbacks.commitMutation(
424-
selection,
425-
{ type: "update-property", animationId: existingSet.id, property: "x", value: newX },
426-
{ label: "Move layer", skipReload: true, coalesceKey },
427-
);
428-
await callbacks.commitMutation(
429-
selection,
430-
{ type: "update-property", animationId: existingSet.id, property: "y", value: newY },
431-
{
432-
label: "Move layer",
433-
softReload: true,
434-
coalesceKey,
435-
// Final commit of the coalesced x/y pair: carry the full {x,y} so the
436-
// runtime `tl.set` is patched in place instantly (skips the soft reload
437-
// when the helper can confidently apply it).
438-
instantPatch: { selector, change: { kind: "set", props: { x: newX, y: newY } } },
439-
},
440-
);
454+
// Build each mutation FIRST, then derive its instantPatch from the SAME
455+
// object that's POSTed — so a future caller can't ship a clean mutation with
456+
// a stale/malformed patch (the validated `value` flows straight into the
457+
// patch). `findUnsafeMutationValues` validates the mutation upstream.
458+
const xMutation = {
459+
type: "update-property",
460+
animationId: existingSet.id,
461+
property: "x",
462+
value: newX,
463+
} as const;
464+
const yMutation = {
465+
type: "update-property",
466+
animationId: existingSet.id,
467+
property: "y",
468+
value: newY,
469+
} as const;
470+
// Patch BOTH coalesced commits. If the SECOND POST fails server-side, the
471+
// first (x) already persisted — patching its commit too means the live
472+
// preview still reflects what DID persist. The x commit carries skipReload
473+
// (no reload), so its instantPatch gives instant feedback without a reload;
474+
// the y commit triggers the soft reload (skipped when the patch applies).
475+
await callbacks.commitMutation(selection, xMutation, {
476+
label: "Move layer",
477+
skipReload: true,
478+
coalesceKey,
479+
instantPatch: setPatchFromUpdateProperty(selector, xMutation),
480+
});
481+
await callbacks.commitMutation(selection, yMutation, {
482+
label: "Move layer",
483+
softReload: true,
484+
coalesceKey,
485+
// Final commit of the coalesced x/y pair: carry both channels so the
486+
// runtime `tl.set` lands the complete {x,y} pose in place.
487+
instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]),
488+
});
441489
return;
442490
}
443491
await callbacks.commitMutation(
@@ -482,21 +530,21 @@ export async function commitStaticGsapRotation(
482530
callbacks: GsapDragCommitCallbacks,
483531
): Promise<void> {
484532
if (existingSet) {
485-
await callbacks.commitMutation(
486-
selection,
487-
{
488-
type: "update-property",
489-
animationId: existingSet.id,
490-
property: "rotation",
491-
value: newRotation,
492-
},
493-
{
494-
label: "Rotate layer",
495-
softReload: true,
496-
// Value-only rotation set: patch the runtime `tl.set` rotation in place.
497-
instantPatch: { selector, change: { kind: "set", props: { rotation: newRotation } } },
498-
},
499-
);
533+
// Derive the instantPatch from the SAME mutation object that's POSTed (single
534+
// source of truth — see commitStaticGsapPosition), so the validated `value`
535+
// flows into the patch and the two can't drift.
536+
const rotationMutation = {
537+
type: "update-property",
538+
animationId: existingSet.id,
539+
property: "rotation",
540+
value: newRotation,
541+
} as const;
542+
await callbacks.commitMutation(selection, rotationMutation, {
543+
label: "Rotate layer",
544+
softReload: true,
545+
// Value-only rotation set: patch the runtime `tl.set` rotation in place.
546+
instantPatch: setPatchFromUpdateProperty(selector, rotationMutation),
547+
});
500548
return;
501549
}
502550
await callbacks.commitMutation(

packages/studio/src/hooks/gsapRuntimeBridge.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
8989
expect(mutation.type).not.toBe("add-keyframe");
9090
});
9191

92-
it("forwards instantPatch {kind:set,x,y} on the final commit when updating an existing static set", async () => {
92+
it("forwards instantPatch on BOTH coalesced commits when updating an existing static set", async () => {
9393
const commitMutation = vi.fn();
9494
const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
9595
// An existing position-hold `set` for the selector → update-in-place (not add).
@@ -112,11 +112,15 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
112112
);
113113

114114
expect(handled).toBe(true);
115-
// The coalesced update-property pair: the x commit is skipReload (no patch),
116-
// the final y commit triggers the reload and carries the full {x,y} patch.
115+
// The coalesced update-property pair both carry an instantPatch so a partial
116+
// (second-POST) failure still leaves the preview patched for what persisted:
117+
// the x commit patches {x}, the final y commit patches the full {x,y}.
117118
const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-property");
118119
expect(updates).toHaveLength(2);
119-
expect(updates[0][2].instantPatch).toBeUndefined();
120+
expect(updates[0][2].instantPatch).toEqual({
121+
selector: "#puck-b",
122+
change: { kind: "set", props: { x: -50 } },
123+
});
120124
expect(updates[1][2].instantPatch).toEqual({
121125
selector: "#puck-b",
122126
change: { kind: "set", props: { x: -50, y: 30 } },

packages/studio/src/hooks/useGsapScriptCommits.test.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe("applyPreviewSync", () => {
6363
expect(reloadPreview).not.toHaveBeenCalled();
6464
});
6565

66-
it("instantPatch + patch fails: falls back to the soft reload", () => {
66+
it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => {
6767
patchRuntimeTweenInPlace.mockReturnValue(false);
6868
applySoftReload.mockReturnValue(true);
6969
const reloadPreview = vi.fn();
@@ -79,11 +79,13 @@ describe("applyPreviewSync", () => {
7979
reloadPreview,
8080
);
8181

82-
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT");
82+
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
83+
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
84+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
8385
expect(reloadPreview).not.toHaveBeenCalled();
8486
});
8587

86-
it("instantPatch + patch fails + soft reload fails: escalates to full reload", () => {
88+
it("instantPatch + patch fails + soft reload returns false: does NOT sync-escalate (U4)", () => {
8789
patchRuntimeTweenInPlace.mockReturnValue(false);
8890
applySoftReload.mockReturnValue(false);
8991
const reloadPreview = vi.fn();
@@ -99,10 +101,13 @@ describe("applyPreviewSync", () => {
99101
reloadPreview,
100102
);
101103

102-
expect(reloadPreview).toHaveBeenCalledTimes(1);
104+
// U4: the synchronous false return means the soft reload couldn't run, NOT
105+
// that the preview is broken — escalation happens only via onAsyncFailure.
106+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
107+
expect(reloadPreview).not.toHaveBeenCalled();
103108
});
104109

105-
it("no instantPatch + softReload + scriptText: soft reloads (today's behavior)", () => {
110+
it("no instantPatch + softReload + scriptText: soft reloads, passing onAsyncFailure", () => {
106111
applySoftReload.mockReturnValue(true);
107112
const reloadPreview = vi.fn();
108113

@@ -114,11 +119,11 @@ describe("applyPreviewSync", () => {
114119
);
115120

116121
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
117-
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT");
122+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
118123
expect(reloadPreview).not.toHaveBeenCalled();
119124
});
120125

121-
it("no instantPatch + softReload that fails: full reload (today's behavior)", () => {
126+
it("no instantPatch + softReload that returns false: does NOT sync-escalate (U4)", () => {
122127
applySoftReload.mockReturnValue(false);
123128
const reloadPreview = vi.fn();
124129

@@ -129,7 +134,9 @@ describe("applyPreviewSync", () => {
129134
reloadPreview,
130135
);
131136

132-
expect(reloadPreview).toHaveBeenCalledTimes(1);
137+
// onAsyncFailure is wired, but the sync false return does not trigger it.
138+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
139+
expect(reloadPreview).not.toHaveBeenCalled();
133140
});
134141

135142
it("no instantPatch + no softReload: full reload (today's behavior)", () => {
@@ -264,7 +271,7 @@ describe("runCommit — instantPatch wiring", () => {
264271
});
265272

266273
expect(fetch).toHaveBeenCalledTimes(1);
267-
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT");
274+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
268275
expect(deps.reloadPreview).not.toHaveBeenCalled();
269276
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
270277
});
@@ -279,7 +286,7 @@ describe("runCommit — instantPatch wiring", () => {
279286
});
280287

281288
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
282-
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT");
289+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
283290
expect(deps.reloadPreview).not.toHaveBeenCalled();
284291
});
285292
});

packages/studio/src/hooks/useGsapScriptCommits.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ export function applyPreviewSync(
6868
// Fall through to the soft/full reload path below.
6969
}
7070
if (options.softReload && result.scriptText) {
71-
if (!applySoftReload(iframe, result.scriptText)) reloadPreview();
71+
// Per U4, do NOT escalate on the synchronous `false` return (it means
72+
// "soft-reload couldn't run; the value is unchanged on screen, not broken"
73+
// — a full reload would re-flash the WebGL context for nothing). Only the
74+
// async MotionPath-plugin load failure escalates, via `onAsyncFailure`,
75+
// which fires after a soft reload that already returned true optimistically.
76+
applySoftReload(iframe, result.scriptText, reloadPreview);
7277
} else {
7378
reloadPreview();
7479
}

0 commit comments

Comments
 (0)