Preview and apply mutation plans

Validate several document edits, then apply them together against one document revision.

Use a mutation plan when several edits belong to one logical change. A plan resolves every target, previews the combined work without changing the document, and applies valid steps as one atomic transaction.

For one independent replacement or deletion, prefer the direct operations in Replace and delete content.

Two query references become one atomic plan that is previewed before all steps apply together.

Run a complete browser example

Create a browser app with an #editor mount element and an explicit container height, as shown in the Editor quickstart. This example expects the tracked-changes fixture at /contract.docx. Its source lives in the docs app and is typechecked against the public v2 browser API:

import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  onReady: async ({ superdoc }) => {
    const doc = superdoc.activeEditor?.doc;
    if (!doc) throw new Error('The active document is unavailable.');

    const [companyResult, liabilityResult] = await Promise.all([
      doc.query.match({
        select: { type: 'text', pattern: 'Amazing' },
        require: 'exactlyOne',
      }),
      doc.query.match({
        select: { type: 'text', pattern: '$500,000' },
        require: 'exactlyOne',
      }),
    ]);

    const company = companyResult.items[0];
    const liability = liabilityResult.items[0];

    if (!company || company.matchKind !== 'text' || !liability || liability.matchKind !== 'text') {
      throw new Error('The expected contract text was not found.');
    }

    if (companyResult.evaluatedRevision !== liabilityResult.evaluatedRevision) {
      throw new Error('The document changed while the plan targets were being collected.');
    }

    const plan = {
      atomic: true as const,
      changeMode: 'tracked' as const,
      author: { name: 'AI Reviewer' },
      steps: [
        {
          id: 'rename-company',
          op: 'text.rewrite' as const,
          where: {
            by: 'select' as const,
            select: { type: 'text' as const, pattern: 'Amazing' },
            require: 'exactlyOne' as const,
          },
          args: { replacement: { text: 'Northstar' } },
        },
        {
          id: 'lower-liability-cap',
          op: 'text.rewrite' as const,
          where: {
            by: 'select' as const,
            select: { type: 'text' as const, pattern: '$500,000' },
            require: 'exactlyOne' as const,
          },
          args: { replacement: { text: '$250,000' } },
        },
      ],
    };

    const preview = await doc.mutations.preview(plan);
    if (!preview.valid) {
      throw new Error(preview.failures?.map((failure) => failure.message).join('; ') ?? 'Plan preview failed.');
    }

    const receipt = await doc.mutations.apply({ ...plan, expectedRevision: preview.evaluatedRevision });
    console.log('Applied steps:', receipt.steps);
  },
});

window.addEventListener('beforeunload', () => {
  superdoc.destroy();
});

The example queries both targets before applying any change and confirms that both matches came from the same source revision. Its selector steps are resolved together by the plan engine, and apply uses the successful preview revision as its document guard.

Choose plan targets

A single-step plan can use item.handle.ref from query.match(). For multiple mutating steps, use selector targets so the engine can resolve each step against the current document. Revision-bound refs cannot be combined in a multi-step plan.

Bind apply to preview.evaluatedRevision after a successful preview. A query's evaluatedRevision identifies the source used to produce its refs; it is not the mutation-plan revision guard.

Every plan requires:

  • atomic: true
  • changeMode: 'direct' or 'tracked'
  • A stable, unique id for every step
  • A supported step op
  • A where target for core text/format steps, or explicit native args for review steps

Check doc.capabilities().planEngine.supportedStepOps before constructing plans dynamically. The generated reference remains authoritative for each step shape.

Attribute a tracked text plan

Put one display author in the plan input. The same input works with mutations.preview, mutations.apply, typed invoke, and the Node SDK. The second argument to mutations.apply remains host cancellation options; an author there is rejected.

const matches = await doc.query.match({
  select: { type: 'text', pattern: 'Original clause.' },
  require: 'exactlyOne',
});
const match = matches.items[0];
if (match?.matchKind !== 'text' || !match.handle?.ref) {
  throw new Error('Expected the original clause');
}
const plan = {
  atomic: true,
  changeMode: 'tracked',
  author: { name: 'AI Reviewer' },
  steps: [
    {
      id: 'revise-clause',
      op: 'text.rewrite',
      where: { by: 'ref', ref: match.handle.ref },
      args: { replacement: { text: 'Revised clause.' } },
    },
  ],
} satisfies Parameters<typeof doc.mutations.apply>[0];
const preview = await doc.mutations.preview(plan);
if (preview.valid) {
  await doc.mutations.apply({ ...plan, expectedRevision: preview.evaluatedRevision });
}

This example requires a document containing Original clause.. Every revision created by the plan uses AI Reviewer in the review API, built-in review UI, and exported DOCX. The author is copied when each call is submitted. Editing the caller's object afterward does not change that call's attribution. It does not change the session user, collaboration presence, or permission checks, and it does not affect subsequent human edits or plans without an override.

The accepted author is { name: string } with a non-empty name. Email, initials, and other author fields are rejected; the human session's email is not inherited. An override requires explicit changeMode: 'tracked' and supports existing body text.insert, text.delete, and text.rewrite steps, with any supported leading assertions. Individual steps cannot select different authors. Formatting, structural, and review steps cannot be combined with an override. Existing text-write restrictions still apply, including refusal of non-empty structural cross-paragraph replacements with an author. Preview reports those refusals without changing text, revisions, or history, and apply refuses the same plan before writing.

Supported shared-room text plans retain the same author on independent peers and rejoining clients. Display attribution does not create a collaboration actor or grant review permissions. The plan remains one Undo/Redo unit. The usual revision guard still applies between preview and apply; preview does not reserve the document revision.

For different display authors, use separate tracked plain-text doc.insert, doc.delete, or doc.replace calls with { changeMode: 'tracked', author: { name } }. Those calls are not atomic as a group.

Select text across table cells

The text returned for a table by blocks.list({ includeText: true }) is aggregate inspection text. It is not necessarily a searchable text range: concatenating text from several cells does not make it a single query.match() or mutation selector. A scoped tracked rewrite whose pattern has no match returns MATCH_NOT_FOUND; it does not imply that the table contains hidden tracked text.

For a supported cross-cell rewrite, query the first and last text ranges separately within the table, then combine their returned selection endpoints. This selects all content between those endpoints, including intervening cells. Choose endpoints that describe the intended range and keep them in the same story and document revision.

const revision = (await doc.info()).revision;
const within = { kind: 'block', nodeType: 'table', nodeId: tableId } as const;
const first = await doc.query.match({
  select: { type: 'text', pattern: firstCellText },
  within,
  require: 'exactlyOne',
});
const last = await doc.query.match({
  select: { type: 'text', pattern: lastCellText },
  within,
  require: 'exactlyOne',
});
const firstMatch = first.items[0];
const lastMatch = last.items[0];
if (firstMatch?.matchKind !== 'text' || lastMatch?.matchKind !== 'text') {
  throw new Error('Expected text matches for both endpoints');
}
const start = firstMatch.target;
const end = lastMatch.target;
if (
  first.evaluatedRevision !== last.evaluatedRevision ||
  start?.kind !== 'selection' ||
  end?.kind !== 'selection' ||
  JSON.stringify(start.story) !== JSON.stringify(end.story)
) {
  throw new Error('Resolve both endpoints in the same story and revision');
}
const plan = {
  atomic: true,
  changeMode: 'tracked' as const,
  expectedRevision: revision,
  steps: [
    {
      id: 'rewrite-cell-range',
      op: 'text.rewrite',
      where: {
        by: 'target',
        target: { kind: 'selection', start: start.start, end: end.end, story: start.story },
      },
      args: { replacement: { text: replacementText } },
    },
  ],
} satisfies Parameters<typeof doc.mutations.apply>[0];
const preview = await doc.mutations.preview(plan);
if (preview.valid) await doc.mutations.apply(plan);

Preview the concrete plan before applying it. This route does not add aggregate-table text matching or scoped hidden tracked-text selection support. An actual hidden tracked-text match with within still returns CAPABILITY_UNSUPPORTED.

Relocate a discussion and decide revisions together

Applications can explicitly relocate a root comment thread before accepting or rejecting exact revision IDs in one mutation. The application chooses which thread to move and its destination. Ordinary revision decisions retain their default behavior.

const plan = {
  atomic: true,
  changeMode: 'direct',
  expectedRevision: revision,
  steps: [
    {
      id: 'move-discussion',
      op: 'comments.patch',
      args: {
        commentId: rootCommentId,
        target: { kind: 'text', blockId: survivingParagraphId, range: { start: 0, end: 0 } },
      },
    },
    {
      id: 'decide',
      op: 'trackChanges.decide',
      args: {
        decision: 'reject',
        target: { kind: 'ids', ids: revisionIds },
      },
    },
  ],
} satisfies Parameters<typeof doc.mutations.apply>[0];
const preview = await doc.mutations.preview(plan);
if (preview.valid) await doc.mutations.apply(plan);

Review plans support direct edits in the body story. Concrete point/range targets refer to the starting revision; every relocation must precede every decision. Decisions identify whole text revisions through id or ids. Preview validates the complete sequence without changing document bytes, revision, or history. Apply publishes the complete result once and creates one Undo entry; a failed step leaves the original document intact.

Review plans cannot mix core text/format/structural steps. Comment creation, comment metadata changes, Custom XML writes, structural revisions and secondary stories are unsupported. The selected destination must survive every decision. Preview reports unsupported combinations explicitly; do not invent a fallback destination after failure. plan.execute remains stepwise and retains earlier successful edits when a later entry fails.

A grouped revision can include paragraph marks or other structural carriers even when its public type is deletion. Such groups are unsupported by review plans and are rejected before publication. Repeated exact revision IDs are counted and decided once.

Replace a selected range with a locked block content control

V2 supports structural.replace for a nonempty visible-text selection inside one ordinary body paragraph. Supply one block rich-text SDT whose content contains ordinary paragraphs. The operation replaces the selected text, preserves surrounding text, and produces one undoable change. Paragraph and run formatting in the supported fragment is retained through DOCX export and reopen.

const plan = {
  atomic: true,
  changeMode: 'direct',
  steps: [
    {
      id: 'replace-selected-slash',
      op: 'structural.replace',
      where: {
        by: 'target',
        target: {
          kind: 'selection',
          start: { kind: 'text', blockId: paragraphId, offset: 0 },
          end: { kind: 'text', blockId: paragraphId, offset: 1 },
        },
      },
      args: {
        content: {
          kind: 'sdt',
          sdt: {
            scope: 'block',
            type: 'richText',
            tag: 'replacement',
            lock: 'content',
            content: [
              {
                kind: 'paragraph',
                paragraph: {
                  props: { spacing: { line: 30, lineRule: 'exact' } },
                  inlines: [{ kind: 'run', run: { text: 'Replacement content' } }],
                },
              },
            ],
          },
        },
      },
    },
  ],
} satisfies Parameters<typeof doc.mutations.apply>[0];
const preview = await doc.mutations.preview(plan);
if (preview.valid) await doc.mutations.apply(plan);

Exact line spacing uses points: line: 30 serializes as w:line="600" twips. The locked control prevents content editing after creation. Standalone and supported shared browser rooms use the same operation.

This structural replacement must be the only mutating step in its plan. Tracked mode, cross-paragraph targets, nested table/control targets, non-paragraph SDT children, linked assets, placeholder and appearance overrides remain unsupported. Existing paragraph styles may be referenced; this operation does not import new style definitions. Use planEngine.supportedTargets and planEngine.unsupported to discover supported targeting modes and named restrictions, then preview the concrete input. Invalid payloads, targets and revisions fail before mutation.

Preview without changing the document

doc.mutations.preview(plan) resolves targets and validates every step without applying document changes.

Read:

  • valid before calling apply()
  • failures for the step ID, phase, code, and message
  • steps for the targets each step resolved
  • evaluatedRevision for the state used during preview

A valid preview is still a snapshot. Another writer can change the document before apply, so keep expectedRevision on the plan.

Apply atomically

doc.mutations.apply(plan) commits every step together. If compilation, target resolution, an assertion, or revision validation fails, the plan does not partially apply successful steps.

The returned plan receipt includes the before/after revision, a result for every step, any tracked-change addresses, and timing metadata. Use step results for verification and diagnostics, not for inventing performance claims.

The generated mutations.preview reference and mutations.apply reference list supported steps, limits, outputs, and failure codes.

Show temporary edits in the document

Use mutations.beginTemporary to display an in-memory suggestion without adding an Undo entry or clearing Redo. Supply a nonempty host tag and an ordered operations array. Each operation uses the corresponding public method's input and options:

const shown = await doc.mutations.beginTemporary({
  tag: 'suggestion-preview',
  expectedRevision: revision,
  operations: [
    { operation: 'replace', input: { target, text: 'Proposed wording' }, options: { changeMode: 'direct' } },
  ],
});
// Replace the entire displayed choice without first rolling it back.
await doc.mutations.replaceTemporary({
  previewId: shown.temporary.id,
  tag: 'suggestion-preview',
  operations: [
    { operation: 'replace', input: { target, text: 'Alternative wording' }, options: { changeMode: 'direct' } },
  ],
});
await doc.mutations.rollbackTemporary({ previewId: shown.temporary.id });

replaceTemporary takes the same previewId, original tag, and a new complete operation array. It evaluates the new choice against the original state, then publishes the replacement once. If validation fails, the currently displayed choice remains intact. rollbackTemporary restores the original package and preserves the existing Undo and Redo stacks. commitTemporary keeps the displayed content and adds one Undo entry. A completed handle cannot be reused. Each lifecycle receipt includes temporary: { id, phase, tag } and the before/after revision. Temporary semantic refs and text shifts use the original preview baseline. A consumer maintaining its own anchors must retain a baseline snapshot, restore it before processing a replace receipt, and restore it on rollback; do not compose replacement shifts onto the previous choice. The revision pair describes the actual old and new displayed states. Built-in extension anchors handle this lifecycle automatically.

Set onNativeEdit: 'cancel' on beginTemporary to let native editor interaction cancel the preview before continuing. Clicking into the document, typing, deletion, paste, composition and native Undo/Redo first restore the baseline, rebase the captured selection, then perform the original interaction once. A caret inside replaced wording keeps its relative offset, clamped to the restored range; positions outside it follow the actual transition. The policy remains fixed for the handle; replaceTemporary can omit it or repeat it. Without the option, the existing exclusive editing lease remains in effect. This does not allow unrelated programmatic edits while a preview owns the document.

Native cancellation emits the existing mutation event with receipt.temporary.phase: 'rollback' and receipt.temporary.cancelledBy: 'native-edit', including the preview ID, tag and onNativeEdit policy. Hosts can reset their pending choice from this event; an explicit Cancel does not carry cancelledBy. A lifecycle-only commit or unchanged rollback has no paint transaction ID; a rollback that changes content carries its actual paint transaction ID.

Use replaceTemporary to switch choices directly; a separate rollback followed by begin would expose two lifecycle operations. This is the temporary lifecycle's publication guarantee, not a general layout-hold API for arbitrary calls.

Comment on text an earlier operation inserted

Give an operation an id, unique within the call, and a later comments.create can target its result with { kind: 'operationResult', operationId }. The comment is anchored on the text that insert created, as it is in the temporary document, so you don't compute offsets yourself:

await doc.mutations.beginTemporary({
  tag: 'counter-proposal',
  operations: [
    { operation: 'trackChanges.decide', input: { decision: 'reject', target: { kind: 'id', id: changeId } } },
    {
      id: 'ours',
      operation: 'insert',
      input: { target: { kind: 'selection', start: at, end: at }, value: 'within 30 days' },
      options: { changeMode: 'tracked', author: { name: 'Reviewer' } },
    },
    {
      operation: 'comments.create',
      input: { target: { kind: 'operationResult', operationId: 'ours' }, text: 'Our standard term.' },
    },
  ],
});

A trackChanges.decide can be referenced too. Its result is the text the decision leaves: the inserted text it accepts or the deleted text it restores, or a point where the text it removes was. The same text is in the decision's receipt as effects.resultText, reported for one whole, ungrouped text insertion or deletion. For a replacement, a move, a grouped change, a partial decision, or a change made only of a tab or line break, the receipt has no resultText, and a reference to it fails with CAPABILITY_UNSUPPORTED.

const operations = [
  {
    id: 'decision',
    operation: 'trackChanges.decide',
    input: { decision: 'accept', target: { kind: 'id', id: changeId } },
  },
  {
    operation: 'comments.create',
    input: { target: { kind: 'operationResult', operationId: 'decision' }, text: 'Accepted.' },
  },
];

Each run resolves the reference again, so replaceTemporary anchors the comment on the new wording. Only comments.create can use a reference, with no other edit between it and the operation it names. A reference to a missing, later or duplicate id, or across an edit, fails with INVALID_INPUT before anything changes; an operation without a text result fails with CAPABILITY_UNSUPPORTED. References stay within one call: commitTemporary({ operations }) cannot target operations from the shown preview.

Supported operations are plain-text insert, replace, delete, whole-revision trackChanges.decide, and comments.create, with their existing author, tracking, permission, target, and revision checks. Preparation is atomic. HTML, retained snapshots, and collaboration are not currently supported by this lifecycle and return typed failures. Temporary sessions take an exclusive document mutation lease: API writes are rejected until rollback or commit. Native editor writes, Undo and Redo are also rejected by default; onNativeEdit: 'cancel' restores the baseline before allowing those native interactions. Reads and rendering remain available. Dispose the editor when abandoning the entire document; otherwise finish the temporary session explicitly.

Retain discussions when a revision erases their text

Pass commentRetention: 'point' to trackChanges.decide (or its review-plan step) to keep a thread whose text is erased. The thread becomes a point at the exact removed range; IDs, replies, resolved state, author, dates, and comment parts are retained. A partially surviving anchor remains a range over the surviving text. The decision and retention form one undoable mutation.

await doc.trackChanges.decide({
  decision: 'reject',
  target: { kind: 'id', id: revisionId },
  commentRetention: 'point',
});

Omitting the option keeps the existing native deletion policy. Retention currently supports whole text revisions in materialized story XML. Structural revision decisions, partial revision fragments, and source-fenced large-document review return an explicit unsupported-context failure rather than silently applying the deletion policy.

Commit content-control operations together

V2 also supports doc.plan.execute({ atomic: true, entries }) for supported content-control operations and associated text or formatting edits. Without atomic: true, plans keep their existing stepwise behavior and retain successful entries before a failure.

const result = await doc.plan.execute({
  atomic: true,
  entries: controls.map(({ target }, index) => ({
    operationId: 'contentControls.replaceContent',
    input: { target, content: `Reference ${index + 1}`, overrideContentLock: true },
  })),
});
if (result.failure) throw new Error(result.failure.message);

The atomic route prepares on detached document bytes and commits the supported result once to the active session. It preserves existing history and gives the whole batch one Undo unit. Preparation or commit failure leaves the active document and history unchanged. It is bounded to 1,000 entries in one existing body, header or footer story. Cross-story batches and operations that change the package graph are refused unchanged.

Local plans support content-control creation, unwrap, deletion, property patching, lock changes and content replacement; plain text insertion/replacement/deletion; inline formatting; and existing mutations.apply operations. Each operation retains its own support boundaries. For example, tracked lock changes, inline retagging/deletion and replacement of a newly tracked inline insertion may be refused. Atomic execution does not make an unsupported tracked representation valid: it discards the complete prepared proposal on refusal.

Shared rooms support direct control/formatting batches within one story when the batch preserves document identities. Structural creation/deletion, tracked atomic proposals and cross-story shared batches are refused unchanged. A shared batch is one backend commit and one local Undo unit; peer history remains local to each peer.

captureAs and capture references may connect entries inside an atomic plan. Atomic captures do not persist across calls and captureReturns is unavailable, because preparation can produce revision-bound handles. Read fresh results after a successful commit. Failure expectations and per-entry history exclusion are also unavailable in this route. Hosts without atomic support refuse the opt-in before executing entries.

On this page