Files
hyperframes/packages/studio/src/utils/sdkShadow.ts
T
Vance IngallsandClaude Opus 4.8 0e5caa453a 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>
2026-06-16 12:41:25 -07:00

518 lines
18 KiB
TypeScript

/**
* SDK shadow dispatch utilities for Stage 7 Step 3b.
*
* Shadow mode keeps the server patch path authoritative while also dispatching
* the equivalent op to the SDK session, then compares the result to detect
* addressing gaps (blocker E: no-hf-id elements) and serialization drift
* (blocker B: linkedom whole-doc serialize). Results are reported as structured
* mismatches for telemetry — no user-visible change.
*/
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";
// ─── Op mapping ──────────────────────────────────────────────────────────────
/**
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
*
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
// (e.g. "name"); prefix only when needed, never double-prefix.
function attrName(property: string): string {
return property.startsWith("data-") ? property : `data-${property}`;
}
// The SDK element model excludes data-hf-* attributes (document.ts skips them),
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
// never match — drop those ops from the shadow instead of false-mismatching.
function isShadowableOp(op: PatchOperation): boolean {
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
return true;
}
// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
// runShadowDispatch to flag any unmapped type as visible telemetry rather than
// silently dropping it (see the unmapped_type guard there).
const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
"inline-style",
"text-content",
"attribute",
"html-attribute",
]);
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;
for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: attrName(op.property),
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
}
// unknown op types produce no SDK op
}
if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}
return result;
}
// ─── Shadow result types ──────────────────────────────────────────────────────
export interface SdkShadowMismatch {
kind: "element_not_found" | "value_mismatch" | "dispatch_error";
hfId: string;
property?: string;
expected?: string | null;
actual?: string | null | undefined;
error?: string;
}
export interface SdkShadowResult {
/** False if the element was not found in the SDK session. */
dispatched: boolean;
mismatches: SdkShadowMismatch[];
}
// ─── Shadow dispatch ──────────────────────────────────────────────────────────
type ElementSnapshot = ReturnType<Composition["getElement"]>;
type OpFields = {
property: string;
expected: string | null | undefined;
actual: string | null | undefined;
};
type FlatSnapshot = {
styles: Record<string, string | null>;
attrs: Record<string, string | null>;
text: string | null;
};
function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
return {
styles: snap?.inlineStyles ?? {},
attrs: Object.fromEntries(
Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]),
),
text: snap?.text ?? null,
};
}
type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
// style properties are kebab-case ("background-color"). Convert for read-back, else
// every hyphenated property false-mismatches against a null actual.
function kebabToCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
// Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
// An empty string and absent text (null) are treated as equivalent (collapsed
// to null) so "" vs null does not flag — both mean "no text content".
function normalizeText(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
"inline-style": (op, flat) => ({
property: op.property,
expected: op.value,
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
}),
// snapshot.text is already TRIMMED; trim the expected op value to match, so
// trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
// collapsed in checkOpParity. A genuinely different text value still flags.
"text-content": (op, flat) => ({
property: "text",
expected: normalizeText(op.value),
actual: normalizeText(flat.text),
}),
attribute: (op, flat) => ({
property: attrName(op.property),
expected: op.value ?? null,
actual: flat.attrs[attrName(op.property)] ?? null,
}),
"html-attribute": (op, flat) => ({
property: op.property,
expected: op.value ?? null,
actual: flat.attrs[op.property] ?? null,
}),
};
function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null {
return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null;
}
function checkOpParity(
op: PatchOperation,
flat: FlatSnapshot,
hfId: string,
): SdkShadowMismatch | null {
const fields = resolveOpFields(op, flat);
if (!fields || fields.actual === fields.expected) return null;
return { kind: "value_mismatch", hfId, ...fields };
}
/**
* Dispatch PatchOperations to the SDK session and return a parity report.
*
* If the element is not found by hfId, returns dispatched:false with a
* element_not_found mismatch (signals blocker E — element has no hf-id or
* SDK can't address it).
*
* On success, verifies that the SDK element snapshot reflects the applied
* values. Value mismatches indicate serialization or normalization drift.
*
* **persist:error drift risk**: the HTTP adapter fires persist:error on
* network failure but the SDK session is already mutated at that point. If
* the server file was not updated (e.g. 503), subsequent shadow parity
* comparisons here will see a diverged SDK session and produce false
* positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
* window is clear of persist:error events.
*/
export function sdkShadowDispatch(
session: Composition,
hfId: string,
ops: PatchOperation[],
): SdkShadowResult {
if (!session.getElement(hfId)) {
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
}
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
const shadowable = ops.filter(isShadowableOp);
try {
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
session.batch(() => {
for (const op of sdkOps) session.dispatch(op);
});
} catch (err) {
return {
dispatched: false,
mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }],
};
}
const flat = flattenSnapshot(session.getElement(hfId));
const mismatches = shadowable
.map((op) => checkOpParity(op, flat, hfId))
.filter((m): m is SdkShadowMismatch => m !== null);
return { dispatched: true, mismatches };
}
// ─── Telemetry reporting ──────────────────────────────────────────────────────
/**
* Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry.
* Despite the telemetry focus, this function does mutate the SDK session — it
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
*/
// Property-path mismatches carry user content (inline-style values, edited
// text) in expected/actual. Scrub before telemetry: fully redact text-content
// values, length-cap the rest. The in-memory parity result keeps raw values.
function redactValueForTelemetry(
property: string | undefined,
value: string | null | undefined,
): string | null | undefined {
if (value == null) return value;
if (property === "text") return `[redacted len=${value.length}]`;
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
}
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
return mismatches.map((m) => ({
...m,
expected: redactValueForTelemetry(m.property, m.expected),
actual: redactValueForTelemetry(m.property, m.actual),
}));
}
export function runShadowDispatch(
session: Composition,
selection: DomEditSelection,
ops: PatchOperation[],
): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
const hfId = selection.hfId;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "property",
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
// Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
// doesn't map. PatchOperation.type is a closed union today, but emit a visible
// unmapped_type event if a future type ever slips through, so the gap surfaces
// in telemetry instead of vanishing.
// Map to the type string before find, so a future unmapped type is read as a
// plain string (no object cast; find on the closed union narrows to never).
const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
if (unmappedType !== undefined) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "property",
dispatched: false,
reason: "unmapped_type",
type: unmappedType,
mismatchCount: 0,
});
return;
}
const result = sdkShadowDispatch(session, hfId, ops);
trackStudioEvent("sdk_shadow_dispatch", {
op: "property",
dispatched: result.dispatched,
mismatchCount: result.mismatches.length,
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
});
}
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
//
// These ops never flow through persistDomEditOperations, so the property-path
// shadow above never sees them. Each runner keeps the server authoritative and
// only observes the SDK: can() pre-checks addressing/validity (pure, no
// mutation — works even for GSAP, which has no element-snapshot value), then a
// dispatch into the live session with a snapshot-based parity check.
//
// Parity coverage by op:
// delete → getElement(id) === null (full)
// timing → snapshot.start/duration/trackIndex (full)
// gsap → tween id present/absent in animationIds (existence only — the
// tween's property values are script-level, not in the snapshot)
/**
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
* Mutates the SDK session (not read-only); server stays authoritative.
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
*/
function runShadowEditOp(
session: Composition,
op: EditOp,
opLabel: string,
dispatchAndCheck: () => SdkShadowMismatch[],
): void {
const verdict = session.can(op);
if (!verdict.ok) {
trackStudioEvent("sdk_shadow_dispatch", {
op: opLabel,
dispatched: false,
reason: "cannot_dispatch",
code: verdict.code,
mismatchCount: 0,
});
return;
}
let mismatches: SdkShadowMismatch[];
try {
mismatches = dispatchAndCheck();
} catch (err) {
trackStudioEvent("sdk_shadow_dispatch", {
op: opLabel,
dispatched: false,
reason: "dispatch_error",
error: String(err),
mismatchCount: 0,
});
return;
}
trackStudioEvent("sdk_shadow_dispatch", {
op: opLabel,
dispatched: true,
mismatchCount: mismatches.length,
mismatches: JSON.stringify(mismatches),
});
}
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "delete",
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
const op: EditOp = { type: "removeElement", target: hfId };
runShadowEditOp(session, op, "delete", () => {
session.batch(() => session.dispatch(op));
return session.getElement(hfId)
? [
{
kind: "value_mismatch",
hfId,
property: "exists",
expected: "removed",
actual: "present",
},
]
: [];
});
}
export interface ShadowTiming {
start?: number;
duration?: number;
trackIndex?: number;
}
// 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 relEqual(actual, expected);
}
return actual === expected;
}
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
export function runShadowTiming(
session: Composition,
hfId: string | null | undefined,
timing: ShadowTiming,
): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "timing",
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
runShadowEditOp(session, op, "timing", () => {
session.batch(() => session.dispatch(op));
const el = session.getElement(hfId);
const mismatches: SdkShadowMismatch[] = [];
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
["start", el?.start],
["duration", el?.duration],
["trackIndex", el?.trackIndex],
];
for (const [key, actual] of fields) {
const expected = timing[key];
if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
mismatches.push({
kind: "value_mismatch",
hfId,
property: key,
expected: String(expected),
actual: actual == null ? null : String(actual),
});
}
return mismatches;
});
}
export type ShadowGsapOp =
| { kind: "add"; target: string; tween: GsapTweenSpec }
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
| { kind: "remove"; animationId: string };
/**
* Shadow a GSAP tween mutation (add / set / remove). The server's animationId
* shares the SDK's id-space (both derive `targetSelector-method-position` from
* the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
*
* Parity via the now-populated ElementSnapshot.animationIds:
* add → the returned tween id is present on the target element
* remove → the id is gone from every element
* set → existence only (the SDK exposes no per-tween property reader; value
* fidelity would need serialize()-script round-trip diffing).
*/
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
const op: EditOp =
gsapOp.kind === "add"
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
: gsapOp.kind === "set"
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
: { type: "removeGsapTween", animationId: gsapOp.animationId };
// fallow-ignore-next-line complexity
runShadowEditOp(session, op, "gsap", () => {
let newId: string | undefined;
session.batch(() => {
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
else session.dispatch(op);
});
if (gsapOp.kind === "add") {
const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
if (!newId || !onTarget.includes(newId)) {
return [
{
kind: "value_mismatch",
hfId: gsapOp.target,
property: "animationIds",
expected: newId ?? "non-empty",
actual: onTarget.join(",") || null,
},
];
}
} else if (gsapOp.kind === "remove") {
const stillPresent = session
.getElements()
.some((el) => el.animationIds.includes(gsapOp.animationId));
if (stillPresent) {
return [
{
kind: "value_mismatch",
hfId: gsapOp.animationId,
property: "animationIds",
expected: "removed",
actual: "present",
},
];
}
}
return [];
});
}
// GSAP value-fidelity diff lives in its own module to keep this file under the
// 600-line studio cap; re-exported here so the shadow surface stays in one place.
export {
gsapFidelityMismatches,
resolveGsapFidelityArgs,
runShadowGsapFidelity,
} from "./sdkShadowGsapFidelity";