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