Skip to content

Commit 4e3ece3

Browse files
vanceingallsclaude
andcommitted
fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify
Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 60db1fc commit 4e3ece3

4 files changed

Lines changed: 89 additions & 19 deletions

File tree

packages/sdk/src/document.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { parseHTML } from "linkedom";
1212
import { ensureHfIds } from "@hyperframes/core/hf-ids";
13-
import { getElementStyles } from "./engine/model.js";
13+
import { findRoot, getElementStyles } from "./engine/model.js";
1414
import type { HyperFramesElement, SdkDocument } from "./types.js";
1515

1616
// Tags that carry no editable content and must not enter the element tree.
@@ -25,6 +25,9 @@ const EXCLUDED_TAGS = new Set([
2525
"head",
2626
]);
2727

28+
// Snapshot text is TRIMMED for display (markup indentation produces noisy
29+
// whitespace text nodes). setText writes verbatim — engine getOwnText/setOwnText
30+
// operate on raw text. el.text is a display value, not a round-trip identity.
2831
function ownText(el: Element): string | null {
2932
let text = "";
3033
el.childNodes.forEach((n) => {
@@ -105,21 +108,26 @@ function extractStyles(doc: Document): string | null {
105108
return styleEl ? styleEl.textContent : null;
106109
}
107110

111+
// Root resolution delegates to the engine's findRoot so dimension extraction
112+
// and mutations agree on which element is the composition root.
108113
// fallow-ignore-next-line complexity
109114
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
110-
const stage = doc.getElementById("stage") ?? doc.querySelector("[data-hf-root]");
115+
const stage = findRoot(doc);
111116
if (!stage) return { width: null, height: null };
117+
// data-width/data-height are the runtime's forced override — prefer them.
118+
const wAttr = stage.getAttribute("data-width");
119+
const hAttr = stage.getAttribute("data-height");
112120
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
113121
const wm = /width:\s*(\d+)px/.exec(style);
114122
const hm = /height:\s*(\d+)px/.exec(style);
115123
return {
116-
width: wm ? parseInt(wm[1] ?? "", 10) : null,
117-
height: hm ? parseInt(hm[1] ?? "", 10) : null,
124+
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
125+
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
118126
};
119127
}
120128

121129
function extractDuration(doc: Document): number | null {
122-
const root = doc.querySelector("[data-hf-root]") ?? doc.body;
130+
const root = findRoot(doc) ?? doc.body;
123131
const dur = root?.getAttribute("data-duration");
124132
return dur ? parseFloat(dur) : null;
125133
}

packages/sdk/src/history.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,13 @@ export function createHistory(session: Composition, opts: HistoryOptions = {}):
6262
.join("\n");
6363
}
6464

65+
function opTypesKey(opTypes: readonly string[]): string {
66+
// Sorted: the same op-type SET coalesces regardless of dispatch order.
67+
return [...opTypes].sort().join(",");
68+
}
69+
6570
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
66-
if (entry.opTypes.join(",") !== incoming.opTypes.join(",")) return false;
71+
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
6772
if (entry.origin !== incoming.origin) return false;
6873
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
6974
// property). Without this, rapid edits to different elements would merge

packages/sdk/src/session.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,39 @@ describe("override-set replay on open", () => {
102102
expect(comp.getOverrides()).toEqual(overrides);
103103
});
104104
});
105+
106+
// ─── batch() transactional rollback ───────────────────────────────────────────
107+
108+
describe("batch rollback on throw", () => {
109+
it("reverts DOM mutations and override-set when the callback throws", async () => {
110+
const comp = await openComposition(BASE_HTML);
111+
const htmlBefore = comp.serialize();
112+
113+
expect(() =>
114+
comp.batch(() => {
115+
comp.setStyle("hf-title", { color: "#e63946" });
116+
comp.setText("hf-sub", "changed");
117+
throw new Error("user cancelled");
118+
}),
119+
).toThrowError("user cancelled");
120+
121+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
122+
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
123+
expect(comp.serialize()).toBe(htmlBefore);
124+
expect(comp.getOverrides()).toEqual({});
125+
});
126+
127+
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
128+
const comp = await openComposition(BASE_HTML);
129+
try {
130+
comp.batch(() => {
131+
comp.setStyle("hf-title", { color: "#e63946" });
132+
throw new Error("boom");
133+
});
134+
} catch {
135+
// expected
136+
}
137+
comp.undo();
138+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
139+
});
140+
});

packages/sdk/src/session.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ class CompositionImpl implements Composition {
7777
private batchInverse: JsonPatchOp[] = [];
7878
private batchOpTypes: string[] = [];
7979
private batchOrigin: unknown = ORIGIN_LOCAL;
80+
/** Override-set state at outermost batch entry — restored if the batch throws. */
81+
private batchOverridesSnapshot: OverrideSet = {};
8082

8183
constructor(parsed: ParsedDocument, opts: OpenCompositionOptions) {
8284
this.parsed = parsed;
@@ -237,6 +239,11 @@ class CompositionImpl implements Composition {
237239

238240
/**
239241
* Coalesce multiple dispatches into one undo entry / one patch event.
242+
*
243+
* Transactional: if the callback throws, all DOM mutations applied so far
244+
* are reverted (accumulated inverse patches replayed in reverse) and the
245+
* override-set is restored — the model is exactly as it was at batch entry.
246+
*
240247
* Note: a batch that produces no effective mutations still fires 'change'
241248
* handlers (parity with no-op dispatch) — subscribers must not assume
242249
* silence when wrapping speculative operations.
@@ -245,7 +252,10 @@ class CompositionImpl implements Composition {
245252
batch(fn: () => void, opts?: { origin?: unknown }): void {
246253
const origin = opts?.origin ?? ORIGIN_LOCAL;
247254
this.batchDepth++;
248-
if (this.batchDepth === 1) this.batchOrigin = origin; // only set on outermost entry
255+
if (this.batchDepth === 1) {
256+
this.batchOrigin = origin; // only set on outermost entry
257+
this.batchOverridesSnapshot = { ...this.overrides };
258+
}
249259
let threw = false;
250260
try {
251261
fn();
@@ -262,24 +272,34 @@ class CompositionImpl implements Composition {
262272
this.batchOrigin,
263273
this.batchOpTypes,
264274
);
265-
this.batchForward = [];
266-
this.batchInverse = [];
267-
this.batchOpTypes = [];
268-
this.batchOrigin = ORIGIN_LOCAL;
275+
this.resetBatchState();
269276
this.patchHandlers.forEach((h) => h(event));
270277
this.changeHandlers.forEach((h) => h());
271278
} else {
272-
// threw OR empty: always reset; fire changeHandlers for no-op (parity with dispatch)
273-
this.batchForward = [];
274-
this.batchInverse = [];
275-
this.batchOpTypes = [];
276-
this.batchOrigin = ORIGIN_LOCAL;
279+
if (threw && this.batchInverse.length > 0) {
280+
// Roll back: the dispatches inside the batch already mutated the
281+
// DOM. Without this, a throwing batch would leave the model in a
282+
// partial state with no patch trail to undo it.
283+
applyPatchesToDocument(this.parsed, [...this.batchInverse].reverse());
284+
this.overrides = { ...this.batchOverridesSnapshot };
285+
this.elementsCache = null;
286+
}
287+
this.resetBatchState();
288+
// Empty no-op batch: fire changeHandlers (parity with dispatch)
277289
if (!threw) this.changeHandlers.forEach((h) => h());
278290
}
279291
}
280292
}
281293
}
282294

295+
private resetBatchState(): void {
296+
this.batchForward = [];
297+
this.batchInverse = [];
298+
this.batchOpTypes = [];
299+
this.batchOrigin = ORIGIN_LOCAL;
300+
this.batchOverridesSnapshot = {};
301+
}
302+
283303
can(op: EditOp): boolean {
284304
return validateOp(this.parsed, op);
285305
}
@@ -339,9 +359,10 @@ class CompositionImpl implements Composition {
339359
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void {
340360
const origin = opts?.origin ?? ORIGIN_APPLY_PATCHES;
341361

342-
// Compute inverse of the incoming patches (apply-then-revert = restore)
343-
// For applyPatches the "inverse" is the current state before application.
344-
// Emit a patch event so history and subscribers stay in sync.
362+
// The emitted PatchEvent carries an EMPTY inversePatches array — hosts
363+
// maintaining an external inverse log must compute inverses from their own
364+
// state; applyPatches events never enter history (origin-guarded).
365+
// Emit a patch event so subscribers stay in sync.
345366
applyPatchesToDocument(this.parsed, patches);
346367
this.elementsCache = null;
347368

0 commit comments

Comments
 (0)