fix(sdk): resolve composition-id targets + emit canonical data-hf-id for GSAP tweens (#1526)

A sub-composition ROOT is addressed by its data-composition-id, but the SDK's
whole element<->tween attribution is data-hf-id based, so the prior fix's
[data-composition-id] selector was invisible to three readers (validateOp/can,
selectorMatchesId -> setTiming + removeElement cascade, buildAnimationIdMap ->
getElement.animationIds), diverging can from apply and orphaning tweens.

Root fix: make composition ids first-class resolvable addresses and emit the
canonical selector everywhere.

- resolveScoped (model.ts): for a bare id with no data-hf-id match, fall back to
  [data-composition-id]. data-hf-id keeps precedence; scoped-path and canonical
  behavior intact. Fixes validateOp gating, findById/getElement, and every op
  handler for comp-root targets in one place.
- gsapTargetSelector (mutate.ts): resolve the target and emit
  [data-hf-id="<resolved host hf-id>"] (canonical). Normal targets unchanged;
  comp-root targets resolve via comp-id -> host -> host hf-id. Defensive
  [data-composition-id] only when the resolved element has no hf-id.
- setTiming syncs the GSAP tween via the resolved element's data-hf-id so a
  comp-root target matches its host tween; removeElement cascade already covers
  the host hf-id via collectSubtreeHfIds.
- export escapeHfId; escape both the querySelector probe and the emitted
  selector string.

Tests: comp-id resolveScoped fallback + precedence (session.subcomp), canonical
selector, validateOp accept, setTiming sync, removeElement cascade, and
getElement.animationIds for comp-root tweens (mutate.gsap). The prior test only
called applyOp, masking all of this.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-16 23:08:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9cc3550f7e
commit 6e32142334
4 changed files with 180 additions and 5 deletions
+10 -3
View File
@@ -37,7 +37,7 @@ export function findById(document: Document, id: string): Element | null {
return resolveScoped(document, id);
}
function escapeHfId(id: string): string {
export function escapeHfId(id: string): string {
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
@@ -76,8 +76,15 @@ export function resolveScoped(document: Document, id: string): Element | null {
if (parts.length === 1) {
const escaped = escapeHfId(id);
const matches = Array.from(document.querySelectorAll(`[data-hf-id="${escaped}"]`));
if (matches.length === 0) return null;
return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null;
if (matches.length > 0) {
return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null;
}
// Fall back to a sub-composition ROOT addressed by its composition id. A
// host element carries data-hf-id (its own leaf id) AND data-composition-id
// (the id studio passes when targeting the sub-comp root). data-hf-id takes
// precedence above; only when no hf-id matches do we treat the bare id as a
// composition id, making comp-ids first-class resolvable addresses.
return document.querySelector(`[data-composition-id="${escaped}"]`);
}
let context: Element | Document = document;