diff --git a/docs/sdk/guides/embedded-override-mode.mdx b/docs/sdk/guides/embedded-override-mode.mdx index def6fdee25..f120c01e34 100644 --- a/docs/sdk/guides/embedded-override-mode.mdx +++ b/docs/sdk/guides/embedded-override-mode.mdx @@ -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, "
Available today
"); +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`). diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index c8f40b2c38..bc1a8003fd 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -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 RecordAvailable today
"); + 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, "Available today
"); + 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, "Original
"); + 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 = + 'In
Keep
"); + const temporary = comp.addElement("hf-stage", 1, "Temporary
"); + 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, "Keep
"); + 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 = `${id}
`).join("")}${id}
`, + 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 = + 'Start A middle B end C.
'; + const comp = await openComposition(html, { coalesceMs: 0 }); + const temporary = withTemporary ? comp.addElement("hf-stage", 0, "Temporary") : 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: 'Original
', + 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 ─────────────────────────────────────────── diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 273a86c34a..20a92b0729 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -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);