Files
hyperframes/packages/studio/src/hooks/serializeByKey.ts
T
Vance IngallsandClaude Opus 4.8 4ee57d5505 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>
2026-06-16 12:36:33 -07:00

34 lines
1.3 KiB
TypeScript

/**
* 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;
};
}