Autocomplete doesn't properly recalculate position when line size changes

Hi.

I’m creating a feature with emoji for markdown. I need:

  1. widget decoration {block:false} behind parsed Emoji nodes.
  2. Autocomplete to suggest me the emojis.

I created those features, when I complete them using acceptCompletion() from @codemirror/autocompletion then everything works fine. But when I type in the full autocomplete detail, and the widget appears, then the autocomplete popup does some wierd jump and stays in that wierd position. I know this is a bug, because when I do literally anything (scroll, press just control, even adjust volume), then view updates and the popup goes down to the I assume correct position.

I prepared a minimal bug reproduction with emoji Decoration.widget() and autocomplete using completeFromList().

I recorded an example video. What I do in the video:

  1. I type :smile to open emoji popup that works fine
  2. in the video, I press : to type in the full label of the autocomplete
  3. After I press it, the autocomplete does wierd jump
  4. After that - I don’t interact with the editor at all, I simply increase volume on my PC using the volume button on my headphones.
  5. I assume view refreshes and popup jumps to “correct” position

Notice that when I finish typing :, then chrome loads the image, and for a brief time, I believe the img created by widget has height 0 or auto, and that’s why the position is miscalculated.

[video-to-gif output image]

This bug is very hard to reproduce, since I believe it only happens because <img> for brief moment has 0 height. On successive tries chrome will use image from cache, and that image actually right from the start will have to correct size. I was only able to reproduce the issue because I set Disable cache and throttle in chrome settings:

image

The code I used for the demo:

import {autocompletion, completeFromList} from "@codemirror/autocomplete";
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
import {syntaxTree} from "@codemirror/language";
import {EditorState, RangeSet, StateField} from "@codemirror/state";
import {Decoration, EditorView, WidgetType} from "@codemirror/view";

export function attachedEditorView(parent: HTMLElement): EditorView {
  return new EditorView({
    parent,
    state: EditorState.create({
      doc: 'Bug report\n\n :smi',
      extensions: [
        emojiPreview(name => {
          return 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f600.svg';
        }),
        EditorView.theme({
          '.cm-emoji-widget': {
            'max-width': '18px',
            'max-height': '18px',
            'display': 'inline',
          },
        }),
        markdown({base: markdownLanguage}),
        autocomplete(),
      ]
    })
  });
}

function autocomplete() {
  return [
    autocompletion({
      activateOnTyping: true,
      override: [
        completeFromList([':smile:', ':wink:'].map(item => ({
          label: item,
          apply: item,
        }))),
      ],
    })];
}

function emojiPreview(emojiUrl: (name: string) => string) {
  return StateField.define({
    create(state) {
      return widgetsRangeSet(state);
    },
    update(previews, transaction) {
      return transaction.docChanged
        ? widgetsRangeSet(transaction.state)
        : previews.map(transaction.changes);
    },
    provide(field: any) {
      return EditorView.decorations.from(field);
    },
  });

  function widgetsRangeSet(state: EditorState) {
    const widgets = [];
    syntaxTree(state).iterate({
      enter({from, to, name}) {
        if (name === 'Emoji') {
          widgets.push(previewWidget(emojiUrl(state.doc.sliceString(from + 1, to - 1))).range(from));
        }
      },
    });
    return RangeSet.of(widgets);
  }

  function previewWidget(url: string): Decoration {
    return Decoration.widget({widget: new EmojiWidget(url), side: 1});
  }
}

class EmojiWidget extends WidgetType {
  private readonly url: string;

  constructor(url: string) {
    super();
    this.url = url;
  }

  eq(other: EmojiWidget): boolean {
    return other.url === this.url;
  }

  toDOM() {
    const image = document.createElement('img');
    image.className = 'cm-emoji-widget';
    image.src = this.url;
    return image;
  }
}
{
  "dependencies": {
    "@codemirror/autocomplete": "^6.11.1",
    "@codemirror/lang-markdown": "*",
    "@codemirror/language": "*",
    "@codemirror/view": "^6.22.1"
  }
}

The event of the image loading and changing height is not detectable by the editor, leading to it using its old content size measurements until something else happens. I recommend either giving your icons a fixed size in CSS, or attaching a load handler that calls requestMeasure to them.

Can we add a line to CodeMirror Reference Manual documentation, mentioning that changing size of widget decoration doesn’t properly display on its own, and either static height or manual call to requestMeasure() is necessary.