Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions packages/studio/src/hooks/gsapDragCommit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>; options: Record<string, unknown> };
function optionRecordingCallbacks(): {
commits: RecordedCommit[];
callbacks: GsapDragCommitCallbacks;
} {
const commits: RecordedCommit[] = [];
return {
commits,
callbacks: {
commitMutation: async (_sel, mutation, options) => {
commits.push({ mutation, options: options as Record<string, unknown> });
},
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<string, number> };
};
const yPatch = commits[1].options.instantPatch as {
change: { props: Record<string, number> };
};
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<string, number> };
};
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 }));

Expand Down
109 changes: 89 additions & 20 deletions packages/studio/src/hooks/gsapDragCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<void>;
fetchAnimations?: () => Promise<GsapAnimation[]>;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -466,16 +530,21 @@ export async function commitStaticGsapRotation(
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
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(
Expand Down
38 changes: 38 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading