fix(sdk): keep added elements when reopening saved overrides (#6231)

* fix(sdk): keep added elements when reopening saved overrides

* fix(sdk): replay saved overrides in latest edit order

* fix(sdk): preserve saved positions after sibling edits

* test(sdk): cover child edits after base parent restoration
This commit is contained in:
Akshar Patel
2026-10-25 00:28:49 +00:00
committed by GitHub
parent 6a917780f5
commit 31e6ed2aed
4 changed files with 293 additions and 8 deletions
+23 -2
View File
@@ -9,7 +9,7 @@ This is exactly how a canvas embedder (for example, AI Studio) persists in-compo
## Opening with overrides
Pass the stored delta as `overrides` when you call `openComposition`. The SDK replays the override set onto the base template in one pass, so the session exposes the user's exact edited state immediately:
Pass the stored delta as `overrides` when you call `openComposition`. The SDK applies the saved changes to the base template, so the session exposes the user's exact edited state immediately:
```typescript
import { openComposition } from "@hyperframes/sdk";
@@ -31,7 +31,7 @@ After `openComposition` returns, `comp.getElements()` reflects the overridden st
## Reading the current delta
Call `getOverrides()` to retrieve the current delta for storage. It returns a shallow copy of the internal override set — safe to serialize and stash:
Call `getOverrides()` to retrieve the current delta for storage. It returns a copy ready to serialize and save:
```typescript
comp.setText("hf-cta", "Get started free");
@@ -45,6 +45,26 @@ await db.saveOverrides(sessionId, nextOverrides);
On the next session open, pass `nextOverrides` as `overrides` again — the user sees their exact state.
Elements inserted with `addElement()` are stored in the delta too, including their
parent and sibling position. Later text, style, or attribute edits replay onto the
restored element:
```typescript
const id = comp.addElement("hf-stage", 1, "<p>Available today</p>");
comp.setText(id, "Available worldwide");
const reopened = await openComposition(templateHtml, {
overrides: comp.getOverrides(),
});
reopened.getElement(id)?.text; // "Available worldwide"
```
`getOverrides()` saves each added or restored element's current position among
all child nodes, including surrounding text. Removing a temporary sibling does
not shift the element when reopened.
Store the returned set without sorting its keys. The SDK keeps the latest edit
order so later child edits apply after their parent's snapshot.
## Override set key format
Keys follow the pattern `hfId.prop.path`. The common forms are:
@@ -54,6 +74,7 @@ Keys follow the pattern `hfId.prop.path`. The common forms are:
| `"hf-title.text"` | Inner text of element `hf-title` |
| `"hf-logo.attr.src"` | `src` attribute on `hf-logo` |
| `"hf-x.style.fontSize"` | Inline `fontSize` style on `hf-x` |
| `"hf-card"` with an element snapshot | Added or restored element, with its HTML, parent, and sibling position |
| `"hf-card"` with value `null` | Removal marker — element was deleted |
A `null` value is a removal marker. When the SDK serializes the composition it omits that element entirely. This lets the host distinguish "never touched" (key absent) from "user deleted" (key present, value `null`).
+29 -1
View File
@@ -160,6 +160,33 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
}
}
applyPatchesToDocument(parsed, patches, { keepScriptTargets: true });
restoreOverridePositions(parsed, overrides);
}
export function isElementSnapshotOverride(
key: string,
value: OverrideSet[string],
): value is Record<string, unknown> & { siblingIndex: number } {
if (value === null || typeof value !== "object") return false;
return /^\/elements\/[^/]+$/.test(keyToPath(key) ?? "") && typeof value.siblingIndex === "number";
}
function restoreOverridePositions(parsed: ParsedDocument, overrides: OverrideSet): void {
const positions: Array<{ el: Element; parent: Element; childNodeIndex: number }> = [];
for (const [key, value] of Object.entries(overrides)) {
if (!isElementSnapshotOverride(key, value) || typeof value.childNodeIndex !== "number")
continue;
const el = findById(parsed.document, key);
if (!el?.parentElement) continue;
positions.push({ el, parent: el.parentElement, childNodeIndex: value.childNodeIndex });
}
// Detach every restored sibling so inserting one cannot shift another's saved position.
for (const { el } of positions) el.remove();
for (const { el, parent, childNodeIndex } of positions.sort(
(a, b) => a.childNodeIndex - b.childNodeIndex,
)) {
parent.insertBefore(el, parent.childNodes[childNodeIndex] ?? null);
}
}
/** Keep a deleted node a script looks up: serialize writes it back hidden, so the script finds it. */
@@ -271,7 +298,7 @@ function applyOne(
if (el && options.keepScriptTargets && scriptLooksUp(parsed.document, el))
keepDelete(parsed, el);
el?.remove();
} else if (patch.op === "add" && patch.value) {
} else if ((patch.op === "add" || patch.op === "replace") && patch.value) {
const v = patch.value as { html: string; parentId: string | null; siblingIndex: number };
const parent = v.parentId
? findById(parsed.document, v.parentId)
@@ -282,6 +309,7 @@ function applyOne(
tmp.innerHTML = v.html;
const node = tmp.firstElementChild;
if (!node) return;
if (patch.op === "replace") findById(parsed.document, p.id)?.remove();
const children = Array.from(parent.children);
const ref = children[v.siblingIndex] ?? null;
parent.insertBefore(node, ref);
+215
View File
@@ -196,6 +196,221 @@ describe("override-set replay on open", () => {
const comp = await openComposition(BASE_HTML, { overrides });
expect(comp.getOverrides()).toEqual(overrides);
});
it("restores added elements and their later edits from the stored override set", async () => {
const comp = await openComposition(BASE_HTML, { overrides: {} });
const id = comp.addElement("hf-stage", 1, "<p>Available today</p>");
comp.setText(id, "Available worldwide");
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
expect(reopened.getElement(id)?.text).toBe("Available worldwide");
expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([
"hf-title",
id,
"hf-sub",
"hf-logo",
]);
expect(reopened.serialize()).toBe(comp.serialize());
});
it("replaces the base element once when reopening an undone removal", async () => {
const comp = await openComposition(BASE_HTML);
comp.setText("hf-title", "Edited headline");
comp.removeElement("hf-title");
comp.undo();
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
expect(reopened.getElement("hf-title")?.text).toBe("Edited headline");
expect(reopened.getElements().filter((element) => element.id === "hf-title")).toHaveLength(1);
expect(reopened.serialize()).toBe(comp.serialize());
});
it("keeps a restored base element after siblings removed before its latest snapshot", async () => {
const comp = await openComposition(BASE_HTML);
comp.removeElement("hf-logo");
comp.undo();
comp.removeElement("hf-title");
comp.removeElement("hf-logo");
comp.undo();
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([
"hf-sub",
"hf-logo",
]);
expect(reopened.serialize()).toBe(comp.serialize());
});
it("keeps an added element's position after removing a sibling and undoing its removal", async () => {
const comp = await openComposition(BASE_HTML);
const id = comp.addElement("hf-stage", 2, "<p>Available today</p>");
comp.removeElement("hf-title");
comp.removeElement(id);
comp.undo();
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([
"hf-sub",
id,
"hf-logo",
]);
expect(reopened.serialize()).toBe(comp.serialize());
});
it("keeps child edits made after restoring an added parent", async () => {
const comp = await openComposition(BASE_HTML);
const parent = comp.addElement("hf-stage", 1, "<section>Details</section>");
const child = comp.addElement(parent, 0, "<p>Original</p>");
comp.setText(child, "Before removal");
comp.removeElement(parent);
comp.undo();
comp.setText(child, "After restore");
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
expect(reopened.getElement(child)?.text).toBe("After restore");
expect(reopened.getElements().filter((element) => element.id === child)).toHaveLength(1);
expect(reopened.serialize()).toBe(comp.serialize());
});
it.each(["session", "host"] as const)(
"keeps later %s edits to a child after restoring its base parent",
async (origin) => {
const html =
'<div data-hf-id="hf-stage" data-hf-root><section data-hf-id="hf-box"><p data-hf-id="hf-in">In</p></section></div>';
const comp = await openComposition(html, { coalesceMs: 0 });
comp.setText("hf-in", "Before removal");
comp.removeElement("hf-box");
comp.undo();
if (origin === "session") comp.setText("hf-in", "After restore");
else
comp.applyPatches([
{ op: "replace", path: "/elements/hf-in/text", value: "After restore" },
]);
const reopened = await openComposition(html, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.getElement("hf-in")?.text).toBe("After restore");
expect(reopened.serialize()).toBe(comp.serialize());
},
);
it.each(["base", "added"] as const)(
"keeps a restored %s element's position after removing a temporary sibling",
async (kind) => {
const comp = await openComposition(BASE_HTML, { coalesceMs: 0 });
const id = kind === "base" ? "hf-sub" : comp.addElement("hf-stage", 1, "<p>Keep</p>");
const temporary = comp.addElement("hf-stage", 1, "<p>Temporary</p>");
comp.removeElement(id);
comp.undo();
comp.removeElement(temporary);
const reopened = await openComposition(BASE_HTML, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual(
kind === "base" ? ["hf-title", "hf-sub", "hf-logo"] : ["hf-title", id, "hf-sub", "hf-logo"],
);
expect(reopened.serialize()).toBe(comp.serialize());
},
);
it("positions an added element after replaying later sibling removals", async () => {
const comp = await openComposition(BASE_HTML);
const id = comp.addElement("hf-stage", 2, "<p>Keep</p>");
comp.removeElement("hf-title");
const reopened = await openComposition(BASE_HTML, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([
"hf-sub",
id,
"hf-logo",
]);
expect(reopened.serialize()).toBe(comp.serialize());
});
it("restores several saved positions without shifting a previously restored sibling", async () => {
const ids = ["hf-c", "hf-b", "hf-first", "hf-last", "hf-a"];
const html = `<div data-hf-id="hf-stage" data-hf-root>${ids.map((id) => `<p data-hf-id="${id}">${id}</p>`).join("")}</div>`;
const comp = await openComposition(html);
comp.applyPatches([
...["hf-c", "hf-b", "hf-a"].map((id) => ({ op: "remove", path: `/elements/${id}` })),
...["hf-a", "hf-b", "hf-c"].map((id, index) => ({
op: "add",
path: `/elements/${id}`,
value: {
html: `<p data-hf-id="${id}">${id}</p>`,
parentId: "hf-stage",
siblingIndex: index + 1,
},
})),
]);
const reopened = await openComposition(html, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([
"hf-first",
"hf-a",
"hf-b",
"hf-c",
"hf-last",
]);
expect(reopened.serialize()).toBe(comp.serialize());
});
it.each([false, true])(
"keeps text around restored inline elements, with temporary sibling: %s",
async (withTemporary) => {
const html =
'<p data-hf-id="hf-stage" data-hf-root>Start <b data-hf-id="hf-a">A</b> middle <i data-hf-id="hf-b">B</i> end <span data-hf-id="hf-c">C</span>.</p>';
const comp = await openComposition(html, { coalesceMs: 0 });
const temporary = withTemporary ? comp.addElement("hf-stage", 0, "<u>Temporary</u>") : null;
comp.removeElement("hf-a");
comp.undo();
comp.removeElement("hf-b");
comp.undo();
if (temporary) comp.removeElement(temporary);
const reopened = await openComposition(html, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.serialize()).toBe(comp.serialize());
},
);
it("replays element snapshots with only an element sibling index", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: {
"hf-added": {
html: '<p data-hf-id="hf-added">Original</p>',
parentId: "hf-stage",
siblingIndex: 1,
},
"hf-added.text": "Updated",
},
});
expect(comp.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([
"hf-title",
"hf-added",
"hf-sub",
"hf-logo",
]);
expect(comp.getElement("hf-added")?.text).toBe("Updated");
});
});
// ─── batch() transactional rollback ───────────────────────────────────────────
+26 -5
View File
@@ -36,7 +36,12 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScripts, resolveScoped, declarationCarriers } from "./engine/model.js";
import {
getGsapScripts,
resolveScoped,
declarationCarriers,
getSiblingIndex,
} from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
import { readClipTiming, type ClipTiming } from "@hyperframes/core/composition-contract";
@@ -48,7 +53,11 @@ import {
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
import { readVariableDeclarations } from "./engine/variableModel.js";
import { serializeDocument } from "./engine/serialize.js";
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
import {
applyPatchesToDocument,
applyOverrideSet,
isElementSnapshotOverride,
} from "./engine/apply-patches.js";
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
import { createHistory } from "./history.js";
import type { HistoryModule } from "./history.js";
@@ -646,6 +655,7 @@ class CompositionImpl implements Composition {
for (const p of forward) {
const key = pathToKey(p.path);
if (key !== null) {
delete this.overrides[key];
this.overrides[key] =
p.op === "remove"
? null
@@ -822,7 +832,19 @@ class CompositionImpl implements Composition {
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
getOverrides(): OverrideSet {
return { ...this.overrides };
const overrides = { ...this.overrides };
for (const [key, value] of Object.entries(overrides)) {
if (!isElementSnapshotOverride(key, value)) continue;
const el = resolveScoped(this.parsed.document, key);
if (el?.parentElement) {
overrides[key] = {
...value,
siblingIndex: getSiblingIndex(el),
childNodeIndex: Array.from(el.parentElement.childNodes).indexOf(el),
};
}
}
return overrides;
}
// fallow-ignore-next-line complexity
@@ -841,6 +863,7 @@ class CompositionImpl implements Composition {
for (const p of patches) {
const key = pathToKey(p.path);
if (key !== null) {
delete this.overrides[key];
this.overrides[key] =
p.op === "remove"
? null
@@ -906,8 +929,6 @@ export async function openComposition(
// serves them for the rest of the session (undo-to-base restores).
const baseVariableDefaults = declaredDefaults(parsed);
// T3 embedded: replay the stored override-set onto the base in one pass,
// so the session exposes the user's exact edited state — not the template.
if (opts?.overrides) applyOverrideSet(parsed, opts.overrides);
const session = new CompositionImpl(parsed, opts ?? {}, baseVariableDefaults);