From 65b06cd341bbdad76aac23bff6e64768e2ce2a52 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 01:58:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20stage=206=20=E2=80=94=20sub-compos?= =?UTF-8?q?ition=20scoped=20ids=20(F9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/sdk/src/document.ts | 19 +- packages/sdk/src/engine/model.ts | 43 +++ packages/sdk/src/engine/mutate.ts | 38 ++- packages/sdk/src/engine/patches.ts | 49 +++- packages/sdk/src/session.subcomp.test.ts | 353 +++++++++++++++++++++++ packages/sdk/src/session.ts | 23 +- packages/sdk/src/types.ts | 8 + 7 files changed, 499 insertions(+), 34 deletions(-) create mode 100644 packages/sdk/src/session.subcomp.test.ts diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index 0bfdbaa8e..76774c0ae 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -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); } } diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index 74f80240c..683be1668 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -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]") ?? diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 7bb4637be..912778c2c 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -11,7 +11,7 @@ import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js"; import type { ParsedDocument } from "./model.js"; import { - findById, + resolveScoped, findRoot, getElementStyles, setElementStyles, @@ -195,7 +195,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); @@ -235,7 +235,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); @@ -260,7 +260,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,6 +517,18 @@ function selectorMatchesId(selector: string, id: HfId): boolean { ); } +/** 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; @@ -749,7 +769,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", diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index e21a0b06f..ae0fe68a2 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -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]!) ?? null; // /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"); diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts new file mode 100644 index 000000000..f66fcb6a9 --- /dev/null +++ b/packages/sdk/src/session.subcomp.test.ts @@ -0,0 +1,353 @@ +/** + * 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 `${inner}`; +} + +/** 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( + `
hi
`, + ); + 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( + `
`, + ); + 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(` +
+

text

+
+ `), + ); + 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(` +
+

inside

+
+

outside

+ `), + ); + 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(` +
+
+ deep +
+
+ `), + ); + 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(`

`), + ); + 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(` +
+

text

+
+ `), + ); + 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( + `

hi

`, + ); + 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(` +
+
+

text

+
+
+ `), + ); + 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(` +
+
+

text

+
+
+ `), + ); + 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(` +
+
+
+ deep +
+
+
+ `), + ); + 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(` +
+
+

A

+
+
+

B

+
+
+ `), + ); + 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(` +
+
+
+

text

+
+
+
+ `), + ); + 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(` +
+
+

inside

+
+

outside

+
+ `); + 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(` +
+
+

text

+
+
+ `); + 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(` +
+
+

inside text

+
+
+ `); + 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(` +
+
+

inside

+
+

outside

+
+ `); + 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(` +
+
+

text

+
+
+ `); + 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(` +
+
+

text

+
+
+ `); + 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. 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(` +
+
+

text

+
+

outer

+
+ `); + 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); + }); +}); diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 730597f3a..0cacc3450 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -164,7 +164,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[] { @@ -178,7 +185,7 @@ class CompositionImpl implements Composition { if (query.track !== undefined && el.trackIndex !== query.track) return false; return true; }) - .map((el) => el.id) + .map((el) => el.scopedId) ); } @@ -238,13 +245,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]; + } } } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index a1ca05d3d..429c74f35 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -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 */