Files
hyperframes/packages/sdk/src/history.ts
T
Vance IngallsandClaude Sonnet 4.6 a0ee97210b fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors (#1350)
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors

* test(sdk,ci): smoke test + explicit sdk-tests CI gate

Smoke test covers the full public surface:
  openComposition → setStyle/setText/dispatch(moveElement) → serialize
  applyPatches + ORIGIN_APPLY_PATCHES tagging
  batch() coalescing + transactional rollback on throw
  undo/redo round-trip
  persist adapter write + persist:error surfacing
  T3 embedded mode: override-set apply on open + getOverrides round-trip

Adds sdk-tests CI job so SDK coverage is explicitly named and required —
prevents a repeat of the demo-next vitest-never-ran incident.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): export adapter types, awaitable flush(), never-coalesce mode

- Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package
  root — callers can now write typed fakes without reaching into internals
- Add flush(): Promise<void> to Composition interface + CompositionImpl —
  app-close handlers can await a clean drain of the persist queue
- coalesceMs <= 0 disables coalescing entirely in createHistory — enables
  deterministic test scenarios without per-entry timestamp manipulation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke

- setText on element with no prior text node (firstTextIdx=-1 path)
- applyOverrideSet null removal on non-existent prop is a no-op (no throw)
- smoke persist test uses comp.flush() instead of setTimeout
- can() JSDoc clarifies Phase 3b false-return is intentional feature-detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: trigger regression suite

* fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy

bun install --frozen-lockfile fails in the regression Docker build because
the lockfile references the sdk workspace member but its package.json was
not copied into the image before the install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:07:49 -07:00

146 lines
4.6 KiB
TypeScript

/**
* Optional history module (F5 layering).
*
* Wires onto a Composition session via on('patch') and implements undo/redo.
* Coalesces same-op+same-targets bursts within coalesceMs into one undo entry.
*
* Usage (standalone / T1):
* const comp = await openComposition(html, { persist });
* // openComposition attaches this automatically in non-embedded mode.
*
* Usage (manual / custom undo timeline):
* const history = createHistory(comp, { coalesceMs: 500, trackedOrigins: ['local'] });
* // host calls history.undo() / history.redo() instead of comp.undo() / comp.redo()
*/
import type { Composition, JsonPatchOp, PatchEvent } from "./types.js";
import { ORIGIN_APPLY_PATCHES } from "./types.js";
export interface HistoryEntry {
readonly patches: readonly JsonPatchOp[];
readonly inversePatches: readonly JsonPatchOp[];
readonly opTypes: readonly string[];
readonly origin: unknown;
readonly timestamp: number;
}
export interface HistoryModule {
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
dispose(): void;
}
export interface HistoryOptions {
/** Only ops with these origins enter the undo stack. Default: all non-ORIGIN_APPLY_PATCHES. */
trackedOrigins?: unknown[];
/** Coalesce window in ms. Same opTypes + same origin within window → one entry. Default: 300. */
coalesceMs?: number;
/** Max undo stack depth. Default: 100. */
maxEntries?: number;
}
export function createHistory(session: Composition, opts: HistoryOptions = {}): HistoryModule {
const coalesceMs = opts.coalesceMs ?? 300;
const maxEntries = opts.maxEntries ?? 100;
const { trackedOrigins } = opts;
const undoStack: HistoryEntry[] = [];
let redoStack: HistoryEntry[] = [];
function isTracked(origin: unknown): boolean {
if (origin === ORIGIN_APPLY_PATCHES) return false;
if (!trackedOrigins) return true;
return trackedOrigins.includes(origin);
}
function pathsKey(patches: readonly JsonPatchOp[]): string {
return patches
.map((p) => p.path)
.sort()
.join("\n");
}
function opTypesKey(opTypes: readonly string[]): string {
// Sorted: the same op-type SET coalesces regardless of dispatch order.
return [...opTypes].sort().join(",");
}
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
if (coalesceMs <= 0) return false;
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
if (entry.origin !== incoming.origin) return false;
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
// property). Without this, rapid edits to different elements would merge
// into one entry holding the second forward + first inverse — undo would
// then revert the wrong element.
if (pathsKey(entry.patches) !== pathsKey(incoming.patches)) return false;
const now = Date.now();
return now - entry.timestamp <= coalesceMs;
}
// fallow-ignore-next-line complexity
const unsubscribe = session.on("patch", (event: PatchEvent) => {
if (!isTracked(event.origin)) return;
const last = undoStack[undoStack.length - 1];
if (last && shouldCoalesce(last, event)) {
// Coalesce: keep first inverse (original prev), replace forward with latest value.
// Slide timestamp forward so rapid-fire edits keep coalescing.
const coalesced: HistoryEntry = {
patches: event.patches,
inversePatches: last.inversePatches,
opTypes: last.opTypes,
origin: last.origin,
timestamp: Date.now(),
};
undoStack[undoStack.length - 1] = coalesced;
} else {
undoStack.push({
patches: event.patches,
inversePatches: event.inversePatches,
opTypes: event.opTypes,
origin: event.origin,
timestamp: Date.now(),
});
if (undoStack.length > maxEntries) undoStack.shift();
}
// Any new op clears the redo stack.
redoStack = [];
});
return {
undo(): boolean {
const entry = undoStack.pop();
if (!entry) return false;
session.applyPatches(entry.inversePatches, { origin: ORIGIN_APPLY_PATCHES });
redoStack.push(entry);
return true;
},
redo(): boolean {
const entry = redoStack.pop();
if (!entry) return false;
session.applyPatches(entry.patches, { origin: ORIGIN_APPLY_PATCHES });
undoStack.push(entry);
return true;
},
canUndo(): boolean {
return undoStack.length > 0;
},
canRedo(): boolean {
return redoStack.length > 0;
},
dispose(): void {
unsubscribe();
undoStack.length = 0;
redoStack.length = 0;
},
};
}