Browser cursor jumping in Chrome when +1 decorator plugins involved

I’m finding a similar bug to the one discussed here Native browser cursor "jumps" when there is a widget at the same position and seemingly fixed here https://github.com/ProseMirror/prosemirror/issues/710

In other words the browser cursor jumps back to just after the last dynamically added inline decoration and after that it begins to do so only from time to time.

The odd part is that it only happens when I have 2 plugins dynamically adding decorations, if I exclude any of them or simply stop adding decorations from them it just works fine.

Relevant code from the plugins:

Visible spaces:

export default new Plugin({
  state: {
    init(_, { doc }) {
      let ranges = [[0, doc.content.size]],
          spacesDecorations = getNewSpaceDecorations(ranges, doc);

      return DecorationSet.create(doc, spacesDecorations);
    },
    apply(tr, set, _, state) {
      if (tr.docChanged) {
        let insertedRanges = getInsertedRanges(tr),
            decorationsToAdd = getNewSpaceDecorations(insertedRanges, state.doc);

        return set.map(tr.mapping, tr.doc).add(state.doc, decorationsToAdd);
      }

      return set;
    }
  },
  props: {
    decorations(state) { return this.getState(state); }
  }
});

Custom spellchecker:

export const spellcheckPluginGenerator = (editorSpellingMistakes) =>  new Plugin({
  state: {
    init: function (_, state) { return decorateStateWithMistakes(state, editorSpellingMistakes) },
    apply: function (tr, old, oldState, newState) {
      let editorSpellingMistakes;

      if (tr.meta.dictionaryChangeSpellingMistakes) {
        editorSpellingMistakes = tr.meta.dictionaryChangeSpellingMistakes;
      } else if (tr.meta.editorSpellingMistakesToAppend) {       
        if (!!window.chrome || window.ENV_TEST) document.getSelection().empty();
        editorSpellingMistakes = [...tr.meta.editorSpellingMistakesToAppend, ...this.props.editorSpellingMistakes(oldState)];
      } else {
        return {
          decorations: old.decorations.map(tr.mapping, tr.doc),
          editorSpellingMistakes: old.editorSpellingMistakes,
        };
      }

      return decorateStateWithMistakes(newState, editorSpellingMistakes);
    }
  },
  props: {
    decorations: function (state) {
      return this.getState(state).decorations;
    },
    editorSpellingMistakes: function (state) {
      return this.getState(state).editorSpellingMistakes;
    },
  }
});

function decorateStateWithMistakes(state, editorSpellingMistakes) {
  let decorations = [],
      rx = mistakesRegexp(editorSpellingMistakes),
      { openTag, closeTag, selfclosedTag } = state.schema.nodes,
      tagTypes = [openTag, closeTag, selfclosedTag];

  if (!editorSpellingMistakes.length) return { decorations: DecorationSet.empty, editorSpellingMistakes };

  state.doc.descendants((node, currentPosition, parent) => {
    let match;

    if (!node.isText || tagTypes.includes(node.type) || tagTypes.includes(parent.type)) return;

    while (match = rx.exec(node.text)) {
      let from = currentPosition + match.index,
          to = currentPosition + match.index + match[0].length;

      decorations.push(
        Decoration.inline(
          from, to,
          { class: "translation_ui__form__textarea__error", "data-from": from, "data-to": to },
        )
      );
    }
  });

  return {
    decorations: DecorationSet.create(state.doc, decorations).map(state.tr.mapping, state.doc),
    editorSpellingMistakes
  };
}

This spellchecker also includes the only way I had to fix the issue. Using https://github.com/ProseMirror/prosemirror/issues/710#issuecomment-338047650 proposal:

if (!!window.chrome || window.ENV_TEST) document.getSelection().empty();

If you can further reduce the reproduction case (it’s likely caused by multiple decorations being added/removed at the same time, not necessarily from different plugins) so that it shows the issue in a deterministic way without too many moving parts, I can see if I can find a workaround for this.

Sorry for the delay, it took me a while to reduce it as much as possible.

You were right it is unrelated with having multiple plugins and it seems to be related with decorations being added in almost every interaction.

You can play with the code here https://glitch.com/~clever-slender-basil and reproduce the issue just by typing “12” at the end of the textarea.

Observations while doing this reduction:

var i = 0;

const ranges = [
  [0,4],
  [4,7],
  [10,15],
];

const spellcheckPlugin = new Plugin({
  state: {
    init: function (_, state) { return DecorationSet.empty },
    apply: function (tr, old, oldState, newState) {
      if (!tr.docChanged) return old;
                  
      let [from, to] = ranges[i++ % ranges.length],
          decorations = [
        Decoration.inline(
          from, from + 5,
          { class: "translation_ui__form__textarea__error" },
        )  
      ];
      
      return DecorationSet.create(newState.doc, decorations);
    }
  },
  props: {
    decorations: function (state) {
      return this.getState(state);
    },    
  }
});
  • The values in the ranges influences when the jump happens. During the reduction I tried with a random range generator instead and while the bug still happened it was not consistent.
  • The cursor location is relevant. The only way to reproduce the bug in this instance is by typing at the end of the input.
  • That doesn’t mean that it only happens when it is at the end of the input, while using it in my spellchecker I managed to reproduce it while typing in other positions.

Thanks for the reproduction script! This patch should help (released as prosemirror-view 1.15.1)

Oh, that’s great news! Thank you!

Sadly while it works for the simplified example I still find it failing with my original code :frowning:

I guess I need to go back to try to get a simplified reproduction.

I updated the same script https://glitch.com/edit/#!/clever-slender-basil?path=index.js%3A1%3A0

Sadly it is not as simple as I would like but this is the best reproducible one I could come up with.

The interesting bits:

  • In this case the mix of plugins/Simultaneous addition of decorations is required. If I comment out visibleSpaces plugin the bug does not occur.
  • For the bug to happen it requires a setMeta to be triggered, if I use the previous approach that only triggers on docChanged it works fine.
  • This time to reproduce we just need to put our cursor at the end of the textarea, no typing required, the infinite loop of appendMistake will change the position of the cursor on its own.

Argh, what a pain. It seems that even when DOM changes to the cursor’s parent node aren’t directly next to the cursor, the Chrome bug still shows up. Could you try the current prosemirror-view master branch to see if the patch I just pushed does a better job?

I can, but I am not quite sure how to do it without a version bump.

Hm. Here’s a built version: https://gist.github.com/marijnh/8c180c05d0f70e8c725507a42b1a4b87

It seems to work :grinning:

All right. Released as 1.15.2