@convex-dev/prosemirror-sync

中文文档
0.2.7发布于 2026/10/03Apache-2.0TypeScriptES Module近 30 天下载 46.1万

Sync ProseMirror documents for Tiptap using this Convex component.

原始使用文档
文档目录14 节
  1. Pre-requisite: Convex
  2. Installation
  3. Usage
  4. BlockNote editor
  5. Tiptap editor
  6. Notes
  7. Configuring the snapshot debounce interval
  8. Unsynced changes warning
  9. Handling sync errors
  10. Unmounting with unsynced changes
  11. Creating a new document
  12. Transforming the document server-side
  13. Future features
  14. Running the example locally

Convex ProseMirror Component

npm version

This is a Convex Component that syncs a ProseMirror document between clients via a Tiptap extension (that also works with BlockNote).

Add a collaborative editor that syncs to the cloud. With this component, users can edit the same document in multiple tabs or devices, and the changes will be synced to the cloud. The data lives in your Convex database, and can be stored alongside the rest of your app's data.

Just configure your editor features, add this component to your Convex backend, and use the provided sync React hook. Read this Stack post for more details.

Example of editing

Example usage, see below for more details:

function CollaborativeEditor() {
  const sync = useBlockNoteSync(api.prosemirrorSync, "some-id");
  return sync.isLoading ? (
    <p>Loading...</p>
  ) : sync.editor ? (
    <BlockNoteView editor={sync.editor} />
  ) : (
    <button onClick={() => sync.create(EMPTY_DOC)}>Create document</button>
  );
}

For the editor, you can choose to use Tiptap or BlockNote. Go with BlockNote unless you want to customize the editor heavily.

  • Tiptap is based on ProseMirror and saves you a lot of configuration compared to using the ProseMirror editor directly. It has a rich ecosystem of extensions, and is very customizable.
  • BlockNote is based on Tiptap and has a nicer UI and experience out of the box, at the cost of being harder to extend and customize for advanced usecases. If you're looking for the easiest way to get all the fancy formatting options, this is it.
  • Unfortunately, even though they both are based on ProseMirror, the data model differs, so it's not trivial to switch editors later on without migrating all of the data, so you might experiment with both before launching publicly.

Features:

  • Safely merges changes between clients via operational transformations (OT).
  • Simple React hook to fetch the initial document and keep it in sync via a Tiptap extension.
  • Supports both Tiptap and BlockNote.
  • Server-side entrypoints for authorizing reads, writes, and snapshots.
  • Create a new document, online or offline.
  • Debounced snapshots allow new clients to avoid reading the full history.
  • Deletion API for old snapshots & steps.
  • Transform the document server-side, enabling easy AI interoperation.
  • Warning when closing a tab with unsynced changes (enabled by default).

See below for future feature ideas and CONTRIBUTING.md for how to contribute. Found a bug? Feature request? File it here.

Pre-requisite: Convex

You'll need an existing Convex project to use the component. Convex is a hosted backend platform, including a database, serverless functions, and a ton more you can learn about here.

Run npm create convex or follow any of the quickstarts to set one up.

Installation

Install the component package:

npm install @convex-dev/prosemirror-sync

Create a convex.config.ts file in your app's convex/ folder and install the component by calling use:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import prosemirrorSync from "@convex-dev/prosemirror-sync/convex.config.js";

const app = defineApp();
app.use(prosemirrorSync);

export default app;

Usage

To use the component, you expose the API in a file in your convex/ folder, and use the editor-specific sync React hook, passing in a reference to the API you defined. For this example, we'll create the API in convex/example.ts.

// convex/example.ts
import { components } from "./_generated/api";
import { ProsemirrorSync } from "@convex-dev/prosemirror-sync";

const prosemirrorSync = new ProsemirrorSync(components.prosemirrorSync);
export const {
  getSnapshot,
  submitSnapshot,
  latestVersion,
  getSteps,
  submitSteps,
} = prosemirrorSync.syncApi({
  // ...
});

In your React components, you can then use the editor-specific hook to fetch the document and keep it in sync via a Tiptap extension. Note: This requires a ConvexProvider to be in the component tree.

BlockNote editor

IMPORTANT: BlockNote doesn't currently support <StrictMode> in React 19. If you're using React 19, make sure you remove any <React.StrictMode> blocks and set reactStrictMode: false in your next.config.ts if applicable. docs

// src/MyComponent.tsx
import { useBlockNoteSync } from "@convex-dev/prosemirror-sync/blocknote";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { api } from "../convex/_generated/api";
import { BlockNoteEditor } from "@blocknote/core";

export function MyComponent() {
  const sync = useBlockNoteSync<BlockNoteEditor>(api.example, "some-id");
  return sync.isLoading ? (
    <p>Loading...</p>
  ) : sync.editor ? (
    <BlockNoteView editor={sync.editor} />
  ) : (
    <button onClick={() => sync.create({ type: "doc", content: [] })}>
      Create document
    </button>
  );
}

Tiptap editor

In your React components, you can use the useTiptapSync hook to fetch the initial document and keep it in sync via a Tiptap extension. Note: This requires a ConvexProvider to be in the component tree.

// src/MyComponent.tsx
import { useTiptapSync } from "@convex-dev/prosemirror-sync/tiptap";
import { EditorContent, EditorProvider } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { api } from "../convex/_generated/api";

export function MyComponent() {
  const sync = useTiptapSync(api.example, "some-id");
  return sync.isLoading ? (
    <p>Loading...</p>
  ) : sync.initialContent !== null ? (
    <EditorProvider
      content={sync.initialContent}
      extensions={[StarterKit, sync.extension]}
    >
      <EditorContent editor={null} />
    </EditorProvider>
  ) : (
    <button onClick={() => sync.create({ type: "doc", content: [] })}>
      Create document
    </button>
  );
}

See a working example in example.ts and App.tsx.

Notes

Configuring the snapshot debounce interval

The snapshot debounce interval is set to one second by default. You can specify a different interval with the snapshotDebounceMs option when calling useTiptapSync or useBlockNoteSync.

A snapshot won't be sent until both of these are true:

  • The document has been idle for the debounce interval.
  • The current user was the last to make a change.

There can be races, but since each client will submit the snapshot for their own change, they won't conflict with each other and are safe to apply.

Unsynced changes warning

By default, the sync extension shows a browser confirmation dialog when the user tries to close a tab with local changes that haven't been sent to the server. This can be disabled:

const sync = useTiptapSync(api.example, "some-id", {
  warnOnUnsyncedClose: false,
});

Handling sync errors

Pass onSyncError to be told when syncing fails:

const sync = useTiptapSync(api.example, "some-id", {
  onSyncError: (error) => {
    console.error("Sync failed", error);
  },
});

This also covers work that happens in the background, with no caller to propagate a failure to: the debounced snapshot submit, and the flush of unconfirmed steps when the editor is destroyed (see below).

Most errors are informational — the extension keeps syncing, and retries on the next local change or update from the server. A failed snapshot submit in particular costs nothing: snapshots only save replaying steps, and the next one supersedes it.

The exception is "Unsynced steps could not be flushed after destroy", which means local steps never made it to the server before the editor went away. If you haven't passed onSyncError, that one surfaces as an unhandled rejection rather than being dropped silently.

Unmounting with unsynced changes

Local steps are the source of truth until the server confirms them, so when an editor is destroyed with steps still in flight — the user switches documents in your app mid-round-trip, for instance — the extension submits them in the background rather than dropping them. warnOnUnsyncedClose only covers closing the tab, which the browser lets us block; an in-app unmount can't be blocked.

The flush is best-effort: if the request fails (offline, say), the steps are lost and onSyncError is called. To avoid the situation entirely, keep the editor mounted until collab.sendableSteps(editor.state) returns null.

Creating a new document

You can create a new document from the client by calling sync.create(content), or on the server by calling prosemirrorSync.create(ctx, id, content).

The content should be a JSON object matching the Schema. If you're using BlockNote, it needs to be the ProseMirror JSON representation of the BlockNote blocks. Look at the value stored in the snapshots table in your database for an example. Both can use this value: { type: "doc", content: [] }

For client-side document creation:

  • While it's safest to wait until the server confirms the document doesn't exist yet (!sync.isLoading), you can choose to call it while offline with a newly created ID to start editing a new document before you reconnect.
  • When the client next connects and syncs the document, it will submit the initial version and all local changes as steps.
  • If multiple clients create the same document, it will fail if they submit different initial content.
  • Note: if you don't open that document while online, it won't sync.

Transforming the document server-side

You can transform the document server-side. It will give you the latest version of the document, and you return a ProseMirror Transform.

You can make this transoform via new Transform(doc) or, if you are hydrating a full EditorState, you can use Editor.create({doc}).tr to create a new Transaction (which is a subclass of Transform).

For example:

import { getSchema } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";

export const transformExample = action({
  args: {
    id: v.string(),
  },
  handler: async (ctx, { id }) => {
    const schema = getSchema(extensions);
    await prosemirrorSync.transform(ctx, id, schema, (doc) => {
      const tr = EditorState.create({ doc }).tr;
      tr.insertText("Hello, world!", 0);
      return tr;
    });
  },
});
  • The extensions should be the same as the ones used by your client editor, for any extensions that affect the schema (not the sync extension).
  • The transform function can be called multiple times if the document is being modified concurrently. Ideally this callback doesn't do any slow operations internally. Instead, do them beforehand.
  • The doc may differ from the one returned from getDoc. You can compare the version returned from getDoc to the second argument to the transform function to see if the document has changed.
  • The transform function can return a null value to abort making changes.
  • If you're passing along a position to insert the text, be aware that changes happening in parallel may cause the position to change. You can pass more stable identifiers, or use the ProseMirror mapping capabilities to map the position between different versions by fetching the steps between the versions fetched with (component).lib.getSteps.
  • If you're using BlockNote, you'll need to convert the BlockNote blocks between BlockNote blocks and ProseMirror nodes. See blockToNode and nodeToBlock.
import { Transform } from "@tiptap/pm/transform";

// An example of doing slower AI operations beforehand:
const schema = getSchema(extensions);
const { doc, version } = await prosemirrorSync.getDoc(ctx, id, schema);
const content = await generateAIContent(doc);
await prosemirrorSync.transform(ctx, id, schema, (doc, v) => {
  if (v !== version) {
    // Decide what to do if the document changes.
  }
  // An example of using Transform directly.
  const tr = new Transform(doc);
  tr.insert(0, schema.text(content));
  return tr;
});

Future features

Features that could be added later:

  • Offline editing support: cache the document and local changes in sessionStorage and sync when back online (only for active browser tab).
    • Also save snapshots (but not local edits) to localStorage so new tabs can see and edit documents offline (but won't see edits from other tabs until they're back online).
  • Configuration for debouncing syncing steps (to reduce function calls).
  • Option to write the concrete value each time a delta is submitted.
  • Pluggable storage for ReactNative, assuming single-session.
  • Convert it to a ProseMirror plugin instead of a Tiptap extension, so raw ProseMirror usecases can also use it.
  • Handling edge cases, such as old clients with local changes on top of an older version of the document where the steps necessary for them to rebase their changes have since been vacuumed.
  • Type parameter for the document ID for more type safety for folks using Convex IDs as their document IDs. This is available on the server but not browser.
  • Drop clientIds entirely and just use two UUIDs locally to differentiate our changes from server-applied changes.
  • Add an optional authorId to the data model to track who made which changes.

Missing features that aren't currently planned:

  • Supporting documents larger than 1 Megabyte.
  • Offline support that syncs changes between browser tabs or peer-to-peer.
  • Syncing Yjs documents instead of ProseMirror steps. That would be done by a different Yjs-specific component.
  • Syncing presence (e.g. showing other users' names and cursor in the UI). This is another thing a Yjs-oriented ProseMirror component could tackle.
  • Callback to confirm rebases and handle failures in the client (during sync).
  • Optimization to sync a snapshot instead of many deltas when an old client reconnects and doesn't have local changes.
  • Handling multiple AsyncStorage instances that are restored from the same cloud backup, leading to multiple clients with the same clientID. For now, we'll assume that AsyncStorage is only used by one client at a time.

Running the example locally

In one terminal, run:

npm run setup
npm run example

To make changes to the component and rebuild automatically:

npm run setup
npm run dev

版本与发布记录

@convex-dev/prosemirror-sync 共发布 45 个版本,首次发布于 2024/12/04;过去 12 个月发布了 8 个版本。

版本发布日期距今
0.2.7latest2026/10/031 个月前
0.2.62026/07/303 个月前
0.2.52026/06/254 个月前
0.2.42026/05/205 个月前
0.2.32026/03/078 个月前
0.2.22026/02/148 个月前

其他发布通道:alpha → 0.1.28-alpha.0。

关于 @convex-dev/prosemirror-sync 的常见问题

@convex-dev/prosemirror-sync 是什么?

Sync ProseMirror documents for Tiptap using this Convex component.

如何安装 @convex-dev/prosemirror-sync?

在项目目录运行 npm install @convex-dev/prosemirror-sync,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.3.1 || ^19.0.0)、convex(^1.24.8)、react-dom(^18.3.1 || ^19.0.0)、@tiptap/core(^2.7.0 || ^3.10.7)、decode-named-character-reference(^1.3.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@convex-dev/prosemirror-sync 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@convex-dev/prosemirror-sync 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@convex-dev/prosemirror-sync 可以商用吗?

@convex-dev/prosemirror-sync 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@convex-dev/prosemirror-sync 的下载量有多少?

根据 npm 官方下载统计,@convex-dev/prosemirror-sync 在 2026/10/11 – 2026/11/09 这 30 天内被下载 461,418 次,日均约 15,381 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@convex-dev/prosemirror-sync 还在维护吗?

最新版本 0.2.7 发布于 2026/10/03(1 个月前);至今共发布 45 个版本,其中过去 12 个月发布了 8 个;首次发布于 2024/12/04。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。