Skip to Content

Local (offline, multi-tab)

An offline backend with no server: the same OT engine as Pitter Patter (prosemirror-collab-commit), with the authority running in the browser against IndexedDB. Tabs of one browser co-edit in real time, and every confirmed keystroke is persisted.

PackageContents
@stesura/collab-adapter-localcreateLocalCollabBackend (IndexedDB store, BroadcastChannel signals, in-browser authority), LocalListener, typed errors
@stesura/collab-adapter-reactuseLocalCollabEditor, LocalCommentProvider / LocalThreadStore for comments. Re-exports the local package.

How it works

┌──────────────── tab A ────────────────┐ ┌──────────────── tab B ────────────────┐ │ collab plugin (versions, rebase) │ │ collab plugin (versions, rebase) │ │ CollabClient ──▶ local authority │ │ CollabClient ──▶ local authority │ │ ▲ │ (orders, rebases,│ │ ▲ │ │ │ └ LocalListener┤ persists) │ │ └ LocalListener┤ │ └────────────────────┼──────────────────┘ └────────────────────┼──────────────────┘ ▼ ▼ ┌─────────────────────────── browser ───────────────────────────┐ │ IndexedDB (docs + commits — plays Postgres: serialized │ │ readwrite transactions = the row lock, │ │ [docId, version] primary key = UNIQUE backstop) │ │ BroadcastChannel (plays Redis pub/sub: commit wake-ups) │ └────────────────────────────────────────────────────────────────┘

Each tab runs the same client stack as server collab; sending a commit is a function call instead of an HTTP POST. IndexedDB gives the guarantees Postgres gives the server: readwrite transactions on the same stores serialize across tabs, and the [docId, version] primary key rejects a losing concurrent commit, which then rebases and retries.

Snapshots. The store persists each commit’s steps and writes a full snapshot every snapshotInterval commits (default 50). A load rebuilds the head from the latest snapshot plus the commits after it; commits older than the previous snapshot are pruned. The commit log is the durability journal: a tab killed mid-typing reloads to its last confirmed keystroke.

Client mount

From the IndexedDB demo  (open it in two tabs):

"use client"; import { StesuraEditor, stesuraNodeViews, StesuraUserProvider } from "@stesura/editor-react"; import { useLocalCollabEditor } from "@stesura/collab-adapter-react"; const Editor = () => { const { editorState, dispatch, pluginFactory, schema, docStatus } = useLocalCollabEditor({ docId: "my-document", extraPlugins: () => [ // ...trackChanges, pagination, references plugins ], }); if (docStatus === "deleted") return <DocumentRemovedState />; return ( <StesuraEditor state={editorState} dispatchTransaction={dispatch} schema={schema} pluginFactory={pluginFactory} nodeViews={stesuraNodeViews} uiExtensions={uiExtensions} /> ); }; export default function Page() { return ( <StesuraUserProvider currentUser={user} resolveUsers={resolveUsers}> <Editor /> </StesuraUserProvider> ); }
  • No provider: pass docId to the hook. Other arguments match useCollabEditor, except getPresenceDisplayName and initialContent (seeding is the backend’s createInitialDoc).
  • docStatus is "loading" | "ready" | "deleted" | "error". "deleted": the doc was removed (backend.deleteDoc); sync has stopped. "error": a failure no retry fixes (a missing migration, a corrupt store, a closed backend) or the retry limit was reached.
  • A tab that falls behind the retained commits (asleep for more than a snapshot interval of other tabs’ commits), or whose doc was migrated by another tab, reloads from the snapshot automatically. Its unconfirmed steps are lost in that case.
  • Undo is plain prosemirror-history: it reverts only this tab’s edits, mapped over the other tabs’ commits.

By default every hook on the same schema shares one backend (database "stesura-collab-local"). Pass your own for control; it must be referentially stable:

const backend = useMemo( () => createLocalCollabBackend({ schema, dbName: "my-app", snapshotInterval: 50, // Seed for docIds that don't exist yet. Backend-level, so every tab seeds the same v0 doc. createInitialDoc: (schema) => myTemplateJSON, schemaVersion: 2, migrate: (docJSON, from, to) => upgradeDoc(docJSON, from, to), onError: reportToSentry, }), [schema] ); useLocalCollabEditor({ docId, backend });

Other options: listenTimeoutMs (default 30 s, see below). The backend also manages documents: listDocs(), hasDoc(docId), deleteDoc(docId), and exportDoc(docId) (the doc’s JSON, e.g. to upload as a server doc’s initial content).

Multi-tab behaviour

  • Convergence. Commits wake other tabs over BroadcastChannel. A tab that misses a wake-up still converges on its next listen timeout (listenTimeoutMs), because IndexedDB, not the channel, holds the truth.
  • Contention. Two tabs editing the same spot at once: the later commit is rebased, and steps that no longer map are dropped, as on the server.
  • Deletes. deleteDoc notifies the other tabs, which stop syncing and report docStatus: "deleted".

Schema versioning

Stored JSON outlives app schemas, and nodeFromJSON throws on unknown node types. Doc rows carry a schemaVersion (default 1). When a load finds an older one, migrate(docJSON, from, to) runs on the doc JSON, the stored commits are discarded (steps can’t be migrated) and the row is re-stamped, all in one transaction, so exactly one tab migrates. With no migrate, or a stored version newer than the app’s, the load throws SchemaMigrationRequiredError.

Typed errors

All exported from @stesura/collab-adapter-local (and re-exported by collab-adapter-react). Each sets name, so match by name rather than instanceof across bundles.

ErrorMeaning
SchemaMigrationRequiredErrorStored schemaVersion differs and no migrate applies
DocMigratedErrorAnother tab migrated the doc under this one; reload (the hook does)
DocDeletedErrorThe doc row is gone (hook: docStatus: "deleted")
StaleClientErrorThe client is behind the retained commits; reload (the hook does)
LocalStoreCorruptErrorThe retained commits can’t rebuild the head; terminal
LocalStoreConfigErrorThe schema can’t build an initial document; configuration error
LocalBackendClosedErrorUsed after close()
LocalContentionErrorLost the IndexedDB version race past the retry budget; treat as a bug

Comments

useLocalCollabEditor wires no comment adapter. Mount LocalCommentProvider with the same docId for IndexedDB-backed threads that sync across tabs:

<StesuraUserProvider currentUser={user} resolveUsers={resolveUsers}> <LocalCommentProvider docId="my-document"> <Editor /> </LocalCommentProvider> </StesuraUserProvider>

Pass the same backend as the hook if you use a custom one: threads are stored per dbName, and deleting a doc purges its threads through the backend’s deletion signal (while a provider for that doc is mounted). onError is supported as on the other providers. Under the hood it is the same createCollabCommentAdapter as the server tier, over a LocalThreadStore.

Storage caveats

  • Data lives in the origin’s storage: clearing site data deletes documents, and the browser may evict it under storage pressure. The hook calls backend.requestPersistence() (navigator.storage.persist()) when it connects. Back up anything precious with exportDoc.
  • BroadcastChannel needs Safari 15.4 or later.

Not included

Presence cursors across tabs, the shared proofread cache and the connection-status hooks are server-tier features. Syncing a local doc to a server is not built; exportDoc is the seam for it.

Next steps

Last updated on