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:
Vance Ingalls
2026-07-05 20:11:49 -07:00
committed by GitHub
parent dfa6fedbcd
commit c5d725abd0
6 changed files with 88 additions and 4 deletions
+14
View File
@@ -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,
+38
View File
@@ -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);
});
});
+6 -1
View File
@@ -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 {
+7
View File
@@ -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 */