diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index 4fe3f806d2..cbbdbfb5a8 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -168,6 +168,9 @@ export function applyPatchesToDocument( for (const patch of patches) { const p = parsePath(patch.path); if (!p) continue; + if (p.id && patch.path.startsWith("/elements/")) { + p.id = p.id.replace(/~1/g, "/").replace(/~0/g, "~"); + } applyOne(parsed, patch, p); } } diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts index 341cafe8e5..9337127383 100644 --- a/packages/sdk/src/session.subcomp.test.ts +++ b/packages/sdk/src/session.subcomp.test.ts @@ -18,6 +18,7 @@ import { resolveScoped, findById, isNewHostBoundary, bareId } from "./engine/mod import { parseMutable } from "./engine/model.js"; import { buildRoots, flatElements } from "./document.js"; import { openComposition } from "./session.js"; +import type { Composition, JsonPatchOp } from "./types.js"; // ─── Fixture helpers ────────────────────────────────────────────────────────── @@ -335,6 +336,81 @@ describe("dispatch — scoped target", () => { }); }); +describe("patch replay — scoped target", () => { + const html = inlinedHtml(` +
+
+

inside

+
+

outside

+
+ `); + const target = "hf-host/hf-leaf"; + const edits: Array<{ name: string; edit: (comp: Composition) => void }> = [ + { name: "style", edit: (comp) => comp.setStyle(target, { color: "red" }) }, + { name: "text", edit: (comp) => comp.setText(target, "edited") }, + { name: "attribute", edit: (comp) => comp.setAttribute(target, "title", "edited") }, + { + name: "timing", + edit: (comp) => comp.setTiming(target, { start: 1, duration: 2, trackIndex: 2 }), + }, + { name: "hold", edit: (comp) => comp.setHold(target, { start: 0, end: 1, fill: "freeze" }) }, + { name: "removal", edit: (comp) => comp.removeElement(target) }, + ]; + + it.each(edits)( + "replays emitted $name patches against the same nested element", + async ({ edit }) => { + const edited = await openComposition(html); + const patches: JsonPatchOp[] = []; + edited.on("patch", (event) => patches.push(...event.patches)); + edit(edited); + expect(patches.some((patch) => patch.path.includes("hf-host~1hf-leaf"))).toBe(true); + const replayed = await openComposition(html); + replayed.applyPatches(patches); + + expect(replayed.serialize()).toBe(edited.serialize()); + expect(replayed.getElement("hf-leaf")?.text).toBe("outside"); + }, + ); + + it.each(edits)("restores scoped $name overrides when reopening", async ({ edit }) => { + const edited = await openComposition(html); + edit(edited); + const reopened = await openComposition(html, { overrides: edited.getOverrides() }); + + expect(reopened.serialize()).toBe(edited.serialize()); + expect(reopened.getElement("hf-leaf")?.text).toBe("outside"); + }); + + it("restores scoped edits when a batch rolls back", async () => { + const comp = await openComposition(html); + const original = comp.serialize(); + + expect(() => + comp.batch(() => { + comp.setText(target, "temporary"); + comp.setStyle(target, { color: "red" }); + throw new Error("cancel batch"); + }), + ).toThrow("cancel batch"); + expect(comp.serialize()).toBe(original); + }); + + it("decodes a literal ~1 only once", async () => { + const source = inlinedHtml('

text

'); + const comp = await openComposition(source); + const patches: JsonPatchOp[] = []; + comp.on("patch", (event) => patches.push(...event.patches)); + comp.setAttribute("hf-literal~1", "title", "changed"); + expect(patches[0]?.path).toBe("/elements/hf-literal~01/attributes/title"); + const replayed = await openComposition(source); + replayed.applyPatches(patches); + + expect(replayed.serialize()).toBe(comp.serialize()); + }); +}); + // ─── 3b. Comp-root GSAP tween attribution ───────────────────────────────────── describe("sub-comp root GSAP tween — canonical hf-id attribution", () => {