From fa1e575bd8fd7b3a25da45a92bf1f9b4d277f9e3 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 14:55:23 -0700 Subject: [PATCH 1/7] feat(sdk,studio): populate animationIds; shadow GSAP update/delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/sdk/src/document.ts | 47 ++++++++++++++-- packages/sdk/src/session.test.ts | 32 +++++++++++ .../studio/src/hooks/useGsapAnimationOps.ts | 18 +++++-- packages/studio/src/utils/sdkShadow.test.ts | 13 ++++- packages/studio/src/utils/sdkShadow.ts | 53 +++++++++++++------ 5 files changed, 138 insertions(+), 25 deletions(-) diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index 76774c0ae3..fd3e871148 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -10,6 +10,7 @@ import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/core/hf-ids"; +import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js"; import type { HyperFramesElement, SdkDocument } from "./types.js"; @@ -37,8 +38,45 @@ function ownText(el: Element): string | null { return trimmed.length > 0 ? trimmed : null; } +/** + * 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 { + const map = new Map(); + const script = extractGsapScript(document); + if (!script) return map; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return map; + for (const { id, animation } of parsed.located) { + const selector = animation.targetSelector; + 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 -function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null { +function buildElement( + el: Element, + scopePrefix: string, + animationIdsByHfId: Map, +): HyperFramesElement | null { const tag = el.tagName.toLowerCase(); if (EXCLUDED_TAGS.has(tag)) return null; @@ -82,7 +120,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu const children: HyperFramesElement[] = []; for (const child of Array.from(el.children)) { - const built = buildElement(child, childPrefix); + const built = buildElement(child, childPrefix, animationIdsByHfId); if (built) children.push(built); } @@ -98,7 +136,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu start, duration, trackIndex, - animationIds: [], + animationIds: animationIdsByHfId.get(id) ?? [], }; } @@ -151,9 +189,10 @@ function extractDuration(doc: Document): number | null { export function buildRoots(document: Document): HyperFramesElement[] { const body = document.body; const roots: HyperFramesElement[] = []; + const animationIdsByHfId = buildAnimationIdMap(document); if (body) { for (const child of Array.from(body.children)) { - const built = buildElement(child, ""); + const built = buildElement(child, "", animationIdsByHfId); if (built) roots.push(built); } } diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts index 23e6fb75da..488f5ad225 100644 --- a/packages/sdk/src/session.test.ts +++ b/packages/sdk/src/session.test.ts @@ -419,3 +419,35 @@ describe("setSelection", () => { expect(calls).toHaveLength(1); }); }); + +describe("animationIds population", () => { + const GSAP_HTML = ` +
+
box
+
plain
+ +
`.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); + }); +}); diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index ebd332c02f..9176f62133 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -41,8 +41,19 @@ export function useGsapAnimationOps({ coalesceKey: `gsap:${animationId}:meta`, }, ); + // Shadow: server animationId shares the SDK id-space, so dispatch as-is. + if (sdkSession) + runShadowGsapTween(sdkSession, { + kind: "set", + animationId, + properties: { + duration: updates.duration, + ease: updates.ease, + position: updates.position, + }, + }); }, - [commitMutationSafely], + [commitMutationSafely, sdkSession], ); const deleteGsapAnimation = useCallback( @@ -52,8 +63,9 @@ export function useGsapAnimationOps({ { type: "delete", animationId, stripStudioEdits: true }, { label: "Delete GSAP animation" }, ); + if (sdkSession) runShadowGsapTween(sdkSession, { kind: "remove", animationId }); }, - [commitMutationSafely], + [commitMutationSafely, sdkSession], ); const deleteAllForSelector = useCallback( @@ -120,8 +132,6 @@ export function useGsapAnimationOps({ // Shadow: dispatch the equivalent addGsapTween to the SDK (server stays // 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, diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index efc15fd1a0..a0a75147d6 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -219,13 +219,24 @@ describe("runShadowTiming", () => { }); 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 before = session.getElement("hf-box")?.animationIds.length ?? 0; runShadowGsapTween(session, { kind: "add", target: "hf-box", 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 }); }); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index a58a619096..4f64fdeca9 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -367,12 +367,15 @@ export type ShadowGsapOp = | { kind: "remove"; animationId: string }; /** - * Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the - * tween lives in the GSAP +`; + + 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(/]*>([\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(/]*>([\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); + }); +}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 4f64fdeca9..7064f6455d 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -10,6 +10,9 @@ import type { Composition } from "@hyperframes/sdk"; import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk"; +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 { DomEditSelection } from "../components/editor/domEditingTypes"; @@ -423,3 +426,130 @@ export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): return []; }); } + +// ─── GSAP value fidelity (serialize round-trip diff) ────────────────────────── +// +// Existence parity (above) 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. + +function extractGsapScript(html: string): string | null { + const scripts = html.match(/]*>([\s\S]*?)<\/script>/gi); + if (!scripts) return null; + for (const block of scripts) { + const body = block.replace(/^]*>/i, "").replace(/<\/script>$/i, ""); + if (body.includes("gsap") || body.includes("__timelines")) return body; + } + return null; +} + +function animById(script: string): Map { + const map = new Map(); + const parsed = parseGsapScriptAcorn(script); + for (const anim of parsed.animations) map.set(anim.id, anim); + return map; +} + +/** + * 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). + */ +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; + } + const fields: Array<[string, unknown, unknown]> = [ + ["method", a.method, b.method], + ["position", a.position, b.position], + ["duration", a.duration, b.duration], + ["ease", a.ease, b.ease], + ["properties", JSON.stringify(a.properties ?? {}), JSON.stringify(b.properties ?? {})], + [ + "fromProperties", + JSON.stringify(a.fromProperties ?? {}), + JSON.stringify(b.fromProperties ?? {}), + ], + ]; + for (const [property, av, bv] of fields) { + if (av !== bv) { + mismatches.push({ + kind: "value_mismatch", + hfId: id, + property, + expected: bv == null ? null : String(bv), + actual: av == null ? null : String(av), + }); + } + } + } + return mismatches; +} + +/** + * 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 { + if (!STUDIO_SDK_SHADOW_ENABLED) 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, + }); + } +} From 19645ee0f3b9f67e21bd11fbb97b6ef16e692112 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 16:00:46 -0700 Subject: [PATCH 3/7] fix(studio,sdk): address shadow code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/sdk/src/document.ts | 29 ++++++-- .../src/hooks/useSafeGsapCommitMutation.ts | 15 +--- packages/studio/src/utils/sdkShadow.test.ts | 20 +++++ packages/studio/src/utils/sdkShadow.ts | 73 +++++++++++++++---- 4 files changed, 103 insertions(+), 34 deletions(-) diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index fd3e871148..a9fa6d7140 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -38,6 +38,24 @@ function ownText(el: Element): string | 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 — @@ -49,10 +67,7 @@ function buildAnimationIdMap(document: Document): Map { const map = new Map(); const script = extractGsapScript(document); if (!script) return map; - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return map; - for (const { id, animation } of parsed.located) { - const selector = animation.targetSelector; + for (const { id, selector } of parseLocatedCached(script)) { if (!selector) continue; let matches: Element[] = []; try { @@ -142,10 +157,12 @@ function buildElement( // fallow-ignore-next-line complexity function extractGsapScript(doc: Document): string | null { - // GSAP script is the first 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 = ` +
+
box
+ +
`.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 = ` +
+
a
+
b
+ +
`.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 + }); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index ecd1d0bbb7..6c0abe9fca 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { applySoftReload } from "../utils/gsapSoftReload"; -import { runShadowGsapFidelity } from "../utils/sdkShadow"; +import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { GsapMutationHttpError, @@ -70,8 +70,18 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra 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. - if (sdkSession && options.shadowGsapOp && result.before != null && result.scriptText != null) { - void runShadowGsapFidelity(result.before, options.shadowGsapOp, result.scriptText); + // 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) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index 4770b6acb3..d136ed6e0b 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -6,8 +6,10 @@ import { runShadowGsapTween, runShadowGsapFidelity, gsapFidelityMismatches, + resolveGsapFidelityArgs, SdkShadowMismatch, } from "./sdkShadow"; +import type { ShadowGsapOp } from "./sdkShadow"; import type { PatchOperation } from "./sourcePatcher"; import { openComposition } from "@hyperframes/sdk"; @@ -318,7 +320,8 @@ window.__timelines["t"] = tl; tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, } as const; ref.addGsapTween(op.target, op.tween); - const serverScript = ref.serialize().match(/]*>([\s\S]*?)<\/script>/i)?.[1] ?? ""; + const serverScript = + ref.serialize().match(/]*>([\s\S]*?)<\/script\s*>/i)?.[1] ?? ""; await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 }); @@ -333,7 +336,7 @@ window.__timelines["t"] = tl; const ref = await openComposition(BEFORE_HTML); ref.addGsapTween(op.target, op.tween); const serverScript = ( - ref.serialize().match(/]*>([\s\S]*?)<\/script>/i)?.[1] ?? "" + ref.serialize().match(/]*>([\s\S]*?)<\/script\s*>/i)?.[1] ?? "" ).replace("100", "999"); await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); @@ -342,3 +345,32 @@ window.__timelines["t"] = tl; 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, "before", "tl.to(...)")).toEqual({ + before: "before", + 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(); + }); +}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 095c19717a..4a9c23aad9 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -427,4 +427,8 @@ export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): // 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, runShadowGsapFidelity } from "./sdkShadowGsapFidelity"; +export { + gsapFidelityMismatches, + resolveGsapFidelityArgs, + runShadowGsapFidelity, +} from "./sdkShadowGsapFidelity"; diff --git a/packages/studio/src/utils/sdkShadowGsapFidelity.ts b/packages/studio/src/utils/sdkShadowGsapFidelity.ts index 7f20f82899..5cf29e10c7 100644 --- a/packages/studio/src/utils/sdkShadowGsapFidelity.ts +++ b/packages/studio/src/utils/sdkShadowGsapFidelity.ts @@ -25,10 +25,12 @@ function isGsapScriptBody(body: string): boolean { } function extractGsapScript(html: string): string | null { - const scripts = html.match(/]*>([\s\S]*?)<\/script>/gi); + // `` (not just ``) — match the whitespace-before-close + // variant too (CodeQL js/bad-tag-filter). + const scripts = html.match(/]*>([\s\S]*?)<\/script\s*>/gi); if (!scripts) return null; for (const block of scripts) { - const body = block.replace(/^]*>/i, "").replace(/<\/script>$/i, ""); + const body = block.replace(/^]*>/i, "").replace(/<\/script\s*>$/i, ""); if (isGsapScriptBody(body)) return body; } return null; @@ -132,6 +134,30 @@ export function gsapFidelityMismatches( 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 From 146e1e8946de696c64adce08063b7e2ebbeb8fc2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 22:34:12 -0700 Subject: [PATCH 7/7] =?UTF-8?q?fix(studio):=20CodeQL=20js/bad-tag-filter?= =?UTF-8?q?=20=E2=80=94=20match=20]*>=20close=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` still tripped CodeQL on attribute-junk closes like `` (HTML5 ignores junk before `>`). Widen the close-tag match to `]*>` in the GSAP-script extraction and the test regexes. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/studio/src/utils/sdkShadow.test.ts | 4 ++-- packages/studio/src/utils/sdkShadowGsapFidelity.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index d136ed6e0b..3fd0b931d7 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -321,7 +321,7 @@ window.__timelines["t"] = tl; } as const; ref.addGsapTween(op.target, op.tween); const serverScript = - ref.serialize().match(/]*>([\s\S]*?)<\/script\s*>/i)?.[1] ?? ""; + ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""; await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 }); @@ -336,7 +336,7 @@ window.__timelines["t"] = tl; const ref = await openComposition(BEFORE_HTML); ref.addGsapTween(op.target, op.tween); const serverScript = ( - ref.serialize().match(/]*>([\s\S]*?)<\/script\s*>/i)?.[1] ?? "" + ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "" ).replace("100", "999"); await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); diff --git a/packages/studio/src/utils/sdkShadowGsapFidelity.ts b/packages/studio/src/utils/sdkShadowGsapFidelity.ts index 5cf29e10c7..62d64b9a0f 100644 --- a/packages/studio/src/utils/sdkShadowGsapFidelity.ts +++ b/packages/studio/src/utils/sdkShadowGsapFidelity.ts @@ -25,12 +25,12 @@ function isGsapScriptBody(body: string): boolean { } function extractGsapScript(html: string): string | null { - // `` (not just ``) — match the whitespace-before-close - // variant too (CodeQL js/bad-tag-filter). - const scripts = html.match(/]*>([\s\S]*?)<\/script\s*>/gi); + // Close tag is `]*>` (not just ``) — HTML5 ignores junk + // before the `>`, e.g. `` or `` (CodeQL js/bad-tag-filter). + const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); if (!scripts) return null; for (const block of scripts) { - const body = block.replace(/^]*>/i, "").replace(/<\/script\s*>$/i, ""); + const body = block.replace(/^]*>/i, "").replace(/<\/script[^>]*>$/i, ""); if (isGsapScriptBody(body)) return body; } return null;