refactor(studio): dedup shadow numeric-equal + GSAP script extraction (#1516)

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

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>

* refactor(studio): dedup shadow numeric-equal + GSAP script extraction

Code-review cleanup (no behavior change):
- Extract the relative-epsilon float compare into shared sdkShadowNumeric.relEqual,
  used by both timing parity (sdkShadow) and GSAP value fidelity (numericEqual) —
  was duplicated verbatim, risking divergent tuning.
- Export extractGsapScript from sdkShadowGsapFidelity and import it in the keyframe
  shadow instead of the byte-identical clone (the regex + marker set must stay in
  sync with document.ts; one copy is safer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-16 12:41:25 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4ee57d5505
commit 0e5caa453a
4 changed files with 27 additions and 40 deletions
+5 -12
View File
@@ -12,6 +12,7 @@ import type { Composition } from "@hyperframes/sdk";
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { relEqual } from "./sdkShadowNumeric";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { PatchOperation } from "./sourcePatcher";
@@ -388,24 +389,16 @@ export interface ShadowTiming {
trackIndex?: number;
}
// Timing start/duration are computed arithmetically by the SDK (e.g. 21.36 -
// 0 + drag delta) but stored as a rounded literal server-side, so exact compare
// flags float-precision noise like 3.1 vs 3.0999999999999996 (~1e-16). Compare
// with a relative epsilon; a genuinely different value (3.1 vs 3.5) still flags.
// trackIndex is an integer track slot — compared exactly by the caller.
function timingValuesEqual(a: number, b: number): boolean {
if (a === b) return true;
return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
}
// start/duration tolerate float-precision drift; trackIndex (integer slot) is exact.
// start/duration tolerate float-precision drift (SDK computes them
// arithmetically, server stores a rounded literal) via the shared relative
// epsilon; trackIndex (integer track slot) is compared exactly.
function timingFieldEqual(
key: keyof ShadowTiming,
actual: number | null | undefined,
expected: number,
): boolean {
if (typeof actual === "number" && key !== "trackIndex") {
return timingValuesEqual(actual, expected);
return relEqual(actual, expected);
}
return actual === expected;
}
@@ -16,6 +16,7 @@ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { relEqual } from "./sdkShadowNumeric";
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
// Marker set must match document.ts extractGsapScript so both pick the same
@@ -24,7 +25,7 @@ function isGsapScriptBody(body: string): boolean {
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
}
function extractGsapScript(html: string): string | null {
export function extractGsapScript(html: string): string | null {
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
@@ -73,12 +74,9 @@ function animByKey(
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
// strings — so only real value drift registers, not formatting differences.
// Relative-epsilon compare: the two writers round-trip durations through JS
// number formatting, so a value like 3.1 can come back as 3.0999999999999996.
// An exact `===` flags that sub-ULP delta as drift. Treat values as equal when
// they're within 1e-6 * max(1, |a|, |b|) of each other — tight enough that a
// real 2 vs 1 (or 0.5 vs 0.49) drift still flags, loose enough to absorb
// float-formatting noise.
// Coerce string operands to numbers, then compare with the shared relative
// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
// isn't flagged as drift while a real 2 vs 1 still is.
function numericEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
const na = typeof a === "string" ? Number(a) : a;
@@ -86,9 +84,7 @@ function numericEqual(a: unknown, b: unknown): boolean {
if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
return false;
}
if (na === nb) return true;
const tolerance = 1e-6 * Math.max(1, Math.abs(na), Math.abs(nb));
return Math.abs(na - nb) <= tolerance;
return relEqual(na, nb);
}
function canonicalProps(obj: Record<string, unknown> | undefined): string {
@@ -31,7 +31,11 @@ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import type { SdkShadowMismatch } from "./sdkShadow";
import { gsapFidelityMismatches, makeSelectorResolver } from "./sdkShadowGsapFidelity";
import {
extractGsapScript,
gsapFidelityMismatches,
makeSelectorResolver,
} from "./sdkShadowGsapFidelity";
// Match the GSAP writer's percentage equality tolerance so a remove resolves to
// the same keyframe the server would pick (writer rounds to ~3 decimals).
@@ -46,23 +50,6 @@ export type ShadowKeyframeOp =
}
| { kind: "remove"; animationId: string; percentage: number };
// ─── Script helpers (mirror sdkShadowGsapFidelity's extraction) ───────────────
function isGsapScriptBody(body: string): boolean {
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
}
function extractGsapScript(html: string): string | null {
// Close tag is `</script[^>]*>` (HTML5 ignores junk before `>`).
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
if (!scripts) return null;
for (const block of scripts) {
const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
if (isGsapScriptBody(body)) return body;
}
return null;
}
// ─── percentage → SDK op mapping ──────────────────────────────────────────────
function findAnimationKeyframes(
@@ -0,0 +1,11 @@
/**
* Relative-epsilon numeric equality shared by the shadow diffs (timing parity +
* GSAP value fidelity). Both writers round-trip durations/positions through JS
* number formatting, so a value like 3.1 can read back as 3.0999999999999996.
* Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a
* real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise.
*/
export function relEqual(a: number, b: number): boolean {
if (a === b) return true;
return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
}