Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/packages/sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ comp.setVariableValue("brandColor", "#6C5CE7");
comp.removeElement("hf-old-caption");
```

`setStyle()` preserves all declarations of untouched properties in their original order, including `!important` priorities and browser fallbacks such as `display:-moz-box !important;display:block`. Setting a property explicitly replaces all of its declarations with the new value and priority; passing `null` removes them. When the edited property has repeated declarations, undo and batch rollback restore the original sequence while retaining unrelated edits made afterward. These inverse patches use `formatVersion: 2` and carry a `restoreStyleProperties` list with the original `style` attribute. Store the operations intact and replay them through `applyPatches()` so host-managed history preserves unrelated properties. Other patch events remain version `1`.

Use `batch()` when several mutations should become one undo entry, one persist write, and one change notification:

```typescript
Expand Down
90 changes: 68 additions & 22 deletions docs/sdk/guides/undo-redo-and-patches.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Undo, Redo & Patches"
description: "Use the SDK's built-in undo stack, subscribe to patch events, and integrate with a host application's own history."
---

The SDK ships undo/redo by default for standalone sessions and emits RFC 6902 JSON patches on every change. Patch events are the integration seam for host application history, collaborative editing, audit logs, and embedded override mode.
The SDK ships undo/redo by default for standalone sessions and emits versioned patch events on every change. Hosts use these events for their own history, collaborative editing, audit logs, and embedded override mode.

## Built-in Undo and Redo

Expand Down Expand Up @@ -65,8 +65,10 @@ Every committed change emits a `patch` event. Subscribe to mirror SDK edits into

```typescript
const off = comp.on("patch", ({ formatVersion, patches, inversePatches, origin, opTypes }) => {
if (formatVersion !== 1) throw new Error("Unexpected patch format version");
saveToDB({ patches, inversePatches, origin, opTypes });
if (formatVersion !== 1 && formatVersion !== 2) {
throw new Error("Unexpected patch format version");
}
saveToDB({ formatVersion, patches, inversePatches, origin, opTypes });
});

// Unsubscribe when done
Expand All @@ -75,8 +77,10 @@ off();

### PatchEvent fields

<ResponseField name="formatVersion" type="1">
Always `1` in the current SDK. Check this and reject unknown versions — a bump means a breaking change.
<ResponseField name="formatVersion" type="1 | 2">
Version `1` uses ordinary JSON patches. Version `2` adds selective style inverses to restore
repeated CSS declarations without reverting unrelated edits made afterward. Integrations that
support only version `1` must reject version `2` before processing its operations.
</ResponseField>
<ResponseField name="patches" type="JsonPatchOp[]">
Forward patches that were applied: the add/remove/replace ops describing how the document changed.
Expand All @@ -98,13 +102,38 @@ The SDK emits and accepts only the add/remove/replace subset of RFC 6902:
```typescript
interface JsonPatchOp {
op: "add" | "remove" | "replace";
path: string; // JSON Pointer (RFC 6901), e.g. "/elements/hf-title/style/color"
path: string; // JSON Pointer (RFC 6901), e.g. "/elements/hf-title/inlineStyles/color"
value?: unknown;
restoreStyleProperties?: readonly string[];
}
```

The SDK never emits `move`, `copy`, or `test` ops, and `applyPatches()` ignores them if passed.

For version `2`, keep `restoreStyleProperties` when storing inverse patches and pass the full
operations to `applyPatches()`. That SDK-specific list limits a style-attribute inverse to the
edited properties. A generic RFC 6902 applier does not implement these selective semantics.

### Upgrading a host from version 1

Upgrade the host's history consumer and replay SDK before adopting an SDK that emits version
`2`. A host that accepts only version `1` cannot safely consume every event from this SDK.
Rejecting an event in a listener does not undo the SDK mutation that already committed.

Existing version-`1` history records remain replayable without conversion. Keep their original
operations: a whole-style replacement still replaces the entire attribute. Previously discarded
declarations cannot be recovered from an old history record. Saved override objects use the same
format and need no migration.

If an older host stored only the two patch arrays, label those records version `1` only when
their source is known to be a version-1-only SDK. Do not infer version `1` from a missing tag in
new or unknown records.

For new history records, store `formatVersion` alongside both patch arrays, including every
`restoreStyleProperties` field. A session can emit versions `1`, `2`, then `1` again, so check
each event and each stored record before replay. Pass supported records to the upgraded SDK's
`applyPatches()`; do not project operations down to only `op`, `path`, and `value`.

---

## Origin Model
Expand Down Expand Up @@ -143,23 +172,29 @@ comp.batch(() => {
When `history: false`, the host maintains its own undo stack from the patch events the SDK emits. To replay an undo step back into the SDK, call `applyPatches()` with the inverse patches from that stack entry:

```typescript
// Host undo stack entry (built from PatchEvent)
type HostEntry = {
patches: JsonPatchOp[];
inversePatches: JsonPatchOp[];
};
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
import type { PatchEvent } from "@hyperframes/sdk";

type HostEntry = Pick<PatchEvent, "formatVersion" | "patches" | "inversePatches">;

let history: HostEntry[] = [];
const history: HostEntry[] = [];

comp.on("patch", ({ patches, inversePatches, origin }) => {
comp.on("patch", ({ formatVersion, patches, inversePatches, origin }) => {
// Skip — this is the SDK replaying a host-initiated undo
if (origin === ORIGIN_APPLY_PATCHES) return;
history.push({ patches, inversePatches });
if (formatVersion !== 1 && formatVersion !== 2) {
throw new Error("Unsupported patch format version");
}
history.push({ formatVersion, patches, inversePatches });
});

function hostUndo() {
const entry = history.pop();
const entry = history.at(-1);
if (!entry) return;
if (entry.formatVersion !== 1 && entry.formatVersion !== 2) {
throw new Error("Unsupported stored patch format version");
}
history.pop();
comp.applyPatches(entry.inversePatches);
}
```
Expand All @@ -171,9 +206,12 @@ function hostUndo() {
```typescript
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";

comp.on("patch", ({ patches, inversePatches, origin }) => {
comp.on("patch", ({ formatVersion, patches, inversePatches, origin }) => {
if (origin === ORIGIN_APPLY_PATCHES) return; // <-- required guard
history.push({ patches, inversePatches });
if (formatVersion !== 1 && formatVersion !== 2) {
throw new Error("Unsupported patch format version");
}
history.push({ formatVersion, patches, inversePatches });
});
```

Expand Down Expand Up @@ -207,18 +245,26 @@ const comp = await openComposition(baseTemplateHtml, {
history: false,
});

let hostStack: HostEntry[] = [];
const hostStack: HostEntry[] = [];

comp.on("patch", ({ patches, inversePatches, origin }) => {
comp.on("patch", ({ formatVersion, patches, inversePatches, origin }) => {
if (origin === ORIGIN_APPLY_PATCHES) return;
hostStack.push({ patches, inversePatches });
if (formatVersion !== 1 && formatVersion !== 2) {
throw new Error("Unsupported patch format version");
}
hostStack.push({ formatVersion, patches, inversePatches });
// Persist the updated override set
saveOverrides(comp.getOverrides());
});

function undo() {
const entry = hostStack.pop();
if (entry) comp.applyPatches(entry.inversePatches);
const entry = hostStack.at(-1);
if (!entry) return;
if (entry.formatVersion !== 1 && entry.formatVersion !== 2) {
throw new Error("Unsupported stored patch format version");
}
hostStack.pop();
comp.applyPatches(entry.inversePatches);
}
```

Expand Down
19 changes: 15 additions & 4 deletions docs/sdk/reference/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -499,13 +499,14 @@ interface ImageValue {

## JsonPatchOp

An emit-only subset of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON Patch. The SDK never emits `move`, `copy`, or `test` ops, and `applyPatches()` ignores operations outside this subset.
Version `1` uses the add/remove/replace subset of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON Patch. Version `2` adds SDK-specific selective style inverses. The SDK never emits `move`, `copy`, or `test` ops, and `applyPatches()` ignores operations outside that operation subset.

```typescript
interface JsonPatchOp {
op: "add" | "remove" | "replace";
path: string;
value?: unknown;
restoreStyleProperties?: readonly string[];
}
```

Expand All @@ -521,6 +522,13 @@ interface JsonPatchOp {
New value for `"add"` and `"replace"` ops. Omitted for `"remove"`.
</ResponseField>

<ResponseField name="restoreStyleProperties" type="readonly string[]">
Present on SDK-generated style-attribute inverses when a property has repeated declarations.
Lists the camelCase properties to restore from `value`, preserving current declarations of
unrelated properties. Preserve this field when storing inverse patches and replay them through
`applyPatches()`.
</ResponseField>

<Warning>
Hosts feeding patches back into `applyPatches()` must restrict themselves to `add`, `remove`, and `replace`. Other RFC 6902 op types are silently ignored.
</Warning>
Expand All @@ -533,16 +541,19 @@ Emitted by `comp.on('patch', handler)` after every committed change (single `dis

```typescript
interface PatchEvent {
readonly formatVersion: 1;
readonly formatVersion: 1 | 2;
readonly patches: readonly JsonPatchOp[];
readonly inversePatches: readonly JsonPatchOp[];
readonly origin: unknown;
readonly opTypes: readonly string[];
}
```

<ResponseField name="formatVersion" type="1">
Always `1`. A bump in `formatVersion` is a breaking change. Hosts should check this value once on startup and reject unknown versions.
<ResponseField name="formatVersion" type="1 | 2">
Version `1` contains ordinary add/remove/replace patches. Version `2` contains an SDK-specific
selective style inverse with `restoreStyleProperties`. Store those operations intact and replay
them through `applyPatches()`. Hosts that only support version `1` must reject version `2`
before processing its patches.
</ResponseField>

<ResponseField name="patches" type="readonly JsonPatchOp[]">
Expand Down
7 changes: 6 additions & 1 deletion packages/parsers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ export * from "./compositionDuration.js";
// consume them without depending on @hyperframes/core). The Node-only asset
// path helpers live behind the ./asset-paths subpath to keep this entry
// browser-safe.
export { decodeUrlPathVariants, decodedUrlPath, encodeUrlPath } from "./utils/urlPath.js";
export {
decodeCssEscapes,
decodeUrlPathVariants,
decodedUrlPath,
encodeUrlPath,
} from "./utils/urlPath.js";
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
export { extractMediaSrcMutations, type MediaSrcMutation } from "./mediaSrcMutation.js";
export {
Expand Down
20 changes: 14 additions & 6 deletions packages/sdk/src/engine/apply-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
findRoot,
declarationElement,
setElementStyles,
restoreElementStyleProperties,
setOwnText,
setGsapScript,
setStyleSheet,
Expand Down Expand Up @@ -219,15 +220,15 @@ export function applyPatchesToDocument(
parsed: ParsedDocument,
patches: readonly JsonPatchOp[],
options: ApplyOptions = {},
): void {
for (const patch of patches) {
): readonly JsonPatchOp[] {
return patches.map((patch) => {
const p = parsePath(patch.path);
if (!p) continue;
if (!p) return patch;
if (p.id && patch.path.startsWith("/elements/")) {
p.id = p.id.replace(/~1/g, "/").replace(/~0/g, "~");
}
applyOne(parsed, patch, p, options);
}
return applyOne(parsed, patch, p, options) ?? patch;
});
}

// fallow-ignore-next-line complexity
Expand All @@ -236,7 +237,7 @@ function applyOne(
patch: JsonPatchOp,
p: ParsedPath,
options: ApplyOptions,
): void {
): JsonPatchOp | undefined {
switch (p.type) {
case "style": {
const el = p.id ? findById(parsed.document, p.id) : null;
Expand Down Expand Up @@ -265,6 +266,13 @@ function applyOne(
if (!el || !p.prop) return;
if (patch.op === "remove") {
el.removeAttribute(p.prop);
} else if (p.prop === "style" && patch.restoreStyleProperties) {
const value = restoreElementStyleProperties(
el,
String(patch.value ?? ""),
patch.restoreStyleProperties,
);
return { op: patch.op, path: patch.path, value };
} else {
el.setAttribute(p.prop, String(patch.value ?? ""));
}
Expand Down
Loading
Loading