Skip to content

fix(studio): serialize per-animationId GSAP meta commits (shadow ease race)#1512

Merged
vanceingalls merged 1 commit into
mainfrom
fix-studio-ease-race
Jun 16, 2026
Merged

fix(studio): serialize per-animationId GSAP meta commits (shadow ease race)#1512
vanceingalls merged 1 commit into
mainfrom
fix-studio-ease-race

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What

Rapid GSAP ease changes fired overlapping read-modify-write POSTs for one file (updateGsapMeta has no request batching — coalesceKey only dedupes edit history). The gsap_fidelity shadow then diffed the latest op against whichever POST's scriptText resolved, which could predate that op's ease → false expected null actual "power2.out" mismatches. Server persists ease correctly; purely a client request-pairing race.

Fix: createKeyedSerializer (per-key promise chain, rejection-safe, self-cleaning) + a serializeKey commit option. update-meta commits pass serializeKey=gsap:${animationId}:meta so same-animation meta commits run in order; keyless / distinct-key commits run concurrently.

Why

Found via SDK shadow telemetry: 16 gsap_fidelity:ease false mismatches.

Tests

4 cases on the serializer (same-key ordering, distinct-key concurrency, rejection-safety).

🤖 Generated with Claude Code

Code-review fixes (folded in)

  • Broadened from per-animationId to per-file serialization at the commitMutation chokepoint, covering all op types + all animations on a file (not just one meta family) — closes the cross-animation file race too.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at HEAD 1aada07.

Shape of the fix — correct. Broadening from per-animationId to per-sourceFile at the commitMutation chokepoint is the right grain: the server route is /api/projects/{pid}/gsap-mutations/{sourceFile}, so per-file is exactly the unit that races server-side. Cross-animation writes to the same file would have raced under the per-animationId scheme; per-file closes that. useRef(createKeyedSerializer()) survives re-renders, the chain is rejection-safe + self-cleaning, and the 4 serializer unit tests cover the critical contracts (ordering, distinct-key parallelism, rejection recovery, value propagation). Order preservation is FIFO under React's microtask scheduling, which is what callers will assume.

Concerns

1. targetPath resolution is duplicated between the wrapper and runCommit — coupling bug waiting to happen. useGsapScriptCommits.ts:124 (wrapper) and the same selection.sourceFile || activeCompPath || "index.html" expression inside runCommit (resolving targetPath before mutateGsapScript) are two copies of the same fallback chain. If either drifts (e.g. someone adds a 4th fallback in one place, or normalizes paths in one), the serializer key won't match the actual POST target — silent re-introduction of the race for the drifted path. Either extract a resolveTargetPath(selection, activeCompPath) helper used by both, or compute once in the wrapper and pass targetPath to runCommit as the source of truth. Cheap to do now, expensive to debug later.

2. No failure-path observability. Per the fidelity-telemetry origin story of this PR (16 gsap_fidelity:ease mismatches surfaced the bug), a regression here is invisible until shadow telemetry catches it again. Consider a debug-level counter for queue depth per key — or at minimum a metric on queue depth ≥ N or per-key wait time, so a future regression (slow file route → growing queue → user-visible lag) is observable before the next shadow-telemetry round. Failure paths in runCommit already throw through the chain, but no counter on serialized-chain rejections either.

3. No queue timeout / wedge handling. A hung POST blocks every subsequent same-file commit until it settles or rejects. mutateGsapScript uses bare fetch with no AbortController/timeout — if the request takes 30s, the next edit to that file takes 30s+. Interactive studio surface, so this could be perceptible. Probably fine in practice (HTTP times out eventually), but worth a thought-pass: is there a fetch-level timeout upstream you're relying on, or should this serializer (or mutateGsapScript) carry one?

Nits

4. useRef(createKeyedSerializer()) re-allocates a serializer on every render (the init expression runs each render, the result is just ignored after the first). Costless functionally, but the idiomatic lazy form is const r = useRef<X|null>(null); if (!r.current) r.current = createKeyedSerializer();. nit.

5. commitMutation's return type changed from Promise<void> to Promise<T> (generic from the serializer). All current callers void it, so neutral, but means anyone newly awaiting commitMutation gets the inner return value rather than nothing. Type-level only.

Questions

6. PR-body recency claim. Body says fix is "per-file at the chokepoint" but the title still reads "per-animationId GSAP meta commits". Worth updating the title before merge so the stack history matches the actual landed shape (per-file). Small thing — Graphite stack diff readers tend to skim titles.

What I didn't verify

  • GSAP runtime / ease-curve semantics — Miga handles HF-domain coherence.
  • Whether commitMutationSafely swallowing rejection downstream affects observed failure semantics for shadow telemetry callers.
  • The other 6 PRs in the stack — only #1512 reviewed here.

— Rames D Jusso

miga-heygen
miga-heygen previously approved these changes Jun 16, 2026

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. The createKeyedSerializer is clean, correct, and well-tested.

The promise-chain design is sound: per-key serialization prevents the read-modify-write race without blocking commits to different files. Rejection safety via prior.then(task, task) ensures a failed commit doesn't wedge the chain. The Map cleanup prevents unbounded growth. The useRef placement ensures the serializer survives React re-renders.

The broadening from per-animationId to per-file serialization (default key gsap-file:${file}) is the right call — the server processes same-file writes serially anyway, so this matches the actual constraint.

Nit: the serializeKey option on CommitMutationOptions is currently unused (no caller passes it). Fine as a future extension point, but a brief comment noting it's reserved for finer/coarser granularity would be clearer than the current JSDoc.

4 well-targeted unit tests cover ordering, isolation, rejection safety, and value propagation.

Miga

@vanceingalls vanceingalls force-pushed the fix-sdk-bareid-agree branch from e612d9d to 77608ce Compare June 16, 2026 19:21
@vanceingalls vanceingalls force-pushed the fix-studio-ease-race branch 2 times, most recently from 4d9e9a8 to f33a974 Compare June 16, 2026 19:22
@vanceingalls vanceingalls force-pushed the fix-sdk-bareid-agree branch from 77608ce to 5c94fcc Compare June 16, 2026 19:22
@vanceingalls vanceingalls force-pushed the fix-studio-ease-race branch from f33a974 to ebca753 Compare June 16, 2026 19:30
@vanceingalls vanceingalls force-pushed the fix-sdk-bareid-agree branch from 5c94fcc to 8be6f33 Compare June 16, 2026 19:30
…ace)

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>
@vanceingalls vanceingalls changed the base branch from fix-sdk-bareid-agree to graphite-base/1512 June 16, 2026 19:35
@vanceingalls vanceingalls force-pushed the fix-studio-ease-race branch from ebca753 to 168bb4f Compare June 16, 2026 19:35
@vanceingalls vanceingalls changed the base branch from graphite-base/1512 to main June 16, 2026 19:35
@vanceingalls vanceingalls dismissed miga-heygen’s stale review June 16, 2026 19:35

The base branch was changed.

@vanceingalls vanceingalls merged commit 4ee57d5 into main Jun 16, 2026
30 of 36 checks passed
@vanceingalls vanceingalls deleted the fix-studio-ease-race branch June 16, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants