🦧

Tiptap v3 beta の editor.isActive について

に公開

Tiptap v3のRelease Issueseditor.isActiveが動かない問題が報告されているのでそれについて軽く調べてみます。

Tiptap v2.12.0でセットアップ

pnpm create viteで作ったReact+TypeScriptプロジェクトにTiptapをインストールします。デフォルトではTiptap@^2.12.0がインストールされます。

pnpm create vite
pnpm add @tiptap/pm @tiptap/react @tiptap/starter-kit

とりあえずv2.12.0で動くことを確認します。公式のBold Extensionのサンプルなどを参考にエディターを作ります。

Tiptap.tsx
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

const extensions = [StarterKit];

const content = `
        <p>This isn’t bold.</p>
        <p><strong>This is bold.</strong></p>
        <p><b>And this.</b></p>
        <p style="font-weight: bold">This as well.</p>
        <p style="font-weight: bolder">Oh, and this!</p>
        <p style="font-weight: 500">Cool, isn’t it!?</p>
        <p style="font-weight: 999">Up to font weight 999!!!</p>
      `;

export const Tiptap = () => {
  const editor = useEditor({
    extensions,
    content,
  });

  if (!editor) return null;

  return (
    <>
      <div className="control-group">
        <div className="button-group">
          <button
            onClick={() => editor.chain().focus().toggleBold().run()}
            className={editor.isActive("bold") ? "is-active" : ""}
          >
            Toggle bold
          </button>
          <button
            onClick={() => editor.chain().focus().setBold().run()}
            disabled={editor.isActive("bold")}
          >
            Set bold
          </button>
          <button
            onClick={() => editor.chain().focus().unsetBold().run()}
            disabled={!editor.isActive("bold")}
          >
            Unset bold
          </button>
        </div>
      </div>
      <EditorContent editor={editor} />
    </>
  );
};

pnpm devで開発サーバーを立ちげて、isActiveが動作することを確認できました。

Tiptap v3にする

現時点(2025/5/18)でv3の最新であるv3.0.0-next.8をインストールします。package.jsonのTiptapのバージョンを3.0.0-next.8に書き換えてpnpm iします。

これでisActive動かないことを確認できました。

そもそもの挙動に関する問題

以前書いた記事でも触れましたが、v2.12.0でも上記のサンプルは動かなくなるパターンがあります。
一度Tiptapのバージョンをv2.12.0に戻して以下のようにコンポーネントを書き換えます。editorインスタンスを受け取るコンポーネントをmemo化しています。

少し余談ですがTiptapのドキュメントでもuseEditorによる再レンダリングの影響を抑えるために、エディター以外のコンポーネントを分割してeditorインスタンスをpropsで渡して参照するパターンが紹介されています。

https://tiptap.dev/docs/guides/performance#react-tiptap-editor-integration

Tiptap.tsx
import { memo } from "react";
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

const extensions = [StarterKit];

const content = `
        <p>This isn’t bold.</p>
        <p><strong>This is bold.</strong></p>
        <p><b>And this.</b></p>
        <p style="font-weight: bold">This as well.</p>
        <p style="font-weight: bolder">Oh, and this!</p>
        <p style="font-weight: 500">Cool, isn’t it!?</p>
        <p style="font-weight: 999">Up to font weight 999!!!</p>
      `;

export const Tiptap = () => {
  const editor = useEditor({
    extensions,
    content,
  });

  if (!editor) return null;

  return (
    <>
      <OperationsMemo editor={editor} />
      <EditorContent editor={editor} />
    </>
  );
};

const OperationsMemo = memo(Operations);

function Operations({ editor }: { editor: Editor }) {
  return (
    <div className="control-group">
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive("bold") ? "is-active" : ""}
        >
          Toggle bold
        </button>
        <button
          onClick={() => editor.chain().focus().setBold().run()}
          disabled={editor.isActive("bold")}
        >
          Set bold
        </button>
        <button
          onClick={() => editor.chain().focus().unsetBold().run()}
          disabled={!editor.isActive("bold")}
        >
          Unset bold
        </button>
      </div>
    </div>
  );
}

これでv2.12.0でも動かなくなります。理由としてはeditorインスタンスの比較結果がレンダリング間で変わらないためmemo化により再レンダリングしなくなったためです。memo化していないときに動いていたのはコンポーネントの再レンダリングによりeditor.isActive関数が実行されていたためです。
そしてコンポーネントの再レンダリングに依存するのは得策ではないです。

これを動かすにはやはり「エディター上のイベントを監視できるonUpdateオプションなどでsetStateしてReactコンポーネントを更新する」のが良いです。
今回はeditor.isActive('bold')の結果をエディター上のトランザクションが走るたびにsetStateします。コンテンツの更新だけでなくカーソルの移動なども検知したいため、監視するエディター上のイベントはonTransactionを使います。

Tiptap.tsx
export const Tiptap = () => {
  const [isActiveBold, setIsActiveBold] = useState(false);
  const editor = useEditor({
    extensions,
    content,
    onTransaction: ({ editor }) => {
      setIsActiveBold(editor.isActive("bold"));
    },
  });

  if (!editor) return null;

  return (
    <>
      <OperationsMemo editor={editor} isActiveBold={isActiveBold} />
      <EditorContent editor={editor} />
    </>
  );
};

これでカーソル位置が変わったときにカーソル位置がBoldかどうかという状態をReactのStateに紐付けることができました。このStateはトランザクションの度にsetStateされるのでそれに反応してコンポーネントのUIも更新されます。結果memo化していても動作するようになりました。
この方法はv3.0.0-next.8でも動作します。

ということでTiptapとReactの連携はTiptapのイベントをフックしてsetStateすれば良いというのが個人的な考えです。公式ではuseEditorStateを使う方法も紹介されているのでこちらを使うのも良いかもしれません。

なぜv3だと動かないのか

とはいえ同じサンプルコードでv2からv3で動かないというバグがなぜ発生するのかは気になります。ということで調査してみたところ原因は以下の変更でした。

https://github.com/ueberdosis/tiptap/commit/2ef1c847a960a379f3d067502058567352bf607c

エディターのtransactionの度に再レンダリングしなくなった変更です。shouldRerenderOnTransactionオプションはv2.5.0から追加されたオプションですが、これがv3ではデフォルトでfalseになりtransaction毎に再レンダリングされなくなりました。結果、前述したmemo化の例と同じように再レンダリングによるeditor.isActiveの実行がなくなり、UIにeditor.isActiveの状態が反映されなくなりました。

まとめ

shouldRerenderOnTransactionのdefaltがfalseになったことはパフォーマンス面の強化になっていると思います。とはいえ、エディターの再レンダリングに依存している公式のサンプルなど動かなくなるパターンも存在するため、shouldRerenderOnTransactionオプションの位置付けをTiptapチームがどうしていくのか動向を追いたいと思いました。

Discussion