mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user