Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Plate
  • Editor API
  • Editor Transforms
  • Node
  • Element
  • Text
  • Path
  • Point
  • Range
  • Location
  • Anchor
  • Selection
  • Document Change
  • DOM API
  • React Hooks
  • Plate Core
    • Plate Components
    • Plate Editor
    • Plate Plugin
    • Editor Context
    • Plate Controller
  • Plate Utils
  • Resizable

Document Changes

PreviousNext

Use root-aware document changes as Plate's canonical mutation, mapping, and replay law.

Every document-changing update publishes one DocumentChange. It maps an immutable before value to an immutable after value across the primary document and any named roots.

const commit = editor.read.lastCommit();
const nextValue = commit.changes.apply(previousValue);
const commit = editor.read.lastCommit();
const nextValue = commit.changes.apply(previousValue);

Algebra

AnchorPlate Core

On This Page

AlgebraApply a stored or remote changeCommit invalidationFit external contentCommit fieldsAdapter boundary
Build your editor
Production-ready AI template and reusable components.
Get all-access

DocumentChange owns the algebra required by history, collaboration, anchors, corrections, and view invalidation:

  • apply(value)
  • compose(other)
  • invert(value)
  • mapPosition(position, { root, association, track })
  • iterChangedRanges(visit)
  • toJSON() and DocumentChange.fromJSON(json)

The compact per-root representation is private. Omit root when mapping a primary-document position. Explicit root strings always address named secondary roots.

const mapped = change.mapPosition(position, {
  association: "forward",
});
 
const mappedHeader = change.mapPosition(position, {
  association: "backward",
  root: "header",
});
const mapped = change.mapPosition(position, {
  association: "forward",
});
 
const mappedHeader = change.mapPosition(position, {
  association: "backward",
  root: "header",
});

toJSON() writes version 3 with optional primary and roots fields. An omitted roots object and an empty roots object describe the same document change.

iterChangedRanges reports root: null for the primary document and a string for a named root.

DocumentChange.transform(a, b, value) rebases two changes from the same value for pairwise convergence, including history rebasing. Multi-peer ordering belongs to a collaboration adapter such as Yjs.

Apply a stored or remote change

editor.update({ tags: "remote-import" }, (tx) => {
  tx.changes.apply(DocumentChange.fromJSON(message.change));
});
editor.update({ tags: "remote-import" }, (tx) => {
  tx.changes.apply(DocumentChange.fromJSON(message.change));
});

Use semantic transaction groups for ordinary editing. tx.changes.apply is the adapter, history, and durable replay boundary.

Commit invalidation

Consumers inspect the canonical change through lazy commit.changed queries. They do not reconstruct impact from a parallel operation list.

const unsubscribe = editor.subscribeCommit((commit) => {
  if (!commit.changed.has("document")) return;
 
  const changedBlocks = commit.changed.topLevelRanges();
  const changedNodeKeys = commit.changed.nodeKeys("node");
 
  refreshDocumentConsumers(changedBlocks, changedNodeKeys);
});
const unsubscribe = editor.subscribeCommit((commit) => {
  if (!commit.changed.has("document")) return;
 
  const changedBlocks = commit.changed.topLevelRanges();
  const changedNodeKeys = commit.changed.nodeKeys("node");
 
  refreshDocumentConsumers(changedBlocks, changedNodeKeys);
});

commit.changed.has(kind) and nodeKeys(kind) query the primary document. Pass a named root for one secondary document. Use hasAny(kind) and nodeKeysAll(kind) only when a consumer intentionally spans every root. The queries derive their answers from DocumentChange plus the retained before and after snapshot indexes.

Choose the change kind your consumer needs. nodeKeys("presence") returns identities added to or removed from a document root. Moving a node within that root changes its path, not its presence. Use "node" for changed node values, "path" for path changes, and "decoration" for transient range-paint impact. Presence queries inspect changed ranges without enumerating shifted paths. Whole-value replacement compares the replaced roots.

Aggregate key lists are immutable and reused within a commit. hasNodeKey(key, "path") caches each key's answer without collecting every changed path. When a consumer requests the complete path list, membership queries reuse that result. Other change kinds share cached aggregate membership.

Fit external content

Parsing, paste, and import boundaries carry open content as an immutable ContentSlice. Replace through tx.slice so the compiled schema fits the slice before one canonical change is published.

import { ContentSlice } from "platejs";
 
const slice = ContentSlice.fromJSON({
  content: parsedContent,
  openEnd: 1,
  openStart: 1,
});
 
editor.update.slice.replace(slice);
import { ContentSlice } from "platejs";
 
const slice = ContentSlice.fromJSON({
  content: parsedContent,
  openEnd: 1,
  openStart: 1,
});
 
editor.update.slice.replace(slice);

Use ContentSlice.closed(content) when a transport explicitly needs a closed slice, ContentSlice.empty for the frozen empty slice, and ContentSlice.withContent(slice, content, { open }) when a codec or plugin rewrites content while either preserving or closing its boundaries.

For ordinary closed application content, skip the transport wrapper.

editor.update.fragment.replace([
  { type: "paragraph", children: [{ text: "Closed content" }] },
]);
editor.update.fragment.replace([
  { type: "paragraph", children: [{ text: "Closed content" }] },
]);

Pure commands preview the same atomic replacement with state.slice.fit(slice, options?). Editor-level code can call editor.read.slice.fit(...); it returns false or a frozen TransactionSpec without publishing.

Detached structures use the same compiled grammar without pretending to be in the live document.

const tableCell = {
  type: "tableCell",
  children: [{ type: "paragraph", children: [{ text: "" }] }],
};
const fittedChildren = editor.read.slice.fitContent(slice, {
  parent: tableCell,
});
 
if (fittedChildren === null) {
  throw new Error("The slice does not fit this table cell.");
}
const tableCell = {
  type: "tableCell",
  children: [{ type: "paragraph", children: [{ text: "" }] }],
};
const fittedChildren = editor.read.slice.fitContent(slice, {
  parent: tableCell,
});
 
if (fittedChildren === null) {
  throw new Error("The slice does not fit this table cell.");
}

fitContent returns immutable children or null. It does not mutate the parent, selection, document, or commit stream. Omit root for the primary root; root-scoped editor views inherit their root automatically.

Commit fields

FieldContract
changesRoot-aware change from the before document to the after document.
inverseChangesExact inverse used by history and rollback.
effectsTyped state and integration effects.
annotationsTransaction metadata combined by descriptor policy.
tagsOrdered lifecycle labels.
selectionBefore / selectionAfterModel selections around the commit.
changedLazy impact queries derived from the change and retained indexes.

Adapter boundary

Adapters serialize commit.changes.toJSON(). Shared effects use keyed, versioned codecs on installed descriptors. encodeEditorEffect and decodeEditorEffect from platejs handle their encoded values; the adapter owns descriptor lookup and handling unknown keys or codec versions.

Import the document change and its shared effects in one update so roots and state change atomically. Subscribers, history, and rendering observe the same final commit. Tags can identify an import and prevent echoing it back to peers; they do not replace the document change as the replay contract.

Runtime NodeKey identities, browser state, and local selections are not remote identity. A collaboration adapter owns its concurrency model, relative positions, awareness codec, transport, and persistence. The Yjs plugin provides that document adapter; the app supplies its provider and server policy.