feat(sdk,studio): populate animationIds; shadow GSAP update/delete + value fidelity (#1474)

* feat(sdk,studio): populate animationIds; shadow GSAP update/delete

Closes the GSAP shadow gaps. The server's animationId was assumed to live in a
separate id-space — it does not: the studio-api read path (T6e) and the SDK
both derive tween ids as targetSelector-method-position from the same acorn
parser, so server ids are dispatchable in the SDK as-is.

SDK: populate ElementSnapshot.animationIds (was a hardcoded stub) from
parseGsapScriptAcornForWrite().located, resolving each tween's targetSelector
to element hf-ids. Makes the snapshot truthful and enables real GSAP parity.

Studio: shadow deleteGsapAnimation (removeGsapTween) and updateGsapMeta
(setGsapTween) using the server animationId directly. GSAP add/remove parity
now verifies via animationIds (present after add, gone after remove). set is
existence-only — the SDK still has no per-tween property reader (value fidelity
would need serialize()-script round-trip diffing).

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

* feat(studio): GSAP value fidelity via serialize round-trip diff

Closes the last shadow gap: GSAP value fidelity. Existence parity confirmed a
tween was created/removed but not that its values (duration/ease/position/
properties) matched the server, since the SDK has no per-tween property reader.

runShadowGsapFidelity opens a fresh SDK doc from the server's pre-op file
(result.before), applies the same typed op, serializes, and structurally diffs
the SDK's GSAP script against the server's resulting script (result.scriptText).
Both are re-parsed via parseGsapScriptAcorn, so formatting/whitespace never
produces false positives — only real value drift does. gsapFidelityMismatches
reports per-field drift and tween presence/absence.

Wired at the commitMutation chokepoint (the only place with the server's
before+after scripts); handlers pass the typed ShadowGsapOp via
CommitMutationOptions.shadowGsapOp. Emits sdk_shadow_dispatch op:gsap_fidelity.
Complements the existing live existence shadow (op:gsap).

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

* fix(studio,sdk): address shadow code-review findings

- gsapFidelityMismatches: canonical comparison (sort property keys, numeric-
  coerce position/duration/values). Server (addAnimationToScript) and SDK
  (gsapWriterAcorn) are different writers; non-canonical compare flagged
  key-order / number-vs-string differences as false value drift.
- document.ts buildAnimationIdMap: memoize the acorn parse by script text
  (single-entry). getElements() invalidates on every dispatch, so shadow's
  frequent dispatches were re-parsing the full GSAP AST each rebuild. Selector
  resolution still runs per-call (depends on live DOM).
- runShadowGsapFidelity: early-bail when serverScript/beforeHtml is empty —
  skip the costly openComposition.
- useSafeGsapCommitMutation: import the shared CommitMutationOptions/
  CommitMutation instead of a stale local duplicate (was missing shadowGsapOp).
- align extractGsapScript marker set across sdkShadow.ts and document.ts
  (gsap || __timelines || ScrollTrigger) so both pick the same script.

Tests: +2 canonical-compare cases (key-order, number-vs-string → no drift).

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

* fix(studio,sdk): fallow gate for #1474 (fidelity diff + test clones)

- suppress moderate CRAP on gsapFidelityMismatches and the runShadowGsapTween
  parity arrow (comparison/parity functions are inherently branchy)
- suppress two pre-existing test clones in session.test.ts surfaced by the
  added animationIds tests (TestPreviewAdapter stub, selectionchange setup)

Rebased onto the updated #1473 (no-persist shadow session); inherits the
persist-race fix and prior fallow suppressions.

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

* refactor(studio): extract GSAP fidelity to its own module (file-size gate)

sdkShadow.ts hit 602 lines (CI File size check: max 600). Move the GSAP
value-fidelity diff (gsapFidelityMismatches, runShadowGsapFidelity, and their
private helpers) into sdkShadowGsapFidelity.ts; re-export from sdkShadow.ts so
the import surface is unchanged. sdkShadow.ts now 430 lines.

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

* fix(studio,sdk): address #1474 review feedback

- CodeQL js/bad-tag-filter: the GSAP <script> extraction + test regexes now
  match </script\s*> (whitespace-before-close variant). 3 alerts resolved.
- Wiring (Miguel): extract resolveGsapFidelityArgs — a pure, narrowing gate for
  the commitMutation chokepoint (no non-null assertions) — and unit-test the
  fire/skip conditions (session, op, before, scriptText). Replaces the inline
  guard so the wiring decision is covered without rendering the hook.
- Property-handler scope (Rames): comment at the chokepoint documenting that
  only meta-level ops (add/update-meta/delete) carry shadowGsapOp today;
  per-property and keyframe handlers are a deliberate follow-up. Also why
  scriptText can be null.
- Test coverage (Rames): multi-tween-per-element and shared-selector
  cross-element animationIds cases.

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

* fix(studio): CodeQL js/bad-tag-filter — match </script[^>]*> close tags

`</script\s*>` still tripped CodeQL on attribute-junk closes like
`</script foo>` (HTML5 ignores junk before `>`). Widen the close-tag match to
`</script[^>]*>` in the GSAP-script extraction and the test regexes.

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-15 22:40:28 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5f6ced116d
commit 7593aac5ef
9 changed files with 570 additions and 64 deletions
+62 -6
View File
@@ -10,6 +10,7 @@
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids"; import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js"; import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js"; import type { HyperFramesElement, SdkDocument } from "./types.js";
@@ -37,8 +38,60 @@ function ownText(el: Element): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
// Parsing the GSAP script (acorn AST walk) is the expensive part and depends
// only on the script text, so memoize the {tween id, selector} pairs by script.
// Selector→hf-id resolution still runs each call — it depends on the live DOM,
// which changes on dispatch. Single-entry cache covers the hot path (same comp,
// repeated getElements() rebuilds) and stays bounded.
let gsapLocatedCacheKey: string | null = null;
let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = [];
function parseLocatedCached(script: string): Array<{ id: string; selector: string }> {
if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal;
const parsed = parseGsapScriptAcornForWrite(script);
gsapLocatedCacheVal = parsed
? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector }))
: [];
gsapLocatedCacheKey = script;
return gsapLocatedCacheVal;
}
/**
* Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids
* come from the acorn parser's stable `targetSelector-method-position` scheme —
* the SAME id-space the studio-api read path and the SDK GSAP ops use, so these
* ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a
* malformed selector or unparseable script yields no entries (animationIds: []).
*/
function buildAnimationIdMap(document: Document): Map<string, string[]> {
const map = new Map<string, string[]>();
const script = extractGsapScript(document);
if (!script) return map;
for (const { id, selector } of parseLocatedCached(script)) {
if (!selector) continue;
let matches: Element[] = [];
try {
matches = Array.from(document.querySelectorAll(selector));
} catch {
continue; // selector not valid for querySelectorAll — skip
}
for (const el of matches) {
const hfId = el.getAttribute("data-hf-id");
if (!hfId) continue;
const list = map.get(hfId);
if (list) list.push(id);
else map.set(hfId, [id]);
}
}
return map;
}
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null { function buildElement(
el: Element,
scopePrefix: string,
animationIdsByHfId: Map<string, string[]>,
): HyperFramesElement | null {
const tag = el.tagName.toLowerCase(); const tag = el.tagName.toLowerCase();
if (EXCLUDED_TAGS.has(tag)) return null; if (EXCLUDED_TAGS.has(tag)) return null;
@@ -82,7 +135,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
const children: HyperFramesElement[] = []; const children: HyperFramesElement[] = [];
for (const child of Array.from(el.children)) { for (const child of Array.from(el.children)) {
const built = buildElement(child, childPrefix); const built = buildElement(child, childPrefix, animationIdsByHfId);
if (built) children.push(built); if (built) children.push(built);
} }
@@ -98,16 +151,18 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
start, start,
duration, duration,
trackIndex, trackIndex,
animationIds: [], animationIds: animationIdsByHfId.get(id) ?? [],
}; };
} }
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
function extractGsapScript(doc: Document): string | null { function extractGsapScript(doc: Document): string | null {
// GSAP script is the first <script> tag whose text references gsap // GSAP script is the first <script> tag whose text references gsap. Marker
// set must match studio sdkShadow.ts isGsapScriptBody so both pick the same
// script from a given composition.
for (const script of Array.from(doc.querySelectorAll("script"))) { for (const script of Array.from(doc.querySelectorAll("script"))) {
const text = script.textContent ?? ""; const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("ScrollTrigger")) { if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) {
return text; return text;
} }
} }
@@ -151,9 +206,10 @@ function extractDuration(doc: Document): number | null {
export function buildRoots(document: Document): HyperFramesElement[] { export function buildRoots(document: Document): HyperFramesElement[] {
const body = document.body; const body = document.body;
const roots: HyperFramesElement[] = []; const roots: HyperFramesElement[] = [];
const animationIdsByHfId = buildAnimationIdMap(document);
if (body) { if (body) {
for (const child of Array.from(body.children)) { for (const child of Array.from(body.children)) {
const built = buildElement(child, ""); const built = buildElement(child, "", animationIdsByHfId);
if (built) roots.push(built); if (built) roots.push(built);
} }
} }
+64
View File
@@ -17,6 +17,7 @@ const BASE_HTML = `
class TestPreviewAdapter implements PreviewAdapter { class TestPreviewAdapter implements PreviewAdapter {
private selectionHandlers: Array<(ids: string[]) => void> = []; private selectionHandlers: Array<(ids: string[]) => void> = [];
// fallow-ignore-next-line code-duplication
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null { elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
return null; return null;
} }
@@ -386,6 +387,7 @@ describe("setSelection", () => {
expect(patches).toHaveLength(0); expect(patches).toHaveLength(0);
}); });
// fallow-ignore-next-line code-duplication
it("setSelection with same ids does not fire selectionchange again", async () => { it("setSelection with same ids does not fire selectionchange again", async () => {
const comp = await openComposition(BASE_HTML); const comp = await openComposition(BASE_HTML);
const calls: string[][] = []; const calls: string[][] = [];
@@ -419,3 +421,65 @@ describe("setSelection", () => {
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
}); });
}); });
describe("animationIds population", () => {
const GSAP_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0">box</div>
<div data-hf-id="hf-plain">plain</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;</script>
</div>`.trim();
it("attaches the parser's stable tween id to the targeted element", async () => {
const comp = await openComposition(GSAP_HTML);
const box = comp.getElement("hf-box");
expect(box?.animationIds.length).toBe(1);
// Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position.
expect(box?.animationIds[0]).toContain("hf-box");
expect(box?.animationIds[0]).toContain("-to-");
});
it("leaves untargeted elements with an empty animationIds", async () => {
const comp = await openComposition(GSAP_HTML);
expect(comp.getElement("hf-plain")?.animationIds).toEqual([]);
});
it("the populated id is dispatchable as a removeGsapTween target", async () => {
const comp = await openComposition(GSAP_HTML);
const id = comp.getElement("hf-box")?.animationIds[0];
expect(id).toBeDefined();
if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true);
});
it("attaches multiple distinct tween ids when one element has several tweens", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0">box</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
tl.from("[data-hf-id=\\"hf-box\\"]", { x: -100, duration: 0.5 }, 1);
window.__timelines["t"] = tl;</script>
</div>`.trim();
const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? [];
expect(ids.length).toBe(2);
expect(new Set(ids).size).toBe(2); // distinct
});
it("fans a shared-selector tween out to every matched element", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-a" class="fade">a</div>
<div data-hf-id="hf-b" class="fade">b</div>
<script>var tl = gsap.timeline({ paused: true });
tl.to(".fade", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["t"] = tl;</script>
</div>`.trim();
const comp = await openComposition(html);
const a = comp.getElement("hf-a")?.animationIds ?? [];
const b = comp.getElement("hf-b")?.animationIds ?? [];
expect(a.length).toBe(1);
expect(b).toEqual(a); // same tween id on both matched elements
});
});
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
import type { Composition } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { EditHistoryKind } from "../utils/editHistory"; import type { EditHistoryKind } from "../utils/editHistory";
import type { ShadowGsapOp } from "../utils/sdkShadow";
export interface MutationResult { export interface MutationResult {
ok: boolean; ok: boolean;
@@ -18,6 +19,8 @@ export interface CommitMutationOptions {
softReload?: boolean; softReload?: boolean;
skipReload?: boolean; skipReload?: boolean;
beforeReload?: () => void; beforeReload?: () => void;
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
shadowGsapOp?: ShadowGsapOp;
} }
export type CommitMutation = ( export type CommitMutation = (
@@ -1,8 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { roundTo3 } from "../utils/rounding"; import { roundTo3 } from "../utils/rounding";
import { runShadowGsapTween } from "../utils/sdkShadow"; import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
import { import {
assignGsapTargetAutoIdIfNeeded, assignGsapTargetAutoIdIfNeeded,
ensureElementAddressable, ensureElementAddressable,
@@ -33,27 +33,34 @@ export function useGsapAnimationOps({
animationId: string, animationId: string,
updates: { duration?: number; ease?: string; position?: number }, updates: { duration?: number; ease?: string; position?: number },
) => { ) => {
// Shadow op (server animationId shares the SDK id-space): existence via
// runShadowGsapTween (live session) + value fidelity via the chokepoint.
const shadowGsapOp: ShadowGsapOp = {
kind: "set",
animationId,
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
};
commitMutationSafely( commitMutationSafely(
selection, selection,
{ type: "update-meta", animationId, updates }, { type: "update-meta", animationId, updates },
{ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
label: "Edit GSAP animation",
coalesceKey: `gsap:${animationId}:meta`,
},
); );
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
}, },
[commitMutationSafely], [commitMutationSafely, sdkSession],
); );
const deleteGsapAnimation = useCallback( const deleteGsapAnimation = useCallback(
(selection: DomEditSelection, animationId: string) => { (selection: DomEditSelection, animationId: string) => {
const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
commitMutationSafely( commitMutationSafely(
selection, selection,
{ type: "delete", animationId, stripStudioEdits: true }, { type: "delete", animationId, stripStudioEdits: true },
{ label: "Delete GSAP animation" }, { label: "Delete GSAP animation", shadowGsapOp },
); );
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
}, },
[commitMutationSafely], [commitMutationSafely, sdkSession],
); );
const deleteAllForSelector = useCallback( const deleteAllForSelector = useCallback(
@@ -103,6 +110,26 @@ export function useGsapAnimationOps({
fromTo: { x: 0, y: 0, opacity: 1 }, fromTo: { x: 0, y: 0, opacity: 1 },
}; };
// Shadow op (server stays authoritative). "set" has no SDK method, so it
// is not shadowed; otherwise: existence via runShadowGsapTween (live) +
// value fidelity via the chokepoint (shadowGsapOp in options).
const shadowGsapOp: ShadowGsapOp | undefined =
selection.hfId && method !== "set"
? {
kind: "add",
target: selection.hfId,
tween: {
method,
position,
duration,
ease: "power2.out",
...(method === "fromTo"
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
: { properties: toDefaults[method] ?? { opacity: 1 } }),
},
}
: undefined;
await commitMutation( await commitMutation(
selection, selection,
{ {
@@ -115,25 +142,10 @@ export function useGsapAnimationOps({
properties: toDefaults[method] ?? { opacity: 1 }, properties: toDefaults[method] ?? { opacity: 1 },
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
}, },
{ label: `Add GSAP ${method} animation` }, { label: `Add GSAP ${method} animation`, shadowGsapOp },
); );
// Shadow: dispatch the equivalent addGsapTween to the SDK (server stays if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
// authoritative). "set" has no SDK method, so it is not shadowed.
// ponytail: only add is shadowed — delete/update key on the server's
// animationId, which doesn't resolve in the SDK's independent id-space.
if (sdkSession && selection.hfId && method !== "set") {
const tween: GsapTweenSpec = {
method,
position,
duration,
ease: "power2.out",
...(method === "fromTo"
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
: { properties: toDefaults[method] ?? { opacity: 1 } }),
};
runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
}
}, },
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession], [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
); );
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { applySoftReload } from "../utils/gsapSoftReload"; import { applySoftReload } from "../utils/gsapSoftReload";
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
import { import {
GsapMutationHttpError, GsapMutationHttpError,
@@ -67,6 +68,21 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
} }
if (result.changed === false) return; if (result.changed === false) return;
domEditSaveTimestampRef.current = Date.now(); domEditSaveTimestampRef.current = Date.now();
// Shadow value fidelity: diff the SDK's GSAP writer output against the
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
// Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via
// useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce,
// useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up.
// scriptText is null when the composition has no GSAP script; nothing to diff.
const fidelityArgs = resolveGsapFidelityArgs(
sdkSession,
options.shadowGsapOp,
result.before,
result.scriptText,
);
if (fidelityArgs) {
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
}
if (result.before != null && result.after != null) { if (result.before != null && result.after != null) {
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
} }
@@ -80,7 +96,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
reloadPreview(); reloadPreview();
} }
onCacheInvalidate(); onCacheInvalidate();
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]); }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
const propertyOps = useGsapPropertyDebounce(commitMutationSafely); const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
@@ -1,20 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
type CommitMutationOptions = {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
beforeReload?: () => void;
};
type CommitMutation = (
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: CommitMutationOptions,
) => Promise<void>;
type TrackGsapSaveFailure = ( type TrackGsapSaveFailure = (
error: unknown, error: unknown,
+131 -1
View File
@@ -4,8 +4,12 @@ import {
runShadowDelete, runShadowDelete,
runShadowTiming, runShadowTiming,
runShadowGsapTween, runShadowGsapTween,
runShadowGsapFidelity,
gsapFidelityMismatches,
resolveGsapFidelityArgs,
SdkShadowMismatch, SdkShadowMismatch,
} from "./sdkShadow"; } from "./sdkShadow";
import type { ShadowGsapOp } from "./sdkShadow";
import type { PatchOperation } from "./sourcePatcher"; import type { PatchOperation } from "./sourcePatcher";
import { openComposition } from "@hyperframes/sdk"; import { openComposition } from "@hyperframes/sdk";
@@ -219,13 +223,24 @@ describe("runShadowTiming", () => {
}); });
describe("runShadowGsapTween", () => { describe("runShadowGsapTween", () => {
it("dispatches add against a real timeline and reports success", async () => { it("add reports success and the new tween lands on the target's animationIds", async () => {
const session = await openComposition(GSAP_HTML); const session = await openComposition(GSAP_HTML);
const before = session.getElement("hf-box")?.animationIds.length ?? 0;
runShadowGsapTween(session, { runShadowGsapTween(session, {
kind: "add", kind: "add",
target: "hf-box", target: "hf-box",
tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
}); });
expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
});
it("remove drops the tween from animationIds and reports parity", async () => {
const session = await openComposition(GSAP_HTML);
const animationId = session.getElement("hf-box")?.animationIds[0];
expect(animationId).toBeDefined();
runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
}); });
@@ -244,3 +259,118 @@ describe("runShadowGsapTween", () => {
}); });
}); });
}); });
const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
describe("gsapFidelityMismatches", () => {
it("returns no mismatches for identical scripts", () => {
expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
});
it("flags a per-field value drift (duration)", () => {
const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
expect(mismatches.some((m) => m.property === "duration")).toBe(true);
});
it("flags a tween present in one script but not the other", () => {
const empty = `var tl = gsap.timeline({ paused: true });
window.__timelines["t"] = tl;`;
const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
expect(mismatches.some((m) => m.property === "tween")).toBe(true);
});
it("does NOT flag property key-order differences (canonical compare)", () => {
const ab = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
window.__timelines["t"] = tl;`;
const ba = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
window.__timelines["t"] = tl;`;
expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
});
it("does NOT flag number-vs-string-equivalent property values", () => {
const numeric = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["t"] = tl;`;
const stringy = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
window.__timelines["t"] = tl;`;
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
});
});
describe("runShadowGsapFidelity", () => {
const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
window.__timelines["t"] = tl;</script>
</div>`;
it("reports zero mismatches when the SDK output matches the server script", async () => {
// Produce the "server" script by applying the same op via the SDK, so a
// faithful SDK writer must reproduce it exactly.
const ref = await openComposition(BEFORE_HTML);
const op = {
kind: "add",
target: "hf-box",
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
} as const;
ref.addGsapTween(op.target, op.tween);
const serverScript =
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
});
it("reports mismatches when the server script diverges", async () => {
const op = {
kind: "add",
target: "hf-box",
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
} as const;
const ref = await openComposition(BEFORE_HTML);
ref.addGsapTween(op.target, op.tween);
const serverScript = (
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
).replace("100", "999");
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
const ev = lastShadow();
expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
expect(ev?.mismatchCount as number).toBeGreaterThan(0);
});
});
describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
const session = {} as object;
it("returns narrowed args when session, op, before, and serverScript are all present", () => {
expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
before: "<html>before</html>",
op,
serverScript: "tl.to(...)",
});
});
it("returns null when no session (shadow not wired)", () => {
expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
});
it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
});
it("returns null when serverScript is null (composition has no GSAP script)", () => {
expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
});
it("returns null when before is null", () => {
expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
});
});
+46 -16
View File
@@ -367,12 +367,15 @@ export type ShadowGsapOp =
| { kind: "remove"; animationId: string }; | { kind: "remove"; animationId: string };
/** /**
* Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the * Shadow a GSAP tween mutation (add / set / remove). The server's animationId
* tween lives in the GSAP <script>, and ElementSnapshot.animationIds is a stub * shares the SDK's id-space (both derive `targetSelector-method-position` from
* (always [] — see sdk document.ts). So the signal here is can() addressing / * the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
* validity + dispatch-didn't-throw, plus (for add) that the SDK returned a *
* non-empty tween id. Full fidelity needs serialize()-script round-trip diffing, * Parity via the now-populated ElementSnapshot.animationIds:
* out of scope for shadow. // ponytail: upgrade when animationIds is populated. * 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 { export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return; if (!STUDIO_SDK_SHADOW_ENABLED) return;
@@ -382,23 +385,50 @@ export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp):
: gsapOp.kind === "set" : gsapOp.kind === "set"
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties } ? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
: { type: "removeGsapTween", animationId: gsapOp.animationId }; : { type: "removeGsapTween", animationId: gsapOp.animationId };
// fallow-ignore-next-line complexity
runShadowEditOp(session, op, "gsap", () => { runShadowEditOp(session, op, "gsap", () => {
let newId: string | undefined; let newId: string | undefined;
session.batch(() => { session.batch(() => {
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween); if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
else session.dispatch(op); else session.dispatch(op);
}); });
if (gsapOp.kind === "add" && !newId) { if (gsapOp.kind === "add") {
return [ const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
{ if (!newId || !onTarget.includes(newId)) {
kind: "value_mismatch", return [
hfId: gsapOp.target, {
property: "tweenId", kind: "value_mismatch",
expected: "non-empty", hfId: gsapOp.target,
actual: null, 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 []; 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";
@@ -0,0 +1,208 @@
/**
* GSAP value-fidelity shadow (serialize round-trip diff). Split out of
* sdkShadow.ts to keep that file under the 600-line studio cap.
*
* Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not
* that its VALUES (duration / ease / position / properties) match the server.
* The SDK exposes no per-tween property reader, so we compare the two writers'
* output: apply the same op to a fresh SDK doc opened from the server's pre-op
* file, then structurally diff the SDK's GSAP script against the server's
* resulting script. Both are re-parsed, so formatting/whitespace differences
* never produce false positives — only real value drift does.
*/
import { openComposition } from "@hyperframes/sdk";
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 type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
// Marker set must match document.ts extractGsapScript so both pick the same
// <script> from any given composition.
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[^>]*>` (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);
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;
}
function animById(script: string): Map<string, GsapAnimation> {
const map = new Map<string, GsapAnimation>();
const parsed = parseGsapScriptAcorn(script);
for (const anim of parsed.animations) map.set(anim.id, anim);
return map;
}
// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT
// writers, so the same tween can serialize with different property key order or
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
// strings — so only real value drift registers, not formatting differences.
function numericEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
const na = typeof a === "string" ? Number(a) : a;
const nb = typeof b === "string" ? Number(b) : b;
return (
typeof na === "number" &&
typeof nb === "number" &&
!Number.isNaN(na) &&
!Number.isNaN(nb) &&
na === nb
);
}
function canonicalProps(obj: Record<string, unknown> | undefined): string {
if (!obj) return "{}";
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj).sort()) {
const v = obj[key];
// normalize "0.5" → 0.5 so a number/string writer difference isn't drift
out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
}
return JSON.stringify(out);
}
/**
* Structurally diff two GSAP scripts by tween id. Reports a tween present in
* one but not the other, and per-field value drift (method, position, duration,
* ease, properties, fromProperties). Comparison is canonical (see above) so
* writer formatting differences do not produce false mismatches.
*/
// fallow-ignore-next-line complexity
export function gsapFidelityMismatches(
sdkScript: string,
serverScript: string,
): SdkShadowMismatch[] {
const sdk = animById(sdkScript);
const server = animById(serverScript);
const mismatches: SdkShadowMismatch[] = [];
const ids = new Set([...sdk.keys(), ...server.keys()]);
for (const id of ids) {
const a = sdk.get(id);
const b = server.get(id);
if (!a || !b) {
mismatches.push({
kind: "value_mismatch",
hfId: id,
property: "tween",
expected: b ? "present" : "absent",
actual: a ? "present" : "absent",
});
continue;
}
// [property, sdk-value, server-value, equal?]
const fields: Array<[string, unknown, unknown, boolean]> = [
["method", a.method, b.method, a.method === b.method],
["position", a.position, b.position, numericEqual(a.position, b.position)],
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
["ease", a.ease, b.ease, a.ease === b.ease],
[
"properties",
a.properties,
b.properties,
canonicalProps(a.properties) === canonicalProps(b.properties),
],
[
"fromProperties",
a.fromProperties,
b.fromProperties,
canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties),
],
];
for (const [property, av, bv, equal] of fields) {
if (!equal) {
mismatches.push({
kind: "value_mismatch",
hfId: id,
property,
expected: bv == null ? null : JSON.stringify(bv),
actual: av == null ? null : JSON.stringify(av),
});
}
}
}
return mismatches;
}
export interface GsapFidelityArgs {
before: string;
op: ShadowGsapOp;
serverScript: string;
}
/**
* Wiring gate for the commitMutation chokepoint: return the narrowed fidelity
* args only when there is a live session, a typed shadow op, and both the
* pre-op file and the server's resulting script to diff against (scriptText is
* null when the composition has no GSAP script). Returns null otherwise. Pure +
* narrowing so the wiring decision is unit-testable without rendering the hook
* and the caller needs no non-null assertions.
*/
export function resolveGsapFidelityArgs(
sdkSession: unknown,
shadowGsapOp: ShadowGsapOp | undefined,
before: string | null | undefined,
serverScript: string | null | undefined,
): GsapFidelityArgs | null {
if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null;
return { before, op: shadowGsapOp, serverScript };
}
/**
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
* against the server's resulting script. Emits sdk_shadow_dispatch op:
* "gsap_fidelity". Async, fire-and-forget; server stays authoritative.
*/
export async function runShadowGsapFidelity(
beforeHtml: string,
gsapOp: ShadowGsapOp,
serverScript: string,
): Promise<void> {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
// No server script to diff against → skip the (costly) openComposition.
if (!serverScript || !beforeHtml) return;
try {
const session = await openComposition(beforeHtml);
session.batch(() => {
if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween);
else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties);
else session.removeGsapTween(gsapOp.animationId);
});
const sdkScript = extractGsapScript(session.serialize());
if (sdkScript == null) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_fidelity",
dispatched: false,
reason: "no_sdk_script",
mismatchCount: 0,
});
return;
}
const mismatches = gsapFidelityMismatches(sdkScript, serverScript);
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_fidelity",
dispatched: true,
mismatchCount: mismatches.length,
mismatches: JSON.stringify(mismatches),
});
} catch (err) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_fidelity",
dispatched: false,
reason: "fidelity_error",
error: String(err),
mismatchCount: 0,
});
}
}