fix(studio): make inspector commits transactional (#2987)

* fix(studio): make inspector commits transactional

* fix(studio): make inspector persistence atomic

* fix(studio): preserve synchronous gesture semantics
This commit is contained in:
Miguel Ángel
2026-08-04 20:26:43 +00:00
committed by GitHub
parent cde5bae4c5
commit 552419c52d
14 changed files with 634 additions and 90 deletions
@@ -253,7 +253,7 @@ function Transform3dField({
onCommit={(next) => {
const v = parse(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(ctx.element, prop, v);
return onCommitAnimatedProperty(ctx.element, prop, v);
}
}}
/>
@@ -21,7 +21,7 @@ export function CommitField({
liveCommit?: boolean;
align?: "left" | "right";
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
}) {
const [draft, setDraft] = useState(value);
const valueRef = useRef(value);
@@ -29,6 +29,19 @@ export function CommitField({
const inputRef = useRef<HTMLInputElement>(null);
const focusedRef = useRef(false);
const dirtyRef = useRef(false);
const commitGenerationRef = useRef(0);
const pendingCommitRef = useRef<{
baseline: string;
optimistic: string;
} | null>(null);
const lastValueRef = useRef(value);
if (!Object.is(lastValueRef.current, value)) {
lastValueRef.current = value;
if (!Object.is(pendingCommitRef.current?.optimistic, value)) {
commitGenerationRef.current += 1;
pendingCommitRef.current = null;
}
}
valueRef.current = value;
draftRef.current = draft;
@@ -67,14 +80,34 @@ export function CommitField({
}, 250);
};
const cancelGesture = () => {
commitGenerationRef.current += 1;
clearGestureSettleTimer();
gestureActiveRef.current = false;
gestureTransaction.cancel();
};
const commitDraft = (nextValue: string) => {
const generation = ++commitGenerationRef.current;
setDraft(nextValue);
onPreview?.(nextValue);
if (nextValue !== valueRef.current) onCommit(nextValue);
if (nextValue !== valueRef.current) {
const baseline = valueRef.current;
pendingCommitRef.current = { baseline, optimistic: nextValue };
const rollback = () => {
if (generation !== commitGenerationRef.current) return;
pendingCommitRef.current = null;
// The source write is authoritative. A rejected mutation must not leave
// the field showing an optimistic value that will disappear on seek.
setDraft(baseline);
onPreview?.(baseline);
};
try {
void Promise.resolve(onCommit(nextValue)).then(() => {
if (generation === commitGenerationRef.current) pendingCommitRef.current = null;
}, rollback);
} catch {
rollback();
}
}
};
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!gestureActiveRef.current) return false;
@@ -89,6 +122,7 @@ export function CommitField({
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
if (!nextDraft) return;
event.preventDefault();
commitGenerationRef.current += 1;
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransaction.preview(nextDraft);
@@ -148,6 +182,7 @@ export function CommitField({
focusedRef.current = true;
}}
onChange={(event) => {
commitGenerationRef.current += 1;
settleGesture();
dirtyRef.current = true;
setDraft(event.target.value);
@@ -90,6 +90,75 @@ describe("FlatRow", () => {
act(() => root.unmount());
});
it("restores its durable value when an async commit rejects", async () => {
let rejectCommit: ((error: Error) => void) | null = null;
const onCommit = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCommit = reject;
}),
);
const row = (value: string) => (
<FlatRow label="X" value={value} tier="explicitDefault" onCommit={onCommit} />
);
const { host, root } = renderInto(row("22px"));
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
act(() => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(input, "99px");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("focusout", { bubbles: true }));
});
// The parent can echo the preview before persistence settles. That is not a
// durable acknowledgement and must not invalidate the pending rollback.
act(() => root.render(row("99px")));
await act(async () => {
rejectCommit?.(new Error("save failed"));
await Promise.resolve();
});
expect(onCommit).toHaveBeenCalledWith("99px");
expect(input.value).toBe("22px");
act(() => root.unmount());
});
it("does not let an older rejected commit overwrite a newer draft", async () => {
let rejectCommit: ((error: Error) => void) | null = null;
const onCommit = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCommit = reject;
}),
);
const { host, root } = renderInto(
<FlatRow label="X" value="22px" tier="explicitDefault" onCommit={onCommit} />,
);
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
act(() => {
nativeInputValueSetter?.call(input, "99px");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("focusout", { bubbles: true }));
nativeInputValueSetter?.call(input, "100px");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
rejectCommit?.(new Error("old save failed"));
await Promise.resolve();
});
expect(input.value).toBe("100px");
act(() => root.unmount());
});
it("persists a rapid numeric arrow-key burst as one commit", () => {
vi.useFakeTimers();
const onCommit = vi.fn();
@@ -35,7 +35,7 @@ export function FlatRow({
/** Renders a trailing 10px caret-down, for select-backed rows. */
dropdown?: boolean;
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
onReset?: () => void;
}) {
const track = useTrackDesignInput();
@@ -59,7 +59,7 @@ export function FlatRow({
onPreview={onPreview}
onCommit={(nextValue) => {
track("metric", label);
onCommit(nextValue);
return onCommit(nextValue);
}}
/>
</span>
@@ -29,14 +29,14 @@ export function MetricField({
scrub?: boolean;
suffix?: string;
tooltip?: string;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
}) {
const track = useTrackDesignInput();
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
const commit = useCallback(
(nextValue: string) => {
if (nextValue !== value) track("metric", label);
onCommit(nextValue);
return onCommit(nextValue);
},
[label, onCommit, track, value],
);
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
describe("createTransformCommitHandlers", () => {
it.each([
[
"position",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualOffset("x", "20px"),
],
[
"size",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualSize("width", "200px"),
],
[
"rotation",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualRotation("45"),
],
])("propagates blocked %s edits so the field can roll back", async (_name, commit) => {
const blocked = new GsapEditBlockedError("unroll-required");
const onCommitAnimatedProperty = vi.fn().mockRejectedValue(blocked);
const onSetManualOffset = vi.fn();
const onSetManualSize = vi.fn();
const onSetManualRotation = vi.fn();
const element = {
id: "box",
selector: "#box",
element: document.createElement("div"),
boundingBox: { width: 100, height: 100 },
} as unknown as DomEditSelection;
const handlers = createTransformCommitHandlers({
element,
styles: {},
hasGsapAnimation: true,
gsapAnimId: "#box-to-position",
gsapKeyframes: null,
currentPct: 0,
onCommitAnimatedProperty,
onAddKeyframe: undefined,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
showToast: vi.fn(),
});
await expect(commit(handlers)).rejects.toBe(blocked);
expect(onSetManualOffset).not.toHaveBeenCalled();
expect(onSetManualSize).not.toHaveBeenCalled();
expect(onSetManualRotation).not.toHaveBeenCalled();
});
});
@@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
// Route a transform value into the GSAP animation (or a new keyframe) when the
// element is animated. Returns true when handled, so callers fall through to
// the manual-transform path only for non-animated elements.
const commitAnimatedTransformValue = (
const commitAnimatedTransformValue = async (
property: string,
value: number,
noCallbacksMessage: string,
): boolean => {
): Promise<boolean> => {
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, property, value);
await onCommitAnimatedProperty(element, property, value);
return true;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
@@ -62,11 +62,11 @@ export function createTransformCommitHandlers({
return false;
};
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (
commitAnimatedTransformValue(
await commitAnimatedTransformValue(
axis,
parsed,
"Cannot edit position — animation callbacks not available",
@@ -74,20 +74,20 @@ export function createTransformCommitHandlers({
)
return;
const current = readStudioPathOffset(element.element);
void Promise.resolve(
await Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
).catch(() => undefined);
);
};
// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
await onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (hasGsapAnimation) {
@@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
void Promise.resolve(
await Promise.resolve(
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
}),
).catch(() => undefined);
);
};
const commitManualRotation = (nextValue: string) => {
const commitManualRotation = async (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
if (
commitAnimatedTransformValue(
await commitAnimatedTransformValue(
"rotation",
parsed,
"Cannot edit rotation — animation callbacks not available",
)
)
return;
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
};
return { commitManualOffset, commitManualSize, commitManualRotation };
@@ -71,9 +71,15 @@ export interface PropertyPanelProps {
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => Promise<BackgroundRemovalResult>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetManualOffset: (
element: DomEditSelection,
next: { x: number; y: number },
) => void | Promise<void>;
onSetManualSize: (
element: DomEditSelection,
next: { width: number; height: number },
) => void | Promise<void>;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -8,6 +8,30 @@ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useInspectorGestureTransaction", () => {
it("restores the durable baseline when an inspector commit rejects", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
const onCommit = vi.fn().mockRejectedValue(new Error("save failed"));
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe() {
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe />));
act(() => {
gesture?.preview(25);
gesture?.settle();
});
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25]);
await act(async () => Promise.resolve());
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25, 10]);
act(() => root.unmount());
});
it("keeps a new gesture active when the prior async commit is acknowledged", () => {
const host = document.createElement("div");
const root = createRoot(host);
@@ -34,4 +58,45 @@ describe("useInspectorGestureTransaction", () => {
act(() => root.unmount());
});
it("does not let an older rejected commit roll back a newer successful gesture", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
let rejectFirst: ((error: Error) => void) | null = null;
const onCommit = vi
.fn()
.mockImplementationOnce((value: number) => {
onPreview(value);
return new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
});
})
.mockImplementationOnce((value: number) => {
onPreview(value);
return Promise.resolve();
});
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe() {
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe />));
act(() => {
gesture?.preview(20);
gesture?.settle();
gesture?.preview(30);
gesture?.settle();
});
await act(async () => {
rejectFirst?.(new Error("old save failed"));
await Promise.resolve();
});
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
expect(onPreview).toHaveBeenLastCalledWith(30);
act(() => root.unmount());
});
});
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
return Boolean(result && typeof result.then === "function");
}
/** One owner for continuous inspector edits: preview freely, persist once. */
export function useInspectorGestureTransaction<T>({
sourceValue,
@@ -8,44 +12,96 @@ export function useInspectorGestureTransaction<T>({
}: {
sourceValue: T;
onPreview: (value: T) => void;
onCommit: (value: T) => void;
onCommit: (value: T) => void | Promise<void>;
}) {
const sourceRef = useRef(sourceValue);
const activeRef = useRef<{ before: T; latest: T } | null>(null);
const previewRef = useRef(onPreview);
const commitRef = useRef(onCommit);
if (!activeRef.current) sourceRef.current = sourceValue;
const generationRef = useRef(0);
const pendingRef = useRef<{ before: T; latest: T } | null>(null);
const awaitingSourceAckRef = useRef<{ generation: number; value: T } | null>(null);
const lastSourceValueRef = useRef(sourceValue);
if (!Object.is(lastSourceValueRef.current, sourceValue)) {
lastSourceValueRef.current = sourceValue;
const matchesSourceAck = Object.is(awaitingSourceAckRef.current?.value, sourceValue);
const matchesOptimisticValue =
(activeRef.current && Object.is(activeRef.current.latest, sourceValue)) ||
(pendingRef.current && Object.is(pendingRef.current.latest, sourceValue)) ||
matchesSourceAck;
if (matchesSourceAck) awaitingSourceAckRef.current = null;
if (!matchesOptimisticValue) {
generationRef.current += 1;
activeRef.current = null;
pendingRef.current = null;
awaitingSourceAckRef.current = null;
sourceRef.current = sourceValue;
} else {
sourceRef.current = sourceValue;
}
}
previewRef.current = onPreview;
commitRef.current = onCommit;
const begin = useCallback(() => {
if (!activeRef.current) {
generationRef.current += 1;
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
}
}, []);
const preview = useCallback((value: T) => {
if (!activeRef.current) {
generationRef.current += 1;
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
}
activeRef.current.latest = value;
previewRef.current(value);
}, []);
const rollbackCommit = useCallback((active: { before: T; latest: T }, generation: number) => {
if (generation !== generationRef.current) return;
pendingRef.current = null;
if (awaitingSourceAckRef.current?.generation === generation) {
awaitingSourceAckRef.current = null;
}
sourceRef.current = active.before;
previewRef.current(active.before);
}, []);
const settle = useCallback(() => {
const active = activeRef.current;
activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) {
const generation = ++generationRef.current;
sourceRef.current = active.latest;
// Restore the captured baseline before the persistent commit captures
// rollback state. The commit reapplies `latest` synchronously, so this
// is not visible but a failed save can now correctly restore `before`.
previewRef.current(active.before);
commitRef.current(active.latest);
pendingRef.current = active;
awaitingSourceAckRef.current = { generation, value: active.latest };
try {
const result = commitRef.current(active.latest);
if (isPromiseCommit(result)) {
void result.then(
() => {
if (generation === generationRef.current) pendingRef.current = null;
},
() => rollbackCommit(active, generation),
);
} else if (generation === generationRef.current) {
pendingRef.current = null;
// Synchronous inspector consumers historically restore their preview
// after persisting (color pickers close, curves release the pointer).
// Async source mutations keep the optimistic preview until the write
// resolves so they do not flash back to the baseline while pending.
previewRef.current(active.before);
}
} catch {
rollbackCommit(active, generation);
}
}
}, []);
}, [rollbackCommit]);
const cancel = useCallback(() => {
generationRef.current += 1;
const active = activeRef.current;
activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) {
@@ -66,7 +122,7 @@ export function useInspectorGestureDraft<T>({
}: {
sourceValue: T;
onPreview: (value: T) => void;
onCommit: (value: T) => void;
onCommit: (value: T) => void | Promise<void>;
}) {
const [draft, setDraft] = useState(sourceValue);
const transaction = useInspectorGestureTransaction({
@@ -77,7 +133,7 @@ export function useInspectorGestureDraft<T>({
},
onCommit: (next) => {
setDraft(next);
onCommit(next);
return onCommit(next);
},
});
@@ -43,17 +43,43 @@ function renderHookWith(
animations: GsapAnimation[],
onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
onReady: (commit: Commit) => void,
bumpGsapCache = vi.fn(),
onBatch?: (
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>,
label: string,
) => unknown | Promise<unknown>,
) {
function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation: async (_sel, mutation, options) => {
const gsapCommitMutation = Object.assign(
async (
_sel: DomEditSelection,
mutation: Record<string, unknown>,
options: { label: string },
) => {
await onMutation(mutation, options.label);
},
onBatch
? {
batch: async (
calls: Array<{
selection: DomEditSelection;
mutation: Record<string, unknown>;
options: { label: string };
}>,
options: { label: string },
) => {
await onBatch(calls, options.label);
},
}
: {},
);
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation,
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
previewIframeRef: { current: null },
bumpGsapCache: vi.fn(),
bumpGsapCache,
});
onReady(commitAnimatedProperties);
return null;
@@ -87,8 +113,53 @@ describe("useAnimatedPropertyCommit — ownership and rejection propagation", ()
act(() => root.unmount());
});
it("rejects runtime-computed property ownership before sending a mutation", async () => {
const runtimePosition = {
...keyframedAnim,
hasUnresolvedKeyframes: true,
} as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[runtimePosition],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50 })).rejects.toMatchObject({
reason: "source-uneditable",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rejects every property before a mixed-group commit can partially persist", async () => {
const helperOpacity = {
id: "#box-to-visual",
targetSelector: "#box",
propertyGroup: "visual",
method: "to",
properties: { opacity: 0.5 },
provenance: { kind: "helper", fn: "fade", callSite: 1 },
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[helperOpacity],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({
reason: "unroll-required",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rethrows a persistence failure to the telemetry wrapper", async () => {
const failure = new Error("save failed");
const bumpGsapCache = vi.fn();
let commit!: Commit;
const root = renderHookWith(
[keyframedAnim],
@@ -96,9 +167,11 @@ describe("useAnimatedPropertyCommit — ownership and rejection propagation", ()
throw failure;
},
(ready) => (commit = ready),
bumpGsapCache,
);
await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
expect(bumpGsapCache).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
@@ -107,7 +180,13 @@ function renderCommitHook(
mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void,
) {
return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
return renderHookWith(
[keyframedAnim],
(mutation) => {
mutations.push(mutation);
},
onReady,
);
}
// Regression (#1808): a "3D transform" / design-panel property edit on an
@@ -160,7 +239,9 @@ describe("commitStaticSet group routing", () => {
) {
return renderHookWith(
[positionSet],
(mutation, label) => committed.push({ mutation, label }),
(mutation, label) => {
committed.push({ mutation, label });
},
onReady,
);
}
@@ -208,7 +289,9 @@ describe("commitStaticSet group routing", () => {
let commit!: Commit;
renderHookWith(
[positionSet, instantSizeHold],
(mutation, label) => committed.push({ mutation, label }),
(mutation, label) => {
committed.push({ mutation, label });
},
(c) => (commit = c),
);
@@ -226,4 +309,56 @@ describe("commitStaticSet group routing", () => {
expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
});
it("persists multiple property groups in one atomic batch", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
const batches: Array<{
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>;
label: string;
}> = [];
let commit!: Commit;
const root = renderHookWith(
[positionSet],
(mutation, label) => committed.push({ mutation, label }),
(ready) => (commit = ready),
vi.fn(),
(calls, label) => batches.push({ calls, label }),
);
await act(async () => {
await commit(selection, { x: 400, width: 500 });
});
expect(committed).toHaveLength(0);
expect(batches).toHaveLength(1);
expect(batches[0]!.label).toBe("Set properties");
expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([
{
type: "update-properties",
animationId: positionSet.id,
properties: { x: 400 },
},
{
type: "add",
targetSelector: "#box",
method: "set",
position: 0,
properties: { width: 500 },
global: true,
},
]);
act(() => root.unmount());
});
it("fails before sending anything when an atomic multi-group batch is unavailable", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
let commit!: Commit;
const root = renderStaticHook(committed, (ready) => (commit = ready));
await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow(
"Atomic GSAP property batch is unavailable",
);
expect(committed).toHaveLength(0);
act(() => root.unmount());
});
});
@@ -24,22 +24,16 @@ import {
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { assertGsapEditPersisted, directEditOutcomeForProperties } from "./gsapEditOutcome";
import {
assertGsapEditPersisted,
directEditOutcomeForProperties,
GsapEditBlockedError,
} from "./gsapEditOutcome";
import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
interface CommitAnimatedPropertyDeps {
selectedGsapAnimations: GsapAnimation[];
gsapCommitMutation:
| ((
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
},
) => Promise<void>)
| null;
gsapCommitMutation: CommitMutation | null;
addGsapAnimation: (
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
@@ -110,7 +104,7 @@ async function maybeAutoKeyframeSet(
);
}
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
type Commit = CommitMutation;
/** Undo-history label for a static-set commit, from the group it writes. */
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
@@ -140,6 +134,17 @@ async function commitSetProps(
animations: GsapAnimation[],
commit: Commit,
): Promise<void> {
const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
await commit(call.selection, call.mutation, call.options);
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
}
function buildSetPropsCall(
selection: DomEditSelection,
setAnim: GsapAnimation,
propEntries: [string, number | string][],
selector: string | null,
): CommitMutationCall {
const properties = Object.fromEntries(propEntries);
const numericProps: SetPatchProps = {};
for (const [k, v] of propEntries) {
@@ -155,16 +160,15 @@ async function commitSetProps(
},
}
: undefined;
await commit(
return {
selection,
{ type: "update-properties", animationId: setAnim.id, properties },
{
mutation: { type: "update-properties", animationId: setAnim.id, properties },
options: {
label: staticSetLabel(propEntries),
softReload: true,
...(instantPatch ? { instantPatch } : {}),
},
);
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
};
}
/**
@@ -180,7 +184,25 @@ async function commitStaticSet(
animations: GsapAnimation[],
commit: Commit,
): Promise<void> {
if (!selector) return;
const calls = planStaticSetCalls(selection, propEntries, selector, animations);
const only = calls[0];
if (!only) return;
if (calls.length === 1) {
await commit(only.selection, only.mutation, only.options);
return;
}
if (!commit.batch) {
throw new Error("Atomic GSAP property batch is unavailable");
}
await commit.batch(calls, {
label: staticSetLabel(propEntries),
softReload: true,
});
}
function groupStaticSetEntries(
propEntries: [string, number | string][],
): Map<string, [string, number | string][]> {
// One commit per PROPERTY GROUP, each into a static write that owns that group —
// never a live tween, and never a foreign-group write (a width edit used to
// merge into the element's position set, producing a mixed write the split
@@ -194,9 +216,22 @@ async function commitStaticSet(
batch.push(entry);
byGroup.set(group, batch);
}
const staticWrites = animations.filter(
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
);
return byGroup;
}
function planStaticSetCalls(
selection: DomEditSelection,
propEntries: [string, number | string][],
selector: string | null,
animations: GsapAnimation[],
): CommitMutationCall[] {
const byGroup = groupStaticSetEntries(propEntries);
const staticWrites = selector
? animations.filter(
(a) =>
isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
)
: [];
// Resolve every group's target BEFORE committing anything, and coalesce
// groups that land on the SAME write into one commit: the snapshot is captured
// once, so if two groups resolved to one legacy mixed write, a first
@@ -212,13 +247,12 @@ async function commitStaticSet(
newSetBatches.push(batch);
}
}
for (const [targetWrite, batch] of byTargetWrite) {
await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
}
// Fresh adds don't reshape existing sets, so their ids can't go stale.
for (const batch of newSetBatches) {
await addGlobalStaticSet(selection, batch, commit);
}
return [
...[...byTargetWrite].map(([targetWrite, batch]) =>
buildSetPropsCall(selection, targetWrite, batch, selector),
),
...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
];
}
/**
@@ -244,11 +278,10 @@ function findGroupOwningStaticWrite(
* the timeline (matches the manual-drag UX). The global-set instant patch applies
* it straight to the element so the first edit shows with no soft-reload flash.
*/
async function addGlobalStaticSet(
function buildGlobalStaticSetCall(
selection: DomEditSelection,
batch: [string, number | string][],
commit: Commit,
): Promise<void> {
): CommitMutationCall {
const numericProps: SetPatchProps = {};
for (const [k, v] of batch) {
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
@@ -257,10 +290,10 @@ async function addGlobalStaticSet(
// selector is the bare class an id-less element yields, which would hold every
// sibling. No one-element form means no write at all (see writeTargetSelector).
const target = writeTargetSelector(selection);
if (!target) return;
await commit(
if (!target) throw new GsapEditBlockedError("no-selector");
return {
selection,
{
mutation: {
type: "add",
targetSelector: target,
method: "set",
@@ -268,7 +301,7 @@ async function addGlobalStaticSet(
properties: Object.fromEntries(batch),
global: true,
},
{
options: {
label: staticSetLabel(batch),
softReload: true,
...(Object.keys(numericProps).length > 0
@@ -280,7 +313,7 @@ async function addGlobalStaticSet(
}
: {}),
},
);
};
}
/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
@@ -418,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
selector,
primaryProp,
);
if (!anim && !writeTargetSelector(selection)) {
throw new GsapEditBlockedError("no-selector");
}
// Whether the element is animated at all. A 3D edit only creates/edits
// keyframes when it IS — a static element (no keyframes on any of its tweens)
// gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
@@ -472,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return;
}
// Existing static hold on a NON-animated element — merge the props into the
// same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
if (anim && isInstantHold(anim)) {
await commitSetProps(
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
// keyframes (incl. the no-animation case, which creates a fresh set).
// Route the complete property set through the group-aware planner even
// when pickBestAnimation found one existing set: a mixed X+width edit
// must update the position set AND create a size set atomically rather
// than contaminating the first set with a foreign property group.
if (!elementHasKeyframes) {
await commitStaticSet(
selection,
anim,
propEntries,
selector,
selectedGsapAnimations,
@@ -486,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return;
}
// Static element (no keyframes anywhere)persist as a `tl.set`, never
// keyframes (incl. the no-animation case, which creates a fresh set).
if (!elementHasKeyframes) {
await commitStaticSet(
// Existing static hold on an otherwise animated elementmerge the props
// into the same write, then auto-keyframe it against the sibling tween.
if (anim && isInstantHold(anim)) {
await commitSetProps(
selection,
anim,
propEntries,
selector,
selectedGsapAnimations,
@@ -509,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
// one-element form the edit is dropped rather than written onto every
// class sibling (see writeTargetSelector).
const newTweenTarget = writeTargetSelector(selection);
if (selector && newTweenTarget) {
if (newTweenTarget) {
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -539,7 +579,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
);
return;
}
bumpGsapCache();
throw new GsapEditBlockedError("no-selector");
} catch (error) {
bumpGsapCache();
throw error;
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
applyStudioBoxSize,
applyStudioPathOffset,
applyStudioRotation,
readStudioBoxSize,
readStudioPathOffset,
readStudioRotation,
} from "../components/editor/manualEdits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useDomGeometryCommits rollback", () => {
it("restores every optimistic geometry mutation when persistence rejects", async () => {
const element = document.createElement("div");
element.id = "box";
document.body.append(element);
applyStudioPathOffset(element, { x: 10, y: 20 });
applyStudioBoxSize(element, { width: 100, height: 80 });
applyStudioRotation(element, { angle: 15 });
const selection = {
id: "box",
selector: "#box",
element,
} as unknown as DomEditSelection;
const failure = new Error("save failed");
const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
const host = document.createElement("div");
const root = createRoot(host);
function Probe() {
commits = useDomGeometryCommits({
previewIframeRef: { current: null },
showToast: vi.fn(),
commitPositionPatchToHtml,
});
return null;
}
act(() => root.render(<Probe />));
await expect(commits!.handleDomPathOffsetCommit(selection, { x: 50, y: 60 })).rejects.toBe(
failure,
);
await expect(
commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
).rejects.toBe(failure);
await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
expect(readStudioRotation(element)).toEqual({ angle: 15 });
act(() => root.unmount());
});
});
@@ -4,6 +4,12 @@ import {
applyStudioPathOffset,
applyStudioBoxSize,
applyStudioRotation,
captureStudioPathOffset,
captureStudioBoxSize,
captureStudioRotation,
restoreStudioPathOffset,
restoreStudioBoxSize,
restoreStudioRotation,
clearStudioPathOffset,
clearStudioBoxSize,
clearStudioRotation,
@@ -51,10 +57,14 @@ export function useDomGeometryCommits({
showToast(error.message, "error");
return Promise.reject(error);
}
const before = captureStudioPathOffset(selection.element);
applyStudioPathOffset(selection.element, next);
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioPathOffset(selection.element, before);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -71,6 +81,8 @@ export function useDomGeometryCommits({
showToast(error.message, "error");
return Promise.reject(error);
}
const beforeSize = captureStudioBoxSize(selection.element);
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
applyStudioBoxSize(selection.element, next);
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a
@@ -86,6 +98,10 @@ export function useDomGeometryCommits({
return commitPositionPatchToHtml(selection, patches, {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioBoxSize(selection.element, beforeSize);
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -98,10 +114,14 @@ export function useDomGeometryCommits({
showToast(error.message, "error");
return Promise.reject(error);
}
const before = captureStudioRotation(selection.element);
applyStudioRotation(selection.element, next);
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioRotation(selection.element, before);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],