Skip to content

Applying a mismatched transaction ERROR #7316

@spacerrsdreams

Description

@spacerrsdreams

Affected Packages

core

Version(s)

3.13.0

Bug Description

Hi,

I am getting Applying a mismatched transaction ERROR, after trying to implement "auto complete" in editor.

Do you know the reason?

if (this.storage.data.currentSuggestion && this.storage.suggestionTriggerPoint) {
            const suggestion = this.storage.data.currentSuggestion;
            const trigger = this.storage.suggestionTriggerPoint;

            this.storage.data = {};
            this.storage.suggestionTriggerPoint = null;

            const { from } = trigger;
            const currentSelection = state.selection;

            const commandChain = editor.chain().focus();

            // Restore cursor to trigger point
            if (currentSelection.from !== from || currentSelection.to !== from) {
              commandChain.setTextSelection(from);
            }

            const { font, maxWidth } = getEditorFontInfo();
            const blocks = splitIntoBlocks({ text: suggestion, maxWidth, font });

            blocks.forEach((block, index) => {
              if (block.length) {
                commandChain.insertContent(block);
              }
              if (index < blocks.length - 1) {
                commandChain.splitBlock();
              }
            });

            commandChain.run();

            return true;
          }

This is the code snippet which is responsible for applying the transaction.

This is whole extension.

/* eslint-disable max-lines */
/* eslint-disable max-lines-per-function */
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";

import { getEditorFontInfo, splitIntoBlocks } from "@/components/features/editor/editor.utils";
import type {
  InlineSuggestionOptions,
  InlineSuggestionStorage,
} from "@/components/features/editor/extensions/inline-suggestion/inline-suggestion.types";
import { fetchAutocompletion } from "@/components/features/editor/extensions/inline-suggestion/utils/fetch-autocompletion";

const SUGGESTION_DELAY = 500;

export const inlineSuggestionPluginKey = new PluginKey("inlineSuggestion");

export const InlineSuggestion = Extension.create<InlineSuggestionOptions, InlineSuggestionStorage>({
  name: "inlineSuggestion",

  addOptions() {
    return {
      enabled: true,
    };
  },

  addStorage() {
    return {
      enabled: this.options.enabled ?? true,
      inflightController: null,
      debounceTimer: undefined,
      suggestionTriggerPoint: null,
      data: {},
    };
  },

  onUpdate() {
    this.storage.enabled = this.options.enabled ?? true;
  },

  addCommands() {
    return {
      setInlineSuggestionsEnabled:
        (enabled: boolean) =>
        ({ editor }) => {
          this.storage.enabled = enabled;

          if (!enabled) {
            this.storage.data = {};
            this.storage.suggestionTriggerPoint = null;

            if (this.storage.inflightController) {
              try {
                this.storage.inflightController.abort?.("disabled");
              } catch {
                // ignore
              }
              this.storage.inflightController = null;
            }

            if (this.storage.debounceTimer) {
              clearTimeout(this.storage.debounceTimer);
              this.storage.debounceTimer = undefined;
            }
          }

          editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));

          return true;
        },

      clearInlineSuggestion:
        () =>
        ({ editor }) => {
          this.storage.data = {};
          this.storage.suggestionTriggerPoint = null;

          if (this.storage.inflightController) {
            try {
              this.storage.inflightController.abort?.("cleared");
            } catch {
              // ignore
            }
            this.storage.inflightController = null;
          }

          if (this.storage.debounceTimer) {
            clearTimeout(this.storage.debounceTimer);
            this.storage.debounceTimer = undefined;
          }

          editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));

          return true;
        },

      fetchSuggestion:
        () =>
        ({ state, editor }) => {
          if (this.storage.data.currentSuggestion && this.storage.suggestionTriggerPoint) {
            const suggestion = this.storage.data.currentSuggestion;
            const trigger = this.storage.suggestionTriggerPoint;

            this.storage.data = {};
            this.storage.suggestionTriggerPoint = null;

            const { from } = trigger;
            const currentSelection = state.selection;

            const commandChain = editor.chain().focus();

            // Restore cursor to trigger point
            if (currentSelection.from !== from || currentSelection.to !== from) {
              commandChain.setTextSelection(from);
            }

            const { font, maxWidth } = getEditorFontInfo();
            const blocks = splitIntoBlocks({ text: suggestion, maxWidth, font });

            blocks.forEach((block, index) => {
              if (block.length) {
                commandChain.insertContent(block);
              }
              if (index < blocks.length - 1) {
                commandChain.splitBlock();
              }
            });

            commandChain.run();

            return true;
          }

          if (!this.storage.enabled) {
            return false;
          }

          const { $from } = state.selection;
          const node = $from.parent;
          const existingText = node.textContent || "";
          const cursorOffset = $from.parentOffset;
          const title = (state.doc.firstChild?.textContent ?? "").trim() || "(Untitled)";

          if (!existingText.trim()) {
            return false;
          }

          if (this.storage.inflightController) {
            try {
              this.storage.inflightController.abort?.("cancelled");
            } catch {
              // ignolre
            }
            this.storage.inflightController = null;
          }

          const controller = new AbortController();

          this.storage.inflightController = controller;

          const triggerFrom = state.selection.from;
          const blockId = node.attrs.id as string | undefined;

          this.storage.suggestionTriggerPoint = {
            from: triggerFrom,
            blockId: blockId ?? null,
          };

          fetchAutocompletion({
            data: {
              existingText,
              cursorOffset,
              title,
            },
            signal: controller.signal,
            onSuggestion: (suggestion: string) => {
              const triggerPoint = this.storage.suggestionTriggerPoint?.from ?? triggerFrom;
              const currentCursorPos = editor.state.selection.from;

              //! Only show suggestion if cursor is still at trigger position
              if (currentCursorPos !== triggerPoint) {
                this.storage.data = {};
                this.storage.inflightController = null;

                return;
              }

              this.storage.data = {
                currentSuggestion: suggestion,
                nodeDetails: { from: triggerPoint, to: triggerPoint },
              };

              editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
            },
            onComplete: () => {
              const triggerPoint = this.storage.suggestionTriggerPoint?.from ?? triggerFrom;
              const currentCursorPos = editor.state.selection.from;

              if (currentCursorPos !== triggerPoint && this.storage.data.currentSuggestion) {
                this.storage.data = {};
              }

              this.storage.inflightController = null;
              editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
            },
            onError: () => {
              this.storage.data = {};
              this.storage.inflightController = null;
              editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
            },
          });

          return true;
        },
    };
  },

  addProseMirrorPlugins() {
    const getStorage = () => this.storage;
    const editor = this.editor;
    const fetchSuggestion = () => editor.commands.fetchSuggestion();

    const isInFirstParagraph = (state: typeof editor.state) => {
      const { $from } = state.selection;
      const firstChild = state.doc.firstChild;
      const currentNode = $from.parent;

      return firstChild === currentNode;
    };

    return [
      new Plugin({
        key: inlineSuggestionPluginKey,
        state: {
          init() {
            return DecorationSet.empty;
          },
          apply(tr) {
            const storage = getStorage().data;

            if (storage.currentSuggestion && storage.nodeDetails) {
              const { from } = storage.nodeDetails;
              const suggestionText = storage.currentSuggestion ?? "";

              const decoration = Decoration.widget(
                from,
                () => {
                  const span = document.createElement("span");

                  span.classList.add("inline-suggestion-placeholder");
                  span.setAttribute("data-inline-suggestion", suggestionText);
                  span.setAttribute("aria-hidden", "true");
                  span.style.whiteSpace = "pre-wrap";

                  if (suggestionText.length) {
                    const segments = suggestionText.split(/\n/u);

                    segments.forEach((segment, index) => {
                      if (segment.length) {
                        span.appendChild(document.createTextNode(segment));
                      }
                      if (index < segments.length - 1) {
                        span.appendChild(document.createElement("br"));
                      }
                    });
                  }

                  return span;
                },
                { side: 1, ignoreSelection: true },
              );

              return DecorationSet.create(tr.doc, [decoration]);
            }

            return DecorationSet.empty;
          },
        },
        props: {
          decorations(state) {
            return this.getState(state);
          },
          handleKeyDown(view, event) {
            const storage = getStorage();

            if (event.key === "Tab") {
              event.preventDefault();

              if (!storage.enabled) {
                return false;
              }

              if (!storage.data.currentSuggestion) {
                const { from } = view.state.selection;
                const pos = view.state.doc.resolve(from);
                const blockId = pos.parent.attrs.id as string;

                storage.suggestionTriggerPoint = {
                  from,
                  blockId: blockId ?? null,
                };
              }

              fetchSuggestion();

              return true;
            }

            if (event.key === "Escape") {
              if (storage.data.currentSuggestion) {
                editor.commands.clearInlineSuggestion();

                return true;
              }
            }

            if (storage.data.currentSuggestion) {
              storage.data = {};
              view.dispatch(view.state.tr.setMeta("addToHistory", false));
            }

            return false;
          },
          handleTextInput(view, from, to, text) {
            const storage = getStorage();

            if (!storage.enabled) {
              return false;
            }

            //! Don't trigger on deletions
            if (from !== to) {
              return false;
            }

            if (storage.debounceTimer) {
              clearTimeout(storage.debounceTimer);
              storage.debounceTimer = undefined;
            }

            if (storage.data.currentSuggestion) {
              storage.data = {};
              view.dispatch(view.state.tr.setMeta("addToHistory", false));
            }

            if (storage.inflightController) {
              try {
                storage.inflightController.abort?.("cancelled");
              } catch {
                // ignore
              }
              storage.inflightController = null;
            }

            const lastChar = text[text.length - 1] || "";
            const isSpace = lastChar === " ";

            // Only trigger on space
            if (!isSpace) {
              return false;
            }

            // Check if cursor is at end or followed by whitespace
            const cursorPos = view.state.selection.from;
            const $pos = view.state.doc.resolve(cursorPos);
            const textAfterCursor = $pos.parent.textContent.slice($pos.parentOffset);

            if (textAfterCursor.length > 0 && !/^\s/u.test(textAfterCursor)) {
              return false;
            }

            storage.debounceTimer = setTimeout(() => {
              storage.debounceTimer = undefined;
              const currentState = view.state;

              if (isInFirstParagraph(currentState)) {
                return;
              }

              const triggerFrom = currentState.selection.from;
              const resolvedPos = currentState.doc.resolve(triggerFrom);
              const blockId = resolvedPos.parent.attrs.id as string;

              storage.suggestionTriggerPoint = {
                from: triggerFrom,
                blockId: blockId ?? null,
              };

              fetchSuggestion();
            }, SUGGESTION_DELAY);

            return false;
          },
          handleClick(view, pos) {
            const storage = getStorage();

            if (storage.data.currentSuggestion) {
              const resolvedPos = view.state.doc.resolve(pos);
              const blockId = resolvedPos.parent.attrs.id as string;

              storage.suggestionTriggerPoint = {
                from: pos,
                blockId: blockId ?? null,
              };
            }

            return false;
          },
        },
      }),
    ];
  },
});
Image

Browser Used

Other

Code Example URL

No response

Expected Behavior

It should work just fine without transaction mismatch error...

Additional Context (Optional)

I am using bun, nextjs@16.0.* tiptap@3.13.0 react@19

Dependency Updates

  • Yes, I've updated all my dependencies.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Open SourceThe issue or pull reuqest is related to the open source packages of Tiptap.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions