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.
| Package | Contents |
|---|---|
@stesura/collab-adapter-local | createLocalCollabBackend (IndexedDB store, BroadcastChannel signals, in-browser authority), LocalListener, typed errors |
@stesura/collab-adapter-react | useLocalCollabEditor, 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
docIdto the hook. Other arguments matchuseCollabEditor, exceptgetPresenceDisplayNameandinitialContent(seeding is the backend’screateInitialDoc). docStatusis"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.
deleteDocnotifies the other tabs, which stop syncing and reportdocStatus: "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.
| Error | Meaning |
|---|---|
SchemaMigrationRequiredError | Stored schemaVersion differs and no migrate applies |
DocMigratedError | Another tab migrated the doc under this one; reload (the hook does) |
DocDeletedError | The doc row is gone (hook: docStatus: "deleted") |
StaleClientError | The client is behind the retained commits; reload (the hook does) |
LocalStoreCorruptError | The retained commits can’t rebuild the head; terminal |
LocalStoreConfigError | The schema can’t build an initial document; configuration error |
LocalBackendClosedError | Used after close() |
LocalContentionError | Lost 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 withexportDoc. - 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
- Collaboration overview: backend comparison.
- Pitter Patter: the same engine with a server authority.
- Live demo: IndexedDB .