Batching, loading, and errors

Load explicit properties, manage proxy lifetimes, handle atomic batches, and recover from stable error codes.

Document objects are proxies: they queue operations against the runtime's document. Obtain them through context.document. A RunCallback<T> receives a RequestContext and returns Promise<T>. runtime.run() returns that callback's result. Returning from run() does not flush pending commands. Use an explicit final await context.sync().

Create a runtime with Runtime and setup. Use context and document proxies inside runtime.run().

Load collections, then item properties

ClientObject.load(option?) queues reads and returns the same proxy. Its context property identifies the owning request context. A navigation getter returns another proxy; it does not load that proxy's scalar properties.

Load collection membership before loading each item's properties:

const headings = await runtime.run(async (context) => {
  const paragraphs = context.document.paragraphs;
  paragraphs.load({ select: 'items', top: 100, skip: 0 });
  await context.sync();

  for (const paragraph of paragraphs.items) {
    paragraph.load(['text', 'style']);
  }
  await context.sync();
  return paragraphs.items.map((paragraph) => ({
    text: paragraph.text,
    style: paragraph.style,
  }));
});

Each loop queues item loads, then syncs once. The result contains plain data you can use outside the run. Collections expose read-only items arrays. Item objects remain editable proxies. Reload values after writes when your next decision needs the changed document.

To load the items and their properties in one sync, name each property with an items/ path:

const headings = await runtime.run(async (context) => {
  const paragraphs = context.document.paragraphs;
  paragraphs.load({ select: 'items/text,items/style', top: 100 });
  await context.sync();
  return paragraphs.items.map((paragraph) => ({
    text: paragraph.text,
    style: paragraph.style,
  }));
});

On a collection, a property name without the items/ prefix means the same thing: paragraphs.load('text') loads text on each paragraph.

LoadOption accepts a property string, a string array, or LoadQueryOptions:

Form or optionMeaning
load('text')Load one scalar property
load('text,style') or load(['text', 'style'])Load named scalar properties
{ select: 'items', top: 20, skip: 40 }Load a collection window
load('items/text') or load('text') on a collectionLoad the items and text on each item in the same sync
load()Load the object's default selection; prefer explicit names
expandReserved; nonempty values fail with InvalidArgument

top and skip require nonnegative integers and apply to collections. Unknown properties and options fail explicitly. Only one level of navigation works. Deeper paths such as items/font/bold, and paths on objects that are not collections such as font/bold, fail with InvalidArgument and name the path in target. items/<navigation property> such as items/font also fails; load font on each item. Load paragraph.font or another navigation object directly.

Item properties follow the same rule as other loads: in a sync that also writes, they report the document from before the writes. The range collection that Paragraph.split() returns accepts items/ paths, top, and skip after the split's sync. Before that sync, such a load fails with NotImplemented.

Separate dependent work

Each sync(): Promise<void> commits its writes atomically. It captures queued operations when you call it. Await each sync before starting another on the same context.

A load in the same batch as a write reads the previous value. To read the written value, load it in a later sync. Setters called while a sync waits belong to the next sync.

Some dependent reads require extra transport batches. Those batches share one document revision. A concurrent edit produces StaleDocument; the runtime does not replay the write.

Objects returned by insertions need a completed sync before dependent edits:

await runtime.run(async (context) => {
  const paragraph = context.document.body.insertParagraph('Summary', 'End');
  await context.sync();
  paragraph.font.bold = true;
  paragraph.spaceAfter = 12;
  await context.sync();
});

The two syncs are separate transactions. A later failure does not undo an earlier successful sync. Batch independent writes when they should succeed or fail together. Use separate syncs for conflicting structural edits. Run each row or column addition or deletion, and each insertBreak() call other than a line break, in its own sync. Paragraph breaks in insertText(), fields, and equations follow the paragraph rules in Propose several structural edits in one run. Different levels of an existing list can share a sync; competing aliases for one level can conflict. Keep field result updates separate from layout-changing writes. See each topic's supported batching limits.

You can batch separate plain-text edits in a paragraph with direct text runs. Returned ranges account for those edits. Mixing text edits with formatting, hyperlinks, or structural writes in the same paragraph refuses.

An existing range stays valid after formatting changes or text edits beyond its end. A text edit before its end produces StaleDocument, including edits from an earlier sync in the same run. Untouched paragraphs keep valid ranges.

A range across paragraphs also becomes stale if an intervening paragraph changes, appears, or disappears. After editing a target paragraph, search again before a dependent edit. See Text and ranges.

Keep writes within one story

A story is the main body, a header, a footer, or another document text container. Text and formatting writes in one context.sync() must target one story. Writes to multiple stories fail with ConflictingChanges. Reads from different stories can share a sync.

Bookmark removal is an exception: Document.deleteBookmark() and Bookmark.delete() can share a sync with writes to another story. Inserting a bookmark can also move an existing name from another story in the same transaction.

If the document has a primary header and footer, resolve both objects before formatting them. Then commit each story's writes separately:

await runtime.run(async (context) => {
  const section = context.document.sections.getFirst();
  await context.sync();
  const stories = [section.getHeader('Primary'), section.getFooter('Primary')];
  await context.sync();

  for (const story of stories) {
    story.font.name = 'Calibri';
    story.font.size = 10;
    await context.sync();
  }
});

Each write sync creates a separate transaction. Earlier successful transactions remain if a later transaction fails. Linked headers or footers can share content. Re-read that content before making a dependent edit through another section.

Handle an absent item

Use the collection's null-object accessor when absence is expected:

await runtime.run(async (context) => {
  const first = context.document.body.search('Optional clause').getFirstOrNullObject();
  first.load('text');
  await context.sync();
  if (first.isNullObject) return;
  console.log(first.text);
});

Check isNullObject after sync. Do not read other properties on a null object. A non-null accessor such as getFirst() fails with ItemNotFound when its collection is empty. Collections expose different accessors; check the API member directory.

Keep proxies within their lifetime

A proxy remains usable across syncs within its run. An untracked proxy becomes invalid when the run finishes. Keep plain data in model prompts and job queues.

For intentional proxy reuse, track the object and explicitly adopt it in a later run:

const paragraph = await runtime.run(async (context) => {
  const first = context.document.paragraphs.getFirst();
  first.load('text');
  await context.sync();
  context.trackedObjects.add(first);
  return first;
});

await runtime.run(paragraph, async (context) => {
  paragraph.load('text');
  await context.sync();
  console.log(paragraph.text);
  context.trackedObjects.remove(paragraph);
});

TrackedObjects.add() and remove() accept one ClientObject or an array. runtime.run(objectOrArray, callback) adopts tracked objects from completed runs of that runtime. Await the first run before adoption. Tracking does not prevent document edits or deletion of the target. No proxy survives runtime disposal.

If another run, a collaborator, or an undo removes or merges the target, the next sync that uses the proxy fails with InvalidObjectPath. The error's debugInfo.innerError.code is edit.stale-target. Read the document again to get a fresh proxy.

ClientResult<T> exposes isLoaded and value. Read value after the operation's context.sync() completes; reading it earlier throws ValueNotLoaded. For example, RevisionCollection.resolve() returns a ClientResult<RevisionBatchResult> with resolved and skipped decisions and a remaining count. RevisionCollection.setAuthor() returns a ClientResult<RevisionAuthorResult> with updated and skipped changes. See Resolve a batch of changes.

Recover by error code

Catch both synchronous call failures and rejected syncs. Use isDocxEditorError(error) or DocxEditorError, then inspect code. Do not match message text.

import { isDocxEditorError } from '@docx-editor.dev/editor-api';

try {
  await runtime.run(async (context) => {
    const target = context.document.body.search('Approved').getFirst();
    target.insertText('Reviewed', 'Replace');
    await context.sync();
  });
} catch (error) {
  if (!isDocxEditorError(error)) throw error;
  if (error.code === 'StaleDocument') {
    // Start a fresh read and reconsider the target before proposing an edit.
  } else {
    throw error;
  }
}
DocxEditorErrorCodeRecovery
PropertyNotLoadedLoad the named property and sync before reading
ValueNotLoadedComplete the result-producing sync
InvalidObjectPathSync a pending object, or obtain a fresh proxy after expiration or deletion. If debugInfo.innerError.code is edit.stale-target, the document no longer holds the object
ObjectInUseAwait the owning run before adopting its tracked objects
InvalidArgumentCorrect the input, property name, location, or document target
ResourceLimitExceededInspect limit; reject the file or deliberately adjust supported input budgets
ItemNotFoundHandle absence or choose a null-object accessor
NotSupported, NotImplementedThe call is outside the supported subset for this host, mode, content, or target, and nothing changed. Don't retry the same call. Read debugInfo.innerError.code for the rule, then choose another call, mode, target, or content.
ConflictingChangesRead again and separate dependent edits into different syncs
InvalidRequestContextStart a new run
RuntimeDisposedCreate a new runtime
StaleDocumentStart a fresh run, read again, and reconsider the proposed change
DocumentUnavailableWait for the owning editor to attach a document, then start a new run
PreviewActiveCommit or discard the open preview, then write or preview again
PreviewEndedStart a new preview; a committed, discarded, or replaced preview cannot commit
GeneralExceptionAct on error.debugInfo.innerError.code (see Diagnostics); for edit.internal-invariant, or when innerError is absent, report the failure with the engine version and do not retry the write unchanged

DocxEditorError exposes code, optional target, and optional resource or revision metadata. DocxEditorErrorInit accepts the same fields.

For stale writes, compare expectedRevision and actualRevision:

  • Different values indicate another writer changed the document after your read.
  • Equal values can indicate a range moved during an earlier sync, or its history record expired.

Read-only batches omit expectedRevision. For resource failures, limit identifies the exceeded ZIP, XML, part, or relationship budget.

After StaleDocument, obtain fresh ranges and reconsider the edit. Never disable change tracking to bypass a refusal.

Read why the engine refused

error.debugInfo has the shape of Office.js OfficeExtension.DebugInfo: code, message, and errorLocation, the path of the refused call. The path joins the proxies and members you called, starting at document. For example, document.body.search.getFirst().insertOoxml names an insertOoxml() call on the first search hit, and document.body.paragraphs.items[2].insertText names an insertText() call on the third loaded paragraph. When two calls in one sync conflict, the path names the later call. When the engine knows a specific reason, debugInfo.innerError holds a stable diagnostic code and a fixed hint. Branch on error.code, and give innerError.message to the person or model that chose the edit. The hint never contains document content.

import { isDocxEditorError } from '@docx-editor.dev/editor-api';

try {
  await context.sync();
} catch (error) {
  if (!isDocxEditorError(error)) throw error;
  const reason = error.debugInfo.innerError;
  // For example: { code: 'edit.tracked-operation-unsupported', message: 'This editing API ...' }
  if (reason) feedback.push(`${reason.code}: ${reason.message}`);
}

For all codes, see Diagnostics. To record anonymous refusal events, add a telemetry module to createServer({ modules }). For more information, see Server agents.

Recover from a stale range

The following example recovers from a stale range in a loop that writes one suggestion per sync:

import { isDocxEditorError } from '@docx-editor.dev/editor-api';

// `proposals` holds { find, replace } pairs from your model.
// `stillApplies` is your own check of a proposal against the current text.
await runtime.run(async (context) => {
  context.document.changeTrackingMode = 'TrackMineOnly';
  // Read every target in one sync.
  const searches = proposals.map((proposal) => {
    const matches = context.document.body.search(proposal.find, { matchCase: true });
    matches.load('items');
    return matches;
  });
  await context.sync();

  for (const [index, proposal] of proposals.entries()) {
    const range = searches[index]!.items[0];
    if (!range) continue;
    range.insertText(proposal.replace, 'Replace');
    try {
      // One reviewable suggestion per sync, so peers see each one as it lands.
      await context.sync();
    } catch (error) {
      if (!isDocxEditorError(error) || error.code !== 'StaleDocument') throw error;
      // Equal revisions: the range moved, often through an earlier sync of this run.
      // Different revisions: another writer changed the document.
      // Either way, search again and reconsider before you write.
      const fresh = context.document.body.search(proposal.find, { matchCase: true });
      fresh.load('items');
      await context.sync();
      const current = fresh.items[0];
      if (!current || !stillApplies(proposal)) continue;
      current.insertText(proposal.replace, 'Replace');
      await context.sync();
    }
  }
});

Next steps

See Runtime and setup and Office.js compatibility.