From 6e32142334251d39a4b0c26ff26f3a52fbb59fa6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 23:08:08 -0700 Subject: [PATCH] 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=""] (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) --- packages/sdk/src/engine/model.ts | 13 +++- packages/sdk/src/engine/mutate.gsap.test.ts | 82 +++++++++++++++++++++ packages/sdk/src/engine/mutate.ts | 34 ++++++++- packages/sdk/src/session.subcomp.test.ts | 56 ++++++++++++++ 4 files changed, 180 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index 2efae69eb..6c61b9e41 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -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; diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index c752c8cbd..771eff236 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -32,6 +32,17 @@ function fresh(script = GSAP_SCRIPT) { return parseMutable(makeHtml(script)); } +// A sub-composition host: data-hf-id="hf-host" (its own leaf id) AND +// data-composition-id="sub-1" (the id studio passes when targeting the root). +function freshSubComp(script = GSAP_SCRIPT) { + return parseMutable( + `
+
+ +
`.trim(), + ); +} + function getScript(parsed: ReturnType): string { const doc = serializeDocument(parsed); const m = / + + `); + const comp = await openComposition(html); + // Target the sub-comp ROOT by its composition id. + const animId = comp.addGsapTween("sub-1", { + method: "to", + duration: 0.3, + properties: { x: 200 }, + }); + // The tween is filed under the host's own data-hf-id (canonical form), so + // it surfaces on the host element snapshot. + const host = comp.getElement("hf-host"); + expect(host?.animationIds).toContain(animId); + }); +}); + // ─── 4. Override-set keys for scoped ids ────────────────────────────────────── describe("override-set — scoped id keys", () => {