mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(sdk,studio): resolve animation ids from parsed script, not just DOM-matched elements (#1957)
recordAnimationResolverParity reported a false animation_not_found divergence for any tween whose selector doesn't currently CSS-match a live DOM element, because it only checked el.animationIds (DOM-gated). The real server-side op it shadows resolves purely from the parsed script. Adds Composition.getAllAnimationIds() as a DOM-independent id set and checks it too, matching the server's actual resolution behavior.
This commit is contained in:
@@ -82,6 +82,20 @@ function buildAnimationIdMap(document: Document): Map<string, string[]> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every GSAP tween id `parseLocatedCached` finds in the script, with no DOM
|
||||
* matching at all — the same id space the server-side script ops
|
||||
* (removeAllKeyframesFromScript et al.) resolve against. Unlike
|
||||
* buildAnimationIdMap's per-element map, this never drops an id just because
|
||||
* its selector doesn't currently CSS-match a live element — that gap is what
|
||||
* caused a false animation_not_found divergence in the resolver-shadow
|
||||
* tripwire (a tween on a renamed/duplicate/scoped selector still resolves on
|
||||
* the server, which reads the script directly).
|
||||
*/
|
||||
export function parsedAnimationIds(script: string): Set<string> {
|
||||
return new Set(parseLocatedCached(script).map(({ id }) => id));
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function buildElement(
|
||||
el: Element,
|
||||
|
||||
@@ -518,3 +518,41 @@ window.__timelines["t"] = tl;</script>
|
||||
expect(b).toEqual(a); // same tween id on both matched elements
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllAnimationIds", () => {
|
||||
it("includes a tween id even when its selector matches no live DOM element", async () => {
|
||||
const html = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("#does-not-exist", { x: 100, duration: 1 }, 3);</script>
|
||||
</body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
const flatIds = comp.getAllAnimationIds();
|
||||
expect(flatIds.size).toBeGreaterThan(0);
|
||||
const [unmatchedId] = [...flatIds];
|
||||
// Confirms the bug this fixes: no element's animationIds contains this id,
|
||||
// because "#does-not-exist" never CSS-matches anything in the document.
|
||||
expect(comp.getElements().some((el) => el.animationIds.includes(unmatchedId ?? ""))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty set when the composition has no GSAP script", async () => {
|
||||
const html = /* html */ `<!DOCTYPE html>
|
||||
<html><body><div data-hf-id="hf-box">Hello</div></body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
expect(comp.getAllAnimationIds().size).toBe(0);
|
||||
});
|
||||
|
||||
it("still includes ids for tweens that DO match a live DOM element", async () => {
|
||||
const html = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
|
||||
</body></html>`;
|
||||
const comp = await openComposition(html);
|
||||
const realId = comp.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
|
||||
expect(realId).not.toBe("");
|
||||
expect(comp.getAllAnimationIds().has(realId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
ElementHandle,
|
||||
} from "./types.js";
|
||||
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
import { buildRoots, flatElements } from "./document.js";
|
||||
import { buildRoots, flatElements, parsedAnimationIds } from "./document.js";
|
||||
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
||||
import { parseMutable } from "./engine/model.js";
|
||||
import type { ParsedDocument } from "./engine/model.js";
|
||||
@@ -334,6 +334,11 @@ class CompositionImpl implements Composition {
|
||||
);
|
||||
}
|
||||
|
||||
getAllAnimationIds(): Set<string> {
|
||||
const script = getGsapScript(this.parsed.document);
|
||||
return script ? parsedAnimationIds(script) : new Set();
|
||||
}
|
||||
|
||||
// ── Selection API ────────────────────────────────────────────────────────────
|
||||
|
||||
selection(): SelectionProxy {
|
||||
|
||||
@@ -463,6 +463,13 @@ export interface Composition {
|
||||
getElements(): ElementSnapshot[];
|
||||
getElement(id: HfId): ElementSnapshot | null;
|
||||
find(query: FindQuery): string[];
|
||||
/**
|
||||
* Every GSAP tween id parsed from the composition's script, regardless of
|
||||
* whether its target selector currently matches a live DOM element. See
|
||||
* parsedAnimationIds in document.ts for why this differs from the
|
||||
* per-element animationIds on ElementSnapshot.
|
||||
*/
|
||||
getAllAnimationIds(): Set<string>;
|
||||
|
||||
// ── Selection API ──────────────────────────────────────────────────────────
|
||||
/** Sugar: resolves getSelection() → explicit ops at call time */
|
||||
|
||||
@@ -469,6 +469,12 @@ const GSAP_HTML = /* html */ `<!DOCTYPE html>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
|
||||
</body></html>`;
|
||||
|
||||
const GSAP_UNMATCHED_SELECTOR_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("#coral-band", { x: 100, duration: 1 }, 3);</script>
|
||||
</body></html>`;
|
||||
|
||||
describe("G. recordAnimationResolverParity", () => {
|
||||
it("emits animation_not_found when the SDK cannot resolve the animationId", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
@@ -495,6 +501,17 @@ describe("G. recordAnimationResolverParity", () => {
|
||||
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
||||
expect(trackedEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits nothing when the animationId only resolves via getAllAnimationIds (no live DOM match) — repro of the v0.7.31 false-positive", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(GSAP_UNMATCHED_SELECTOR_HTML);
|
||||
const unmatchedId = [...session.getAllAnimationIds()][0] ?? "";
|
||||
expect(unmatchedId).not.toBe("");
|
||||
// Confirms the bug this fixes: the id is NOT attached to any element.
|
||||
expect(session.getElements().some((el) => el.animationIds.includes(unmatchedId))).toBe(false);
|
||||
recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
|
||||
|
||||
@@ -459,8 +459,9 @@ export async function recordResolverParity(
|
||||
* dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
|
||||
* the animationId the server GSAP path is addressing — the GSAP-edit-surface
|
||||
* analogue of element_not_found. The SDK's resolvable animation ids are the
|
||||
* located ids attached to elements (buildAnimationIdMap), so a target absent
|
||||
* from every element's animationIds is a resolver divergence.
|
||||
* located ids attached to elements (buildAnimationIdMap) OR any id parsed from
|
||||
* the script regardless of DOM match (getAllAnimationIds) — a target absent
|
||||
* from both is a resolver divergence.
|
||||
*
|
||||
* No-op when the shadow flag is off; never throws; never mutates the session.
|
||||
*/
|
||||
@@ -474,7 +475,9 @@ export function recordAnimationResolverParity(
|
||||
try {
|
||||
recordAttempt(opLabel);
|
||||
const elements = session.getElements();
|
||||
const resolves = elements.some((el) => el.animationIds.includes(animationId));
|
||||
const resolves =
|
||||
elements.some((el) => el.animationIds.includes(animationId)) ||
|
||||
session.getAllAnimationIds().has(animationId);
|
||||
if (resolves) return; // SDK locates the animation — parity
|
||||
trackStudioEvent("sdk_resolver_shadow", {
|
||||
animationId,
|
||||
|
||||
Reference in New Issue
Block a user