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
@@ -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());
});
});