Local editing
For a new editor, use new Client({schema}) and pass it directly to PmBinding, SlateBinding, LexicalBinding or DocSync.configure({client}). Each binding creates its own host. Read Client: local or synchronized for the preferred API and four-editor demo. The lower-level APIs below remain supported.
Use LocalClient for a document that stays in memory in one application. It uses the production TypeScript engine without a transport, server, presence service or MCP connection. The same schema and editing operations apply.
The entry point is @barocss/doc-sync/local. It is part of Core, not a new package. Core and the binding packages remain private before registry release; use the built workspace or local tarballs described in Packages and engines.
Run the local editor demo
The dedicated demo has two modes. Independent documents opens ProseMirror, Slate and Lexical with separate LocalClients and undo stacks. Shared document opens a custom textarea plus those three editors on one LocalClient. Each shared view has its own host and selection; all four views use the same document, actor and undo stack.
From a repository checkout with Node 22.18 or later. The verified workflow uses Node 24.22.1 and Chrome 156:
npm install
npm install --prefix apps/demo/compat --workspaces=false --package-lock=false --ignore-scripts --cache /tmp/doc-sync-compat-npm-cache
npm run dev:localOpen the URL printed in the terminal. The command builds the packages and starts a loopback static preview for HTML and JavaScript. It starts no document server, SyncClient, transport or MCP service. Editing runs in the browser's memory.
Type in each editor, select a forward or backward range, and use its Undo and Redo controls. Use Insert prefix to check that an application edit can update the native view while retaining its selection. Independent mode has a reset control for each document. Shared mode has Reset shared document for all views and Remount for each view. Reloading starts again from the sample; it does not recover your edits.
To repeat the Chrome checks while the preview is running:
npm run check:local -- --url http://127.0.0.1:PORT --output output/local-demo/chrome
npm run check:local:shared -- --url http://127.0.0.1:PORT --output output/custom-shared/chromeReplace PORT with the printed port. The check uses an isolated Google Chrome session and the installed agent-browser CLI. See the demo source and setup for the exact controls and verification scope. Independent mode includes a formatted sample. Shared mode uses plain-text paragraphs without marks or attributes so the custom renderer can represent the complete model. Neither mode qualifies every schema shape, product layout or actual OS IME.
Verified browser behavior
In independent mode, Chrome 156 passes the following checks in all three native editors:
- Unicode input and deletion with native/client model equality.
- Forward, backward and collapsed selections.
- Toolbar and keyboard undo/redo, with independent document and History state.
- Application prefix insertion with exact logical and DOM selection restoration, including repeated identical prefixes in both selection directions.
- Remount retaining the document and History, reset restoring one panel, and refresh restoring all samples with empty History.
- Editing after network access is disabled. No document requests, notification errors, console errors or page errors were recorded.
The emitted-module report contains no synchronization client, transport or document-server implementation. The static preview also sets connect-src 'none'. Unused imports can still appear in the bundle's input graph; the report checks the modules that contribute emitted code.
A 390px screenshot/overflow check passes. A synthetic persisted: true pagehide check confirms that cached-page teardown is skipped and editing remains usable. Actual BFCache navigation, actual OS IME, broad mobile editing and rendering latency are not qualified by these checks. Receipts retain the first failures and the final run; see HANDOFF section 5.109 in the repository for their locations.
Shared mode has a separate 15-scenario Chrome check. It verifies one client and four distinct hosts, trusted input from every view, cross-view toolbar and keyboard undo/redo, and exact native/shared model equality. Custom selections retain their positions and direction after another native view edits, without stealing focus. Each active editor also preserves forward/backward ranges after repeated identical application prefixes. Native paragraph split, join and cross-paragraph deletion pass in all four views.
The shared check also covers remount retaining document/History, shared reset, refresh, blocked document networking and a mobile screenshot/overflow smoke check. Selection remains per view; it is not copied as a global caret. The adapter uses composition sessions, with focused headless tests for concurrent application input; shared Chrome composition and actual OS IME are NOT_RUN. See the host-based custom editor for the implementation and schema limits. Current shared-mode receipts are recorded in HANDOFF section 5.111.
LocalClient and SyncClient
Choose LocalClient when the document belongs to one running application and you do not need server synchronization. Choose SyncClient when independent users, editors or agents must exchange changes through a document server.
| Area | LocalClient | SyncClient |
|---|---|---|
| Import | @barocss/doc-sync/local | @barocss/doc-sync/sync |
| Construction | new LocalClient({schema}); actor is optional | new SyncClient(actor, transport, {schema}); actor and transport are required |
| Document engine | Production TypeScript engine | Same production TypeScript engine by default; only this backend currently ships |
| Schema and operations | Schema-defined text, blocks, tables and attributes | Same editing model and collaboration rules |
| Apply one application edit | client.edit(edits) applies and records once | client.commit(client.engine.apply(edits)) applies, records and queues once |
| Editor host | createLocalEditorHost(client) | createSyncClientEditorHost(client) from the Core /editor entry |
| Editor bindings | All four client-facing bindings accept LocalClient directly; PM, Slate and Lexical also expose explicit HostBinding constructors | ProseMirror, Tiptap, Slate and Lexical |
| Selection | Identity anchors for local selection; no remote presence | Identity anchors plus peer cursors and selection ranges |
| Undo and redo | One client History, shared by views using that client | Per-client History; independent participant changes are preserved under the collaboration rules |
| Connection and pending work | No connection, pending transport queue or server acknowledgments | Connect/disconnect, pending changes, acknowledgments and reconnect |
| Storage and restart | Explicit captureState/fromState with document, IDs and History; caller writes storage, no automatic background saving | Ordinary client state is also in memory; durable recovery requires application storage and the documented server/GC protection |
| Schema changes | No local schema-transition API yet | Compatible server/client transition and editor rebuilding within the documented scope |
| Host connection status | Always offline and unsynchronized; local editing still works | Reports the client's current connection and synchronization state |
Both hosts provide apply, commit, selection anchors, composition sessions, undo and redo. A binding can use this common contract, but changing the host does not transfer an existing document, History, pending changes or selection. For a LocalClient, give each native view its own host. Views sharing one client also share its actor and undo stack; they are not independent collaborators.
Offline editing and storage
A disconnected SyncClient is still a synchronized-session client. It retains pending edits and History in memory for reconnect under the existing session rules. LocalClient has no server session to reconnect and no queue to submit. Use it for an in-memory editor, a document preview with editing, or local document transformations.
Local captureState/fromState supplies an explicit save/open value; see local storage. Neither ordinary client automatically makes every edit durable across a page refresh or process exit. A server log alone does not restore a participant's pending changes and undo History. SyncClient checkpoint capture is only part of recovery; valid generation, schema and GC protection must also be established. See synchronization and recovery.
There is no automatic LocalClient-to-SyncClient conversion. Copying a JSON model does not preserve CRDT identities or undo History. Define an explicit import or session handoff before adding collaboration to an existing local document.
Performance scope
LocalClient removes transport and server coordination from the application, but it retains the same CRDT identities, metadata and editor conversion work. It is not a compact JSON-only engine or a promise of faster rendering. The offline comparison records operation counts, original timing flags and narrower repeated checks. It does not establish general browser latency or a networked SyncClient speedup.
Create and edit a document
import {type Schema} from '@barocss/doc-sync';
import {LocalClient} from '@barocss/doc-sync/local';
const schema: Schema = {
root: 'document',
marks: ['bold'],
nodes: {
document: {shape: 'flow', children: ['paragraph'], default: 'paragraph'},
paragraph: {shape: 'text'},
},
};
const client = new LocalClient({schema});
client.edit([{op: 'insert', at: [0, 0], content: 'Hello'}]);
client.edit([{op: 'update', at: [0, 0], length: 5, attrs: {bold: true}}]);
client.undo(); // Remove the formatting; keep the text.
client.redo();
const model = client.model();Each convenience edit() applies and records one action. Explicit typing options can group actions through the existing History rules. The returned operations are already applied. Do not commit them again. model() returns a borrowed immutable view; do not modify it.
An actor is generated with crypto.randomUUID() unless supplied. A custom actor must be a nonempty identifier containing ASCII letters, digits, _, ~ or -; ~root is reserved. Actor identifiers are separate from display names.
Connect a native editor
import {LocalEditorHost} from '@barocss/doc-sync/local';
import {PmHostBinding, buildPmSchema} from '@barocss/doc-sync-prosemirror';
const host = new LocalEditorHost(client);
const binding = new PmHostBinding(host, buildPmSchema(schema));
binding.dispatch(binding.state.tr.insertText('!', 6));
// External application edits also update this binding.
client.edit([{op: 'insert', at: [0, 0], content: 'Local: '}]);
// Release the view and then the in-memory client when the application is done.
binding.dispose();
client.dispose();createLocalEditorHost(client) is equivalent to constructing the host. SlateHostBinding and LexicalHostBinding accept the same host contract. Give each native editor view its own LocalEditorHost. Several hosts can share one client. Sharing one host across views shares its origin and suppresses events for those views, so it is not supported.
A host identifies native edits by origin. Its own binding already applied those edits, while other hosts receive model events. client.edit() represents an external edit and notifies subscribed views. This avoids applying native input twice. Tiptap accepts Client or LocalClient directly and creates its own host. It does not expose a separate HostBinding constructor.
Local host status is {online: false, synchronized: false}. Its presence list is empty, and native selection reports do not send network messages. The false connection flags do not disable local editing.
Share a document between local editors
Two editor views can edit one LocalClient. Create a separate host for each view:
import {LocalClient, createLocalEditorHost} from '@barocss/doc-sync/local';
const shared = new LocalClient({schema});
const firstHost = createLocalEditorHost(shared);
const secondHost = createLocalEditorHost(shared);Pass each host to its own native binding, such as a ProseMirror binding and a Slate binding. Both views read the same document. An edit from either view updates the other through local model events, without a server. Each view keeps its native selection, but both views share one actor and one undo/redo History. Undo from either view acts on that shared History.
This works within one JavaScript runtime. Two independent LocalClient instances do not exchange or merge edits, even if they use the same actor. Separate tabs, workers or processes require an explicit communication and synchronization design; LocalClient supplies no such transport. The demo's default independent mode keeps its panels separate. Select Shared document, or append ?mode=shared to the printed preview URL, to try four views of one local document.
Capture a selection
const saved = client.captureSelection({anchor: [0, 5], focus: [0, 1]});
client.edit([{op: 'insert', at: [0, 0], content: 'A'}]);
const restored = client.resolveSelection(saved);Paths and offsets use UTF-16 units. Captured anchors retain forward, backward and collapsed ranges through edits when both ends remain visible. A hidden or unavailable endpoint resolves to null. Native bindings keep their own selection state; LocalClient supplies capture and resolution rather than a global caret.
Low-level edits and composition
Bindings use host.apply() followed by host.commit(). Apply changes the live engine immediately; commit records and publishes notifications once. Commit must include all pending operation objects in application order. A concatenated array is allowed. Copies, partial batches, reordered batches, repeated commits and a different origin are rejected. An uncommitted batch blocks unrelated convenience edits and history actions until committed. It is not a rollback transaction.
Composition sessions keep a private draft, reserve clocks and publish their retained operations once at end(). Cancel discards the draft and keeps live edits. An external live edit during a draft retains its identity. A composition is one undo group by default. Passing null as the session record option omits those operations from History; mixed sessions record only the requested subset.
Observe changes and errors
const stop = client.subscribe({
modelEvents(edits, info) {
// info.cause is 'local', 'undo' or 'redo'.
},
update() {
const {canUndo, canRedo} = client.historyStatus();
},
});
client.onNotificationError = error => console.error(error.phase, error.message);
stop();Post-commit observer errors do not undo an accepted edit or stop other observers. They are exposed through notificationErrors and onNotificationError. The log retains at most 100 message-only entries. A mutation attempted inside an observer is rejected; schedule follow-up edits after notification returns. Disposing the client ends editing, cancels active drafts and releases subscriptions. Session cancellation remains safe during later binding cleanup.
Scope
The active engine is memory-only. captureState/fromState can save and open its document and History through caller-managed storage. Closing or refreshing does not save edits automatically. The opt-in unified demo reopens the last successful explicit Save. Background saving, synchronized recovery, local schema transitions, automatic conversion to a synchronized session and a Rust/WASM backend are separate work. It retains CRDT identities and metadata; it is not a compact JSON-only model.
The existing SyncClient path and concurrent editing rules remain unchanged. The local model-event cause extends the editor contract; it does not add a server wire event or pretend that a server has verified the document.