feat(sdk): stage 6 — sub-composition scoped ids (F9) (#1434)

* feat(sdk): stage 6 — sub-composition scoped ids (F9)

Adds fully-qualified scoped ids for addressing elements inside inlined
sub-compositions, so callers can target "hf-HOST/hf-LEAF" unambiguously
even when bare hf-ids collide across sub-composition boundaries.

Changes:
- model.ts: resolveScoped() traverses id segments through nested subtrees;
  isNewHostBoundary() detects host boundaries (dcf ≠ parent dcf handles
  outerHTML innerRoot edge case)
- types.ts: HyperFramesElement gains scopedId field
- document.ts: buildElement carries scopePrefix, propagates childPrefix
  at host boundaries; buildRoots starts with ""
- patches.ts: RFC 6902 escapeIdForPath / decodePathSegment for scoped ids
  containing "/"; all path builders and pathToKey/keyToPath updated
- session.ts: getElement() matches by scopedId; find() returns scopedIds;
  orphan cleanup decodes RFC 6902 before key comparison, preserves removal
  markers, purges property sub-keys for both bare and scoped ids
- mutate.ts: all element handlers use resolveScoped instead of findById;
  handleRemoveElement collects full subtree hf-ids before removal for
  complete GSAP animation cascade (Q3 fix); validateOp uses resolveScoped

20 new contract tests in session.subcomp.test.ts covering resolveScoped,
scopedId propagation, dispatch to scoped targets, RFC 6902 patch encoding,
override-set key format, orphan purge, and serialize stability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk): add find({ composition }) filter — Stage 6 WS-C completion

Closes the last headless-testable Stage 6 gap (F9 workstream C).

`find({ composition: "hf-host" })` returns all scopedIds whose prefix
matches the given host id — i.e. every element mounted inside that
sub-composition, at any depth. Combinable with other FindQuery fields
(tag, text, name, track). 3 new contract tests in session.subcomp.test.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): addGsapTween resolves scoped id to bare leaf; validateOp checks target exists

- handleAddGsapTween: strip host prefix for scoped ids (hf-host/hf-leaf →
  selector [data-hf-id="hf-leaf"]) — DOM element carries only the leaf part
- validateOp addGsapTween: call resolveScoped to surface E_TARGET_NOT_FOUND
  before the GSAP script checks (previously can() returned ok for missing targets)
- patches.ts pathToKey: remove dead ?? null (decodePathSegment never returns undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-15 02:21:36 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent f10f3425a5
commit b158870d8f
7 changed files with 560 additions and 39 deletions
+15 -4
View File
@@ -10,7 +10,7 @@
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { findRoot, getElementStyles } from "./engine/model.js";
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js";
// Tags that carry no editable content and must not enter the element tree.
@@ -38,13 +38,23 @@ function ownText(el: Element): string | null {
}
// fallow-ignore-next-line complexity
function buildElement(el: Element): HyperFramesElement | null {
function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null {
const tag = el.tagName.toLowerCase();
if (EXCLUDED_TAGS.has(tag)) return null;
const id = el.getAttribute("data-hf-id") ?? "";
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
// scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/".
// The host element itself is in the PARENT scope (no prefix change for its own id).
const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id;
// Children inherit the scope prefix from their parent.
// If this element is a new host boundary (starts a new sub-comp scope), its
// children use THIS element's scopedId as their prefix.
// Otherwise, children inherit the same prefix that this element used.
const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix;
const inlineStyles = getElementStyles(el);
const classAttr = el.getAttribute("class") ?? "";
@@ -72,12 +82,13 @@ function buildElement(el: Element): HyperFramesElement | null {
const children: HyperFramesElement[] = [];
for (const child of Array.from(el.children)) {
const built = buildElement(child);
const built = buildElement(child, childPrefix);
if (built) children.push(built);
}
return {
id,
scopedId,
tag,
children,
inlineStyles,
@@ -142,7 +153,7 @@ export function buildRoots(document: Document): HyperFramesElement[] {
const roots: HyperFramesElement[] = [];
if (body) {
for (const child of Array.from(body.children)) {
const built = buildElement(child);
const built = buildElement(child, "");
if (built) roots.push(built);
}
}
+43
View File
@@ -34,6 +34,49 @@ export function findById(document: Document, id: string): Element | null {
return document.querySelector(`[data-hf-id="${escaped}"]`);
}
function escapeHfId(id: string): string {
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Resolve a bare or scoped hf-id to its DOM element.
*
* Bare id ("hf-x"): equivalent to findById — top-level document search.
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
* into the subtree of the previous match. This unambiguously addresses an
* element inside a sub-composition even when bare ids collide.
*/
export function resolveScoped(document: Document, id: string): Element | null {
const parts = id.split("/");
let context: Element | Document = document;
for (const part of parts) {
const escaped = escapeHfId(part);
const found: Element | null =
context === document
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
if (!found) return null;
context = found;
}
return context as Element;
}
/**
* Returns true when this element starts a new sub-composition scope — i.e. it
* is a host element (has data-composition-file) and is NOT the outerHTML
* innerRoot of the SAME sub-composition (same dcf value as parent).
*
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
*/
export function isNewHostBoundary(el: Element): boolean {
const dcf = el.getAttribute("data-composition-file");
if (!dcf) return false;
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
return dcf !== parentDcf;
}
export function findRoot(document: Document): Element | null {
return (
document.querySelector("[data-hf-root]") ??
+45 -14
View File
@@ -10,7 +10,7 @@
import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
import type { ParsedDocument } from "./model.js";
import {
findById,
resolveScoped,
findRoot,
getElementStyles,
setElementStyles,
@@ -194,7 +194,7 @@ function handleSetStyle(
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const old = getElementStyles(el);
setElementStyles(el, styles);
@@ -234,7 +234,7 @@ function handleMoveElement(
function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldText = getOwnText(el);
setOwnText(el, value);
@@ -259,7 +259,7 @@ function handleSetAttribute(
validateSetAttribute(name, value);
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldValue = el.getAttribute(name);
const path = attrPath(id, name);
@@ -293,7 +293,7 @@ function handleSetTiming(
let currentScript = origScript;
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldStartStr = el.getAttribute("data-start");
@@ -373,7 +373,7 @@ function handleSetHold(
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const fields: Array<["start" | "end" | "fill", string]> = [
@@ -401,20 +401,28 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
let currentScript = origScript;
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const parentEl = el.parentElement;
const parentId = parentEl?.getAttribute("data-hf-id") ?? null;
const siblingIndex = getSiblingIndex(el);
const html = el.outerHTML;
// Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade
// removes animations targeting any sub-composition element, not just the host.
const subtreeIds = collectSubtreeHfIds(el);
el.remove();
const path = elementPath(id);
result.forward.push(patchRemove(path));
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
if (currentScript) {
for (const subtreeId of subtreeIds) {
currentScript = cascadeRemoveAnimations(currentScript, subtreeId);
}
}
}
if (origScript && currentScript && currentScript !== origScript) {
@@ -509,10 +517,24 @@ function selectorMatchesId(selector: string, id: HfId): boolean {
);
}
// v1 limitation: uses bare-id matching across the whole script, so a selector targeting
// "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf" and any other
// element whose scoped or bare id matches "hf-leaf". Acceptable for typical single-comp
// use; sub-composition authors with leaf-id collisions should use fully-qualified selectors.
// v1 limitation: selectorMatchesId uses bare-id matching across the whole script, so a
// selector targeting "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf"
// and any other element whose scoped or bare id matches "hf-leaf". Acceptable for typical
// single-comp use; sub-composition authors with leaf-id collisions should use
// fully-qualified selectors.
/** Collect all bare data-hf-id values from el and all its descendants. */
function collectSubtreeHfIds(el: Element): string[] {
const ids: string[] = [];
const own = el.getAttribute("data-hf-id");
if (own) ids.push(own);
for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) {
const id = child.getAttribute("data-hf-id");
if (id) ids.push(id);
}
return ids;
}
function cascadeRemoveAnimations(script: string, id: HfId): string {
const parsedGsap = parseGsapScriptAcornForWrite(script);
if (!parsedGsap) return script;
@@ -576,8 +598,11 @@ function handleAddGsapTween(
? ((tween.toProperties ?? {}) as Record<string, number | string>)
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
// Scoped ids like "hf-host/hf-leaf" must use the bare leaf id in the GSAP
// selector — only the leaf part is written as data-hf-id on the DOM element.
const bareTarget = target.includes("/") ? (target.split("/").at(-1) ?? target) : target;
const animation: Omit<GsapAnimation, "id"> = {
targetSelector: `[data-hf-id="${target}"]`,
targetSelector: `[data-hf-id="${bareTarget}"]`,
method: tween.method,
position: tween.position ?? 0,
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
@@ -753,7 +778,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "removeElement": {
const ids = targets(op.target);
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
const missing = ids.filter((id) => findById(parsed.document, id) === null);
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
if (missing.length > 0)
return canErr(
"E_TARGET_NOT_FOUND",
@@ -771,6 +796,12 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
return CAN_OK;
case "addGsapTween":
case "addLabel": {
if (op.type === "addGsapTween" && resolveScoped(parsed.document, op.target) === null)
return canErr(
"E_TARGET_NOT_FOUND",
`Element not found: ${op.target}.`,
"Verify the id against comp.getElements() or comp.find().",
);
const script = getGsapScript(parsed.document);
if (!script)
return canErr(
+33 -16
View File
@@ -30,30 +30,45 @@ import type { JsonPatchOp, PatchEvent } from "../types.js";
// ─── Path builders ────────────────────────────────────────────────────────────
/**
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
*/
function escapeIdForPath(id: string): string {
return id.replace(/~/g, "~0").replace(/\//g, "~1");
}
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
function decodePathSegment(segment: string): string {
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function stylePath(id: string, prop: string): string {
return `/elements/${id}/inlineStyles/${prop}`;
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
}
export function textPath(id: string): string {
return `/elements/${id}/text`;
return `/elements/${escapeIdForPath(id)}/text`;
}
export function attrPath(id: string, name: string): string {
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${id}/attributes/${escaped}`;
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
}
export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string {
return `/elements/${id}/timing/${field}`;
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
}
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
return `/elements/${id}/hold/${field}`;
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
}
export function elementPath(id: string): string {
return `/elements/${id}`;
return `/elements/${escapeIdForPath(id)}`;
}
export function variablePath(id: string): string {
@@ -80,29 +95,30 @@ export function styleSheetPath(): string {
*/
export function pathToKey(path: string): string | null {
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`;
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
// /elements/{id}/text → "{id}.text"
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
if (textMatch) return `${textMatch[1]}.text`;
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`;
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;
// /elements/{id}/timing/{field} → "{id}.timing.{field}"
// Note: field "end" maps to the computed data-end attribute value.
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
if (timingMatch) return `${timingMatch[1]}.timing.${timingMatch[2]}`;
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`;
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
// /elements/{id} (whole element) → "{id}"
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
if (elemMatch) return elemMatch[1] ?? null;
if (elemMatch) return decodePathSegment(elemMatch[1]!);
// /variables/{id} → "var.{id}"
const varMatch = /^\/variables\/(.+)$/.exec(path);
@@ -133,9 +149,10 @@ export function keyToPath(key: string): string | null {
if (text?.[1]) return textPath(text[1]);
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
// the already-encoded attr segment. Reconstruct manually.
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
+395
View File
@@ -0,0 +1,395 @@
/**
* T-contract: sub-composition scoped id suite (Stage 6 / F9).
*
* All tests use pre-inlined HTML (flat DOM with data-composition-file boundaries)
* because the SDK only opens pre-inlined HTML — sub-comp loading is not the SDK's job.
*
* Boundary detection rule: an element is a host (starts a new scope) when it has
* data-composition-file AND its value differs from its parent's data-composition-file.
* This correctly handles the outerHTML innerRoot case (same dcf as parent → not a new host)
* and nested hosts (different dcf from parent → new host).
*/
import { describe, it, expect } from "vitest";
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { resolveScoped } from "./engine/model.js";
import { parseMutable } from "./engine/model.js";
import { buildRoots, flatElements } from "./document.js";
import { openComposition } from "./session.js";
// ─── Fixture helpers ──────────────────────────────────────────────────────────
/** Build a flat inlined HTML string simulating what inlineSubCompositions produces. */
function inlinedHtml(inner: string): string {
return `<!DOCTYPE html><html><body>${inner}</body></html>`;
}
/** Stamp hf-ids and return a linkedom document (same as parseMutable's path). */
function makeDoc(html: string) {
const { document } = parseHTML(ensureHfIds(html));
return document;
}
// ─── 1. resolveScoped ─────────────────────────────────────────────────────────
describe("resolveScoped — flat id", () => {
it("resolves a bare id at top level (same as findById)", () => {
const doc = makeDoc(
`<!DOCTYPE html><html><body><div data-hf-id="hf-aaaa">hi</div></body></html>`,
);
const el = resolveScoped(doc as unknown as Document, "hf-aaaa");
expect(el).not.toBeNull();
expect(el?.getAttribute("data-hf-id")).toBe("hf-aaaa");
});
it("returns null for a missing bare id", () => {
const doc = makeDoc(
`<!DOCTYPE html><html><body><div data-hf-id="hf-aaaa"></div></body></html>`,
);
expect(resolveScoped(doc as unknown as Document, "hf-xxxx")).toBeNull();
});
});
describe("resolveScoped — scoped id", () => {
it("resolves hf-HOST/hf-LEAF inside the host's subtree", () => {
// Simulated post-inline structure: host has data-composition-file
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
`),
);
const el = resolveScoped(doc as unknown as Document, "hf-host/hf-leaf");
expect(el?.getAttribute("data-hf-id")).toBe("hf-leaf");
expect(el?.textContent?.trim()).toBe("text");
});
it("does NOT match a leaf outside the host when ids collide", () => {
// Two elements with the same hf-id — one inside host, one outside.
// resolveScoped must return the one INSIDE the host.
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-dup" class="inside">inside</p>
</div>
<p data-hf-id="hf-dup" class="outside">outside</p>
`),
);
const el = resolveScoped(doc as unknown as Document, "hf-host/hf-dup");
expect(el?.getAttribute("class")).toBe("inside");
});
it("resolves 3-level nesting hf-H1/hf-H2/hf-leaf", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-h1" data-composition-file="sub1.html">
<div data-hf-id="hf-h2" data-composition-file="sub2.html">
<span data-hf-id="hf-leaf">deep</span>
</div>
</div>
`),
);
const el = resolveScoped(doc as unknown as Document, "hf-h1/hf-h2/hf-leaf");
expect(el?.getAttribute("data-hf-id")).toBe("hf-leaf");
expect(el?.textContent?.trim()).toBe("deep");
});
it("returns null when the first segment is not found", () => {
const doc = makeDoc(
inlinedHtml(`<div data-hf-id="hf-other"><p data-hf-id="hf-leaf"></p></div>`),
);
expect(resolveScoped(doc as unknown as Document, "hf-host/hf-leaf")).toBeNull();
});
it("returns null when the leaf is not found inside the host", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-other">text</p>
</div>
`),
);
expect(resolveScoped(doc as unknown as Document, "hf-host/hf-leaf")).toBeNull();
});
});
// ─── 2. ElementSnapshot.scopedId via buildRoots ───────────────────────────────
describe("ElementSnapshot.scopedId", () => {
it("top-level element has scopedId equal to its bare id", () => {
const parsed = parseMutable(
`<div data-hf-id="hf-root" data-hf-root><p data-hf-id="hf-p">hi</p></div>`,
);
const elements = flatElements(buildRoots(parsed.document));
const p = elements.find((e) => e.id === "hf-p");
expect(p?.scopedId).toBe("hf-p");
});
it("element inside sub-comp gets hf-HOST/hf-LEAF scopedId", () => {
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const leaf = elements.find((e) => e.id === "hf-leaf");
expect(leaf?.scopedId).toBe("hf-host/hf-leaf");
});
it("host element itself has bare scopedId (it lives in parent scope)", () => {
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const host = elements.find((e) => e.id === "hf-host");
expect(host?.scopedId).toBe("hf-host");
});
it("3-level nesting produces hf-H1/hf-H2/hf-LEAF", () => {
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-h1" data-composition-file="sub1.html">
<div data-hf-id="hf-h2" data-composition-file="sub2.html">
<span data-hf-id="hf-leaf">deep</span>
</div>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const leaf = elements.find((e) => e.id === "hf-leaf");
expect(leaf?.scopedId).toBe("hf-h1/hf-h2/hf-leaf");
});
it("same sub-comp mounted twice gets different scopedIds", () => {
// hf-x exists in both mounts — different host ids disambiguate
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-mount-a" data-composition-file="sub.html">
<p data-hf-id="hf-x" class="in-a">A</p>
</div>
<div data-hf-id="hf-mount-b" data-composition-file="sub.html">
<p data-hf-id="hf-x" class="in-b">B</p>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const xs = elements.filter((e) => e.id === "hf-x");
const scopedIds = xs.map((e) => e.scopedId);
expect(scopedIds).toContain("hf-mount-a/hf-x");
expect(scopedIds).toContain("hf-mount-b/hf-x");
expect(new Set(scopedIds).size).toBe(2);
});
it("outerHTML innerRoot (same dcf as parent) is NOT itself a new host boundary", () => {
// outerHTML case: host and innerRoot both get data-composition-file="sub.html"
const parsed = parseMutable(
inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<div data-hf-id="hf-inner" data-composition-id="my-sub" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
</div>
`),
);
const elements = flatElements(buildRoots(parsed.document));
const leaf = elements.find((e) => e.id === "hf-leaf");
// Leaf should be scoped under hf-host, not hf-host/hf-inner
expect(leaf?.scopedId).toBe("hf-host/hf-leaf");
});
});
// ─── 3. Dispatch to scoped target ─────────────────────────────────────────────
describe("dispatch — scoped target", () => {
it("setStyle with scoped id mutates the correct element when id collides", async () => {
// Both host subtree and sibling have an element hf-x — scoped target must hit the right one
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-x">inside</p>
</div>
<p data-hf-id="hf-x">outside</p>
</div>
`);
const comp = await openComposition(html);
comp.setStyle("hf-host/hf-x", { color: "red" });
const inside = comp.getElement("hf-host/hf-x");
const outside = comp.getElement("hf-x");
expect(inside?.inlineStyles.color).toBe("red");
// Outside element should be unchanged
expect(outside?.inlineStyles.color).toBeUndefined();
});
it("dispatch emits scoped id in patch path", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`);
const comp = await openComposition(html);
const patches: string[] = [];
comp.on("patch", (e) => {
patches.push(...e.patches.map((p) => p.path));
});
comp.setStyle("hf-host/hf-leaf", { color: "blue" });
// Patch path should encode the scoped id with RFC 6902 escaping (/ → ~1)
expect(patches.some((p) => p.includes("hf-host~1hf-leaf"))).toBe(true);
});
it("getElement by scopedId returns the correct snapshot", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside text</p>
</div>
</div>
`);
const comp = await openComposition(html);
const el = comp.getElement("hf-host/hf-leaf");
expect(el).not.toBeNull();
expect(el?.text).toBe("inside text");
});
it("find() returns scopedIds for sub-comp elements", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf" class="target">inside</p>
</div>
<p data-hf-id="hf-outer" class="target">outside</p>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ tag: "p" });
expect(ids).toContain("hf-host/hf-leaf");
expect(ids).toContain("hf-outer");
});
});
// ─── 4. Override-set keys for scoped ids ──────────────────────────────────────
describe("override-set — scoped id keys", () => {
it("setStyle on scoped id produces scoped key in getOverrides()", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`);
const comp = await openComposition(html);
comp.setStyle("hf-host/hf-leaf", { color: "green" });
const overrides = comp.getOverrides();
expect(overrides["hf-host/hf-leaf.style.color"]).toBe("green");
});
it("removeElement on host purges all sub-comp keys from override-set", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
</div>
`);
const comp = await openComposition(html);
comp.setStyle("hf-host/hf-leaf", { color: "green" });
comp.removeElement("hf-host");
const overrides = comp.getOverrides();
// Removal marker for host is preserved (null); scoped property sub-keys are purged
expect(overrides["hf-host"]).toBeNull();
expect(
Object.keys(overrides).some((k) => k.startsWith("hf-host/") || k.startsWith("hf-host.")),
).toBe(false);
});
});
// ─── 5. find({ composition }) filter ─────────────────────────────────────────
describe("find({ composition })", () => {
it("returns elements inside the named host sub-composition", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside</p>
</div>
<p data-hf-id="hf-outer">outside</p>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ composition: "hf-host" });
expect(ids).toContain("hf-host/hf-leaf");
expect(ids).not.toContain("hf-outer");
expect(ids).not.toContain("hf-host"); // host itself is in parent scope
});
it("returns empty array for unknown host id", async () => {
const html = inlinedHtml(
`<div data-hf-id="hf-root" data-hf-root><p data-hf-id="hf-p">x</p></div>`,
);
const comp = await openComposition(html);
expect(comp.find({ composition: "hf-no-such" })).toEqual([]);
});
it("can combine composition filter with other query fields", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-a">match</p>
<span data-hf-id="hf-b">no</span>
</div>
</div>
`);
const comp = await openComposition(html);
const ids = comp.find({ composition: "hf-host", tag: "p" });
expect(ids).toEqual(["hf-host/hf-a"]);
});
});
// ─── 6. Scoped id stability across serialize ──────────────────────────────────
describe("scopedId stability across serialize/re-parse", () => {
it("scopedId values are identical after serialize + re-open", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">text</p>
</div>
<p data-hf-id="hf-outer">outer</p>
</div>
`);
const comp1 = await openComposition(html);
const serialized = comp1.serialize();
const comp2 = await openComposition(serialized);
const ids1 = comp1
.getElements()
.map((e) => e.scopedId)
.sort();
const ids2 = comp2
.getElements()
.map((e) => e.scopedId)
.sort();
expect(ids1).toEqual(ids2);
});
});
+19 -5
View File
@@ -167,7 +167,14 @@ class CompositionImpl implements Composition {
}
getElement(id: HfId): ElementSnapshot | null {
return this.getElements().find((el) => el.id === id) ?? null;
// Accept both bare ids (top-level) and scoped ids (sub-composition elements).
// Match by scopedId first (canonical); bare-id fallback keeps top-level compat
// for callers that don't yet use scoped ids.
return (
this.getElements().find((el) => el.scopedId === id) ??
this.getElements().find((el) => el.id === id && el.scopedId === el.id) ??
null
);
}
find(query: FindQuery): string[] {
@@ -179,9 +186,10 @@ class CompositionImpl implements Composition {
if (query.text && !el.text?.includes(query.text)) return false;
if (query.name && el.attributes["data-name"] !== query.name) return false;
if (query.track !== undefined && el.trackIndex !== query.track) return false;
if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false;
return true;
})
.map((el) => el.id)
.map((el) => el.scopedId)
);
}
@@ -248,13 +256,19 @@ class CompositionImpl implements Composition {
// Purge orphan property keys for removed elements so the override-set stays
// compact and a future T3 session doesn't replay stale properties onto a
// non-existent element.
// non-existent element. Override-set keys use decoded scoped ids ("hf-host/hf-leaf")
// while path segments use RFC 6902 encoding ("hf-host~1hf-leaf") — decode before compare.
for (const p of forward) {
const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
if (p.op === "remove" && elemMatch) {
const id = elemMatch[1]!;
// Decode RFC 6902 escaping: ~1 → /, ~0 → ~
const id = elemMatch[1]!.replace(/~1/g, "/").replace(/~0/g, "~");
for (const key of Object.keys(this.overrides)) {
if (key.startsWith(`${id}.`)) delete this.overrides[key];
// Purge property sub-keys (e.g. "hf-x.style.color") but preserve
// the removal marker itself (key === id, set to null in the loop above).
if (key.startsWith(`${id}.`) || key.startsWith(`${id}/`)) {
delete this.overrides[key];
}
}
}
}
+10
View File
@@ -3,6 +3,14 @@
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
export interface HyperFramesElement {
readonly id: string;
/**
* Fully-qualified scoped id — host-chain prefix + leaf, separated by "/".
* For top-level elements: scopedId === id.
* For elements inside inlined sub-compositions: "hf-HOST/hf-LEAF" (any depth).
* This is the canonical identifier to use in dispatch targets, getElement(),
* find(), and override-set keys when addressing sub-composition elements.
*/
readonly scopedId: string;
readonly tag: string;
readonly children: readonly HyperFramesElement[];
/** camelCase property names — mirrors CSSStyleDeclaration convention */
@@ -178,6 +186,8 @@ export interface FindQuery {
text?: string;
name?: string;
track?: number;
/** Filter to elements inside a specific sub-composition host (by host hf-id). */
composition?: string;
}
// ─── Typed method sugar (F10) ─────────────────────────────────────────────────