Skip to content

Commit 60db1fc

Browse files
vanceingallsclaude
andcommitted
fix(sdk): coalesce history by patch paths; replay override-set on open
Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0da0766 commit 60db1fc

10 files changed

Lines changed: 220 additions & 20 deletions

File tree

packages/sdk/examples/headless-agent.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,26 @@ export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): P
111111

112112
const textEls = comp.find({ tag: "div" });
113113

114+
// Phase 3b feature-detect: addGsapTween throws UnsupportedOpError until the
115+
// parser-backed engine lands — skip animation rather than crash the job.
116+
const probeTween = {
117+
method: "from",
118+
position: 0,
119+
duration: 0.5,
120+
ease: "power3.out",
121+
fromProperties: { opacity: 0, y: 30 },
122+
} as const;
123+
const first = textEls[0];
124+
if (
125+
!first ||
126+
!comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween })
127+
) {
128+
return comp.serialize();
129+
}
130+
114131
comp.batch(() => {
115132
textEls.forEach((id, i) => {
116-
comp.addGsapTween(id, {
117-
method: "from",
118-
position: i * staggerDelay,
119-
duration: 0.5,
120-
ease: "power3.out",
121-
fromProperties: { opacity: 0, y: 30 },
122-
});
133+
comp.addGsapTween(id, { ...probeTween, position: i * staggerDelay });
123134
});
124135
});
125136

packages/sdk/examples/react-embed.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,23 @@ export function onClipDrag(comp: Composition, id: string, start: number, duratio
8787

8888
// ── GSAP animation panel ──────────────────────────────────────────────────────
8989

90-
export function addBounceIn(comp: Composition, targetId: string): string {
91-
return comp.addGsapTween(targetId, {
90+
// Phase 3b: GSAP ops throw UnsupportedOpError until the parser-backed engine
91+
// lands — feature-detect with can() and disable the panel control if false.
92+
93+
export function addBounceIn(comp: Composition, targetId: string): string | null {
94+
const tween = {
9295
method: "from",
9396
position: 0,
9497
duration: 0.5,
9598
ease: "bounce.out",
9699
fromProperties: { y: 40, opacity: 0 },
97-
});
100+
} as const;
101+
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
102+
return comp.addGsapTween(targetId, tween);
98103
}
99104

100105
export function updateEase(comp: Composition, animationId: string, ease: string): void {
106+
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return;
101107
comp.setGsapTween(animationId, { ease });
102108
}
103109

packages/sdk/examples/vanilla-editor.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,29 +102,36 @@ export function applyOpFromJson(comp: Composition, opJson: unknown): void {
102102

103103
// ── GSAP operations ───────────────────────────────────────────────────────────
104104

105-
export function addFadeIn(comp: Composition, targetId: string, delay = 0): string {
106-
return comp.addGsapTween(targetId, {
105+
// NOTE (Phase 3b): GSAP ops require the parser-backed engine and throw
106+
// UnsupportedOpError until it lands. Feature-detect with can() first.
107+
108+
export function addFadeIn(comp: Composition, targetId: string, delay = 0): string | null {
109+
const tween: GsapTweenSpec = {
107110
method: "from",
108111
position: delay,
109112
duration: 0.4,
110113
ease: "power2.out",
111114
fromProperties: { opacity: 0 },
112-
});
115+
};
116+
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
117+
return comp.addGsapTween(targetId, tween);
113118
}
114119

115120
export function addBounce(
116121
comp: Composition,
117122
targetId: string,
118123
overrides?: Partial<GsapTweenSpec>,
119-
): string {
120-
return comp.addGsapTween(targetId, {
124+
): string | null {
125+
const tween: GsapTweenSpec = {
121126
method: "from",
122127
position: 0,
123128
duration: 0.6,
124129
ease: "bounce.out",
125130
fromProperties: { y: 60, opacity: 0 },
126131
...overrides,
127-
});
132+
};
133+
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
134+
return comp.addGsapTween(targetId, tween);
128135
}
129136

130137
// Keyframe editing (addGsapKeyframe / removeGsapKeyframe — v1, promoted 2026-06-09):

packages/sdk/src/engine/apply-patches.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
* unknown paths are silently ignored, matching the JsonPatchOp contract.
99
*/
1010

11-
import type { JsonPatchOp } from "../types.js";
11+
import type { JsonPatchOp, OverrideSet } from "../types.js";
1212
import type { ParsedDocument } from "./model.js";
1313
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
14+
import { keyToPath } from "./patches.js";
1415

1516
// ─── Path parser ────────────────────────────────────────────────────────────
1617

@@ -56,6 +57,25 @@ function parsePath(path: string): ParsedPath | null {
5657

5758
// ─── Patch application ───────────────────────────────────────────────────────
5859

60+
/**
61+
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
62+
* Property keys with null mean "restore base value" — a no-op on a fresh base.
63+
* Bare element keys with null are removal markers — the element is removed.
64+
*/
65+
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
66+
const patches: JsonPatchOp[] = [];
67+
for (const [key, value] of Object.entries(overrides)) {
68+
const path = keyToPath(key);
69+
if (!path) continue;
70+
if (value === null) {
71+
if (!key.includes(".")) patches.push({ op: "remove", path });
72+
continue;
73+
}
74+
patches.push({ op: "replace", path, value });
75+
}
76+
applyPatchesToDocument(parsed, patches);
77+
}
78+
5979
export function applyPatchesToDocument(
6080
parsed: ParsedDocument,
6181
patches: readonly JsonPatchOp[],

packages/sdk/src/engine/mutate.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ const PHASE3B_OPS = new Set([
5353
"removeLabel",
5454
]);
5555

56-
// Re-exported from the package entry in the next stacked PR (#1325).
57-
// fallow-ignore-next-line unused-export
5856
export class UnsupportedOpError extends Error {
57+
// Stable error code — part of the public API contract (F7); hosts switch on
58+
// err.code rather than the message.
59+
// fallow-ignore-next-line unused-class-member
5960
readonly code = "E_UNSUPPORTED_OP";
6061
constructor(opType: string) {
6162
super(

packages/sdk/src/engine/patches.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,38 @@ export function pathToKey(path: string): string | null {
103103
return null;
104104
}
105105

106+
/**
107+
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
108+
* Used to replay a stored override-set onto a fresh base document (T3 init).
109+
*/
110+
export function keyToPath(key: string): string | null {
111+
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
112+
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
113+
114+
const text = /^([^.]+)\.text$/.exec(key);
115+
if (text?.[1]) return textPath(text[1]);
116+
117+
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
118+
if (attr?.[1] && attr[2]) return attrPath(attr[1], attr[2]);
119+
120+
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
121+
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
122+
123+
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
124+
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
125+
126+
const variable = /^var\.(.+)$/.exec(key);
127+
if (variable?.[1]) return variablePath(variable[1]);
128+
129+
const meta = /^meta\.(width|height|duration)$/.exec(key);
130+
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
131+
132+
// Bare element id — removal marker key.
133+
if (!key.includes(".")) return elementPath(key);
134+
135+
return null;
136+
}
137+
106138
// ─── Patch event builder ──────────────────────────────────────────────────────
107139

108140
export function buildPatchEvent(

packages/sdk/src/history.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,21 @@ export function createHistory(session: Composition, opts: HistoryOptions = {}):
5555
return trackedOrigins.includes(origin);
5656
}
5757

58+
function pathsKey(patches: readonly JsonPatchOp[]): string {
59+
return patches
60+
.map((p) => p.path)
61+
.sort()
62+
.join("\n");
63+
}
64+
5865
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
5966
if (entry.opTypes.join(",") !== incoming.opTypes.join(",")) return false;
6067
if (entry.origin !== incoming.origin) return false;
68+
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
69+
// property). Without this, rapid edits to different elements would merge
70+
// into one entry holding the second forward + first inverse — undo would
71+
// then revert the wrong element.
72+
if (pathsKey(entry.patches) !== pathsKey(incoming.patches)) return false;
6173
const now = Date.now();
6274
return now - entry.timestamp <= coalesceMs;
6375
}

packages/sdk/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type {
1818

1919
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
2020

21+
export { UnsupportedOpError } from "./engine/mutate.js";
22+
2123
export { buildDocument, buildRoots, flatElements } from "./document.js";
2224

2325
export { openComposition } from "./session.js";

packages/sdk/src/session.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Session-level behavior: history coalescing invariants and T3 override replay.
3+
*/
4+
5+
import { describe, it, expect } from "vitest";
6+
import { openComposition } from "./session.js";
7+
8+
const BASE_HTML = `
9+
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
10+
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
11+
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
12+
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
13+
</div>
14+
`.trim();
15+
16+
// ─── History coalescing ───────────────────────────────────────────────────────
17+
18+
describe("history coalescing", () => {
19+
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
20+
const comp = await openComposition(BASE_HTML);
21+
comp.setStyle("hf-title", { color: "#111" });
22+
comp.setStyle("hf-title", { color: "#222" });
23+
comp.setStyle("hf-title", { color: "#333" });
24+
25+
comp.undo();
26+
const el = comp.getElement("hf-title");
27+
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
28+
});
29+
30+
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
31+
const comp = await openComposition(BASE_HTML);
32+
comp.setStyle("hf-title", { color: "#111" });
33+
comp.setStyle("hf-sub", { opacity: "1" });
34+
35+
comp.undo();
36+
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
37+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
38+
39+
comp.undo();
40+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
41+
});
42+
43+
it("rapid edits to different properties of the same element do not coalesce", async () => {
44+
const comp = await openComposition(BASE_HTML);
45+
comp.setStyle("hf-title", { color: "#111" });
46+
comp.setStyle("hf-title", { fontSize: "96px" });
47+
48+
comp.undo();
49+
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
50+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
51+
});
52+
});
53+
54+
// ─── T3 override replay ───────────────────────────────────────────────────────
55+
56+
describe("override-set replay on open", () => {
57+
it("applies style, text, and attribute overrides to the base document", async () => {
58+
const comp = await openComposition(BASE_HTML, {
59+
overrides: {
60+
"hf-title.style.color": "#e63946",
61+
"hf-title.text": "Edited headline",
62+
"hf-logo.attr.src": "/new-logo.png",
63+
},
64+
});
65+
66+
const title = comp.getElement("hf-title");
67+
expect(title?.inlineStyles["color"]).toBe("#e63946");
68+
expect(title?.text).toBe("Edited headline");
69+
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
70+
71+
const html = comp.serialize();
72+
expect(html).toContain("Edited headline");
73+
expect(html).toContain("/new-logo.png");
74+
expect(html).toContain("#e63946");
75+
});
76+
77+
it("applies timing overrides (computed absolute end)", async () => {
78+
const comp = await openComposition(BASE_HTML, {
79+
overrides: { "hf-title.timing.end": 4.5 },
80+
});
81+
expect(comp.serialize()).toContain('data-end="4.5"');
82+
});
83+
84+
it("removes elements marked with the null removal marker", async () => {
85+
const comp = await openComposition(BASE_HTML, {
86+
overrides: { "hf-sub": null },
87+
});
88+
expect(comp.getElement("hf-sub")).toBeNull();
89+
expect(comp.serialize()).not.toContain("subtitle");
90+
});
91+
92+
it("treats property-level null as restore-base (no-op on fresh base)", async () => {
93+
const comp = await openComposition(BASE_HTML, {
94+
overrides: { "hf-title.style.color": null },
95+
});
96+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
97+
});
98+
99+
it("getOverrides returns the set the session was opened with", async () => {
100+
const overrides = { "hf-title.style.color": "#e63946" };
101+
const comp = await openComposition(BASE_HTML, { overrides });
102+
expect(comp.getOverrides()).toEqual(overrides);
103+
});
104+
});

packages/sdk/src/session.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { parseMutable } from "./engine/model.js";
2929
import type { ParsedDocument } from "./engine/model.js";
3030
import { applyOp, validateOp } from "./engine/mutate.js";
3131
import { serializeDocument } from "./engine/serialize.js";
32-
import { applyPatchesToDocument } from "./engine/apply-patches.js";
32+
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
3333
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
3434
import { createHistory } from "./history.js";
3535
import type { HistoryModule } from "./history.js";
@@ -389,6 +389,11 @@ export async function openComposition(
389389
// Single parse: parseMutable stamps hf-ids + builds the live linkedom DOM;
390390
// the query API derives element snapshots from it lazily.
391391
const parsed = parseMutable(html);
392+
393+
// T3 embedded: replay the stored override-set onto the base in one pass,
394+
// so the session exposes the user's exact edited state — not the template.
395+
if (opts?.overrides) applyOverrideSet(parsed, opts.overrides);
396+
392397
const session = new CompositionImpl(parsed, opts ?? {});
393398

394399
const isEmbedded = opts?.overrides !== undefined;

0 commit comments

Comments
 (0)