Files
hyperframes/packages/studio/src/hooks/serializeByKey.test.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

84 lines
2.3 KiB
TypeScript

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