fix(studio): serialize GSAP script commits per file (shadow request race) (#1512)

Rapid GSAP edits (ease/duration/keyframe/property) fired overlapping
read-modify-write POSTs to one script file — coalesceKey only dedupes edit
history, not requests. The gsap_fidelity shadow then diffed an op against
whichever POST's scriptText resolved, which could predate that op → false
"expected null, actual power2.out" mismatches. Server persists correctly; a
pure client request-pairing race.

Adds createKeyedSerializer (per-key promise chain, rejection-safe, self-
cleaning). commitMutation now serializes every GSAP-script commit per target
file by default (key `gsap-file:<path>`) — covering all op types and all
animations, not just one meta family — so same-file POSTs can't interleave.
Distinct files run concurrently; an explicit serializeKey still overrides.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-16 12:36:33 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cc055f318d
commit 4ee57d5505
5 changed files with 151 additions and 3 deletions
@@ -20,6 +20,14 @@ export interface CommitMutationOptions {
softReload?: boolean;
skipReload?: boolean;
beforeReload?: () => void;
/**
* Serialize this commit against others sharing the same key. Used to chain
* per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
* one file can't interleave — which would pair the shadow fidelity diff with a
* stale server result and report false ease mismatches. Commits without a key
* (and under distinct keys) run concurrently as before.
*/
serializeKey?: string;
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
shadowGsapOp?: ShadowGsapOp;
/** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { createKeyedSerializer } from "./serializeByKey";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("createKeyedSerializer", () => {
it("runs same-key tasks strictly in order (second awaits the first)", async () => {
const run = createKeyedSerializer();
const order: string[] = [];
const first = deferred<void>();
const p1 = run("k", async () => {
order.push("1-start");
await first.promise;
order.push("1-end");
});
const p2 = run("k", async () => {
order.push("2-start");
});
// Second task must not start until the first finishes.
await Promise.resolve();
expect(order).toEqual(["1-start"]);
first.resolve();
await Promise.all([p1, p2]);
expect(order).toEqual(["1-start", "1-end", "2-start"]);
});
it("does not block tasks under different keys", async () => {
const run = createKeyedSerializer();
const order: string[] = [];
const blockA = deferred<void>();
const pa = run("a", async () => {
order.push("a-start");
await blockA.promise;
order.push("a-end");
});
const pb = run("b", async () => {
order.push("b-start");
order.push("b-end");
});
// Different key runs to completion while "a" is still blocked.
await pb;
expect(order).toEqual(["a-start", "b-start", "b-end"]);
blockA.resolve();
await pa;
expect(order).toEqual(["a-start", "b-start", "b-end", "a-end"]);
});
it("does not wedge a key when a prior task rejects", async () => {
const run = createKeyedSerializer();
const order: string[] = [];
const p1 = run("k", async () => {
order.push("1");
throw new Error("boom");
});
await expect(p1).rejects.toThrow("boom");
const p2 = run("k", async () => {
order.push("2");
});
await p2;
expect(order).toEqual(["1", "2"]);
});
it("propagates the task's resolved value to its caller", async () => {
const run = createKeyedSerializer();
await expect(run("k", async () => 42)).resolves.toBe(42);
});
});
@@ -0,0 +1,33 @@
/**
* Per-key task serializer. Tasks sharing a key run strictly in order: a new
* task for a key awaits the prior task for that key before starting, so their
* effects (e.g. overlapping read-modify-write POSTs to one file) can't
* interleave. Tasks under different keys are independent and never block each
* other.
*
* Used to serialize GSAP meta-update commits per animationId so the shadow
* fidelity diff always pairs an op with the server result that includes it —
* without globally serializing unrelated commits.
*/
export function createKeyedSerializer() {
const inFlight = new Map<string, Promise<unknown>>();
return function run<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = inFlight.get(key) ?? Promise.resolve();
// Chain onto the prior task regardless of how it settled; a rejected prior
// commit must not wedge the key forever.
const next = prior.then(task, task);
inFlight.set(key, next);
// Once this task settles, drop it from the map if nothing newer replaced it,
// so completed keys don't leak.
void next.then(
() => {
if (inFlight.get(key) === next) inFlight.delete(key);
},
() => {
if (inFlight.get(key) === next) inFlight.delete(key);
},
);
return next;
};
}
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
animationId,
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
};
// coalesceKey groups rapid meta edits into one history entry. Request
// serialization is now handled per-file at the commitMutation chokepoint
// (useGsapScriptCommits), so no per-op serializeKey is needed here.
const metaKey = `gsap:${animationId}:meta`;
commitMutationSafely(
selection,
{ type: "update-meta", animationId, updates },
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
{ label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
);
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
},
@@ -1,10 +1,11 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { applySoftReload } from "../utils/gsapSoftReload";
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
import { createKeyedSerializer } from "./serializeByKey";
import {
GsapMutationHttpError,
formatGsapMutationRejectionToast,
@@ -46,10 +47,16 @@ async function mutateGsapScript(
// oxfmt-ignore
// fallow-ignore-next-line complexity
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
// Serializer for per-key commits (options.serializeKey). Keyed by
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
// the same animationId so their POSTs can't interleave — which is what made
// the shadow fidelity diff pair an op with a stale server result and report
// false ease mismatches. Held in a ref so the chain survives re-renders.
const serializerRef = useRef(createKeyedSerializer());
// Pre-existing complexity (server mutate + history + reload branches); this PR
// adds only a guarded shadow-fidelity dispatch.
// fallow-ignore-next-line complexity
const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
const pid = projectIdRef.current;
if (!pid) return;
const unsafeFields = findUnsafeMutationValues(mutation);
@@ -105,6 +112,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
}
onCacheInvalidate();
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
// commits to the SAME file (any op type, any animation) interleave server-side
// and make the shadow fidelity diff pair an op with a stale server result —
// the false ease/value mismatches this serializer exists to prevent. So
// serialize per target file by default; an explicit serializeKey overrides.
const commitMutation = useCallback(
(selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
const file = selection.sourceFile || activeCompPath || "index.html";
const key = options.serializeKey ?? `gsap-file:${file}`;
return serializerRef.current(key, () => runCommit(selection, mutation, options));
},
[runCommit, activeCompPath],
);
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);