fix(sdk): restore saved edits for dotted element IDs (#6287)

* fix(sdk): restore saved edits for dotted element IDs

* fix(sdk): skip unknown saved override values
This commit is contained in:
Akshar Patel
2026-10-25 23:01:41 +00:00
committed by GitHub
parent 04df17cccb
commit 64c7bbf25d
4 changed files with 135 additions and 29 deletions
@@ -81,6 +81,11 @@ A `null` value is a removal marker. When the SDK serializes the composition it o
Sub-composition elements use scoped IDs — `"hf-host/hf-leaf.text"` — where the host and leaf are separated by `/`.
Periods in an authored ID stay part of that ID: editing `hf-title.main` produces
`"hf-title.main.text"`, which restores the edit to that same element when reopened.
Non-null whole-element values must be snapshots returned by `getOverrides()`. Opening a
delta skips scalar values and incomplete snapshots under whole-element keys.
### Font and image variable overrides
Variable overrides (from `setVariableValue`) live under the `var.{id}` key. Font and image values are objects, not strings:
+12 -2
View File
@@ -142,6 +142,12 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
for (const [key, value] of entries) {
const path = keyToPath(key);
if (!path) continue;
if (
value !== null &&
/^\/elements\/[^/]+$/.test(path) &&
!isElementSnapshotOverride(key, value)
)
continue;
if (value === null) {
patches.push({ op: "remove", path });
} else {
@@ -166,9 +172,13 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
export function isElementSnapshotOverride(
key: string,
value: OverrideSet[string],
): value is Record<string, unknown> & { siblingIndex: number } {
): value is Record<string, unknown> & { html: string; siblingIndex: number } {
if (value === null || typeof value !== "object") return false;
return /^\/elements\/[^/]+$/.test(keyToPath(key) ?? "") && typeof value.siblingIndex === "number";
return (
/^\/elements\/[^/]+$/.test(keyToPath(key) ?? "") &&
typeof value.html === "string" &&
typeof value.siblingIndex === "number"
);
}
function restoreOverridePositions(parsed: ParsedDocument, overrides: OverrideSet): void {
+35 -27
View File
@@ -148,31 +148,41 @@ export function pathToKey(path: string): string | null {
return null;
}
/**
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
* Used to replay a stored override-set onto a fresh base document (T3 init).
*/
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
// Exhaustive element-key dispatch keeps both ID grammars on the same path rules.
// fallow-ignore-next-line complexity
function elementKeyToPath(key: string, allowDots = false): string | null {
const separator = /\.(style|attr|timing|hold)\./.exec(key);
if (!separator) {
const id = key.endsWith(".text") ? key.slice(0, -5) : "";
return id && (allowDots || !id.includes(".")) ? textPath(id) : null;
}
const id = key.slice(0, separator.index);
const prop = key.slice(separator.index + separator[0].length);
if (!id || !prop || (!allowDots && id.includes("."))) return null;
switch (separator[1]) {
case "style":
return stylePath(id, prop);
case "attr":
// Attribute names are already RFC 6902-encoded by pathToKey.
return `/elements/${escapeIdForPath(id)}/attributes/${prop}`;
case "timing":
if (prop === "start" || prop === "end" || prop === "duration" || prop === "trackIndex")
return timingPath(id, prop);
return null;
case "hold":
if (prop === "start" || prop === "end" || prop === "fill") return holdPath(id, prop);
return null;
default:
return null;
}
}
/** Map a stored override key to its RFC 6902 path when reopening a base document. */
export function keyToPath(key: string): string | null {
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
const text = /^([^.]+)\.text$/.exec(key);
if (text?.[1]) return textPath(text[1]);
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
// the already-encoded attr segment. Reconstruct manually.
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key);
if (timing?.[1])
return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex");
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
const element = elementKeyToPath(key);
if (element) return element;
const varDecl = /^varDecl\.(.+)$/.exec(key);
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
@@ -186,10 +196,8 @@ export function keyToPath(key: string): string | null {
if (key === "script.gsap") return gsapScriptPath();
if (key === "style.css") return styleSheetPath();
// Bare element id — removal marker key.
if (!key.includes(".")) return elementPath(key);
return null;
// Existing namespaces keep precedence over the dotted-ID fallback.
return elementKeyToPath(key, true) ?? elementPath(key);
}
// ─── Patch event builder ──────────────────────────────────────────────────────
+83
View File
@@ -138,6 +138,89 @@ describe("history coalescing", () => {
// ─── T3 override replay ───────────────────────────────────────────────────────
describe("override-set replay on open", () => {
it.each([
{ key: "hf-title.timing.bogus", value: 2 },
{ key: "meta.fps", value: 30 },
{ key: "style.foo", value: "x" },
{ key: "future.element", value: { html: '<p data-hf-id="hf-extra">Extra</p>' } },
{ key: "future.element", value: { siblingIndex: 0 } },
])("ignores an unrecognized non-snapshot override: $key", async ({ key, value }) => {
const base = await openComposition(BASE_HTML);
const reopened = await openComposition(BASE_HTML, { overrides: { [key]: value } });
expect(reopened.serialize()).toBe(base.serialize());
});
it.each([false, true])("replays edits with dotted IDs, scoped: %s", async (scoped) => {
const title = '<h1 data-hf-id="hf-title.main" style="color:red" title="Original">Original</h1>';
const html = `<div data-hf-id="hf-stage" data-hf-root>${
scoped
? `<div data-hf-id="hf-host.one" data-composition-file="scene.html">${title}</div>`
: title
}</div>`;
const id = scoped ? "hf-host.one/hf-title.main" : "hf-title.main";
const comp = await openComposition(html, { overrides: {} });
comp.setText(id, "Updated");
comp.setStyle(id, { color: "blue" });
comp.setAttribute(id, "title", null);
comp.setAttribute(id, "data-app.attr.label", "Saved");
comp.setTiming(id, { start: 1, duration: 2 });
comp.setHold(id, { start: 0.5, end: 0.25, fill: "freeze" });
const reopened = await openComposition(html, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.getElement(id)?.text).toBe("Updated");
expect(reopened.getElement(id)?.inlineStyles["color"]).toBe("blue");
expect(reopened.getElement(id)?.attributes["title"]).toBeUndefined();
expect(reopened.getElement(id)?.attributes["data-app.attr.label"]).toBe("Saved");
expect(reopened.getElement(id)?.attributes).toMatchObject({
"data-hold-start": "0.5",
"data-hold-end": "0.25",
"data-hold-fill": "freeze",
});
expect(reopened.getElementTimings()[id]).toMatchObject({ enterAt: 1, exitAt: 3 });
expect(reopened.serialize()).toBe(comp.serialize());
});
it("replays a removal marker for a dotted element ID", async () => {
const html = '<div data-hf-root><p data-hf-id="hf-copy.main">Remove me</p></div>';
const comp = await openComposition(html, { overrides: {} });
comp.removeElement("hf-copy.main");
const reopened = await openComposition(html, {
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
});
expect(reopened.getElement("hf-copy.main")).toBeNull();
expect(reopened.serialize()).toBe(comp.serialize());
});
it("keeps edit-family words inside an attribute name", async () => {
const comp = await openComposition(BASE_HTML, { overrides: {} });
comp.setAttribute("hf-title", "data-app.style.color", "Saved");
comp.setAttribute("hf-title", "data-app.text", "Label");
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
expect(reopened.getElement("hf-title")?.attributes["data-app.style.color"]).toBe("Saved");
expect(reopened.getElement("hf-title")?.attributes["data-app.text"]).toBe("Label");
expect(reopened.serialize()).toBe(comp.serialize());
});
it("keeps ordinary element edits ahead of the variable namespace", async () => {
const html = '<div data-hf-root><p data-hf-id="var">Original</p></div>';
const comp = await openComposition(html, { overrides: {} });
comp.setText("var", "Updated");
comp.setStyle("var", { color: "blue" });
const reopened = await openComposition(html, { overrides: comp.getOverrides() });
expect(reopened.getElement("var")?.text).toBe("Updated");
expect(reopened.getElement("var")?.inlineStyles["color"]).toBe("blue");
});
it("applies style, text, and attribute overrides to the base document", async () => {
const comp = await openComposition(BASE_HTML, {
overrides: {