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);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.
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.
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.
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.
| Field | Contract |
|---|---|
changes | Root-aware change from the before document to the after document. |
inverseChanges | Exact inverse used by history and rollback. |
effects | Typed state and integration effects. |
annotations | Transaction metadata combined by descriptor policy. |
tags | Ordered lifecycle labels. |
selectionBefore / selectionAfter | Model selections around the commit. |
changed | Lazy impact queries derived from the change and retained indexes. |
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.