Sync autocomplete

In my app, @codemirror/autocomplete flickers every time a character is typed. I am trying to figure out what options there are for avoiding this, especially because my autocompletion is not even async.

I’ve found update on CompletionResult but can’t get it work. Digging into the code, I notice this in ActiveResult:

  handleUserEvent(tr: Transaction, type: "input" | "delete", conf: Required<CompletionConfig>): ActiveSource {
    let from = tr.changes.mapPos(this.from), to = tr.changes.mapPos(, 1)
    let pos = cur(tr.state)
    if ((this.explicitPos < 0 ? pos <= from : pos < this.from) ||
        pos > to ||
        type == "delete" && cur(tr.startState) == this.from)
      return new ActiveSource(this.source, type == "input" && conf.activateOnTyping ? State.Pending : State.Inactive)
    let explicitPos = this.explicitPos < 0 ? -1 : tr.changes.mapPos(this.explicitPos), updated
    if (checkValid(this.result.validFor, tr.state, from, to))
      return new ActiveResult(this.source, explicitPos, this.result, from, to)
    if (this.result.update &&
        (updated = this.result.update(this.result, from, to, new CompletionContext(tr.state, pos, explicitPos >= 0))))
      return new ActiveResult(this.source, explicitPos, updated, updated.from, ?? cur(tr.state))
    return new ActiveSource(this.source, State.Pending, explicitPos)

This is coded to always remove the autocompletion results unless pos has increased or explicitPos is >= zero. It never calls .update(). ExplicitPos is only for explicit activation (which doesn’t apply). When you type another character as part of the same string/token, only to has increased, so the ActiveResults are discarded and a new query is made.

Update: Rubber ducking strikes again. Seems the issue here was that if you reconfigure the editor extensions while the autocomplete is open, this will cause it to disappear and reappear and make various things go haywire.

If you want to set up a concrete simplified example that shows this going wrong, please file a bug, and I’ll take a look at it.

After setting up a clean slate example, I think technically this behavior is correct from codemirror’s point of view. I was passing in a new autocomplete source with a new override function… so even if it returns the same results, codemirror can’t know this, and treats it as a new source, by immediately discarding the old one. If I pass in the exact same function instance, the reconfiguration doesn’t seem to trigger a flicker.

I was able to avoid the reconfiguration, which was an improperly changing React prop. There was also the possibility of the editor getting remounted, so I think I was seeing state from two different instances. It’s inside a complex app so that’s my fault.

The reason autocomplete was reconfigured was because the surrounding code just makes a fresh Extensions[] array statelessly, even if a particular extension didn’t have any of its parameters change. So I need to manage the autocomplete extension separately I think. That’s fine, just surprising given how seamless the extension system is otherwise.

The main thing that I want to avoid is that there is a dead interval where pressing Enter (in our case Tab) right after typing doesn’t register as an autocomplete because the results have not arrived.

While the sync update API fixes this once the popup is open, it seems it is still possible to sneak in an Enter after 1 letter, if you do it fast enough. I can do so at the bottom here:

It’s even possible to insert an Enter after the results are already on screen for a brief moment, both Safari and Chrome on Mac.

So ideally there would be a way to just have sync sources, not just sync updates.

Yes, autocomplete happens async, and enter will only pick a completion when completions are available, so obviously if you press enter right after inserting a letter, you get a newline, since we’re not changing the meaning of enter before the user has gotten visual feedback that there’s now a completion available. If you want completions to be synchronous and something the user anticipates, you might need to write a different extension for that.

It seems like it would be a minor change to the built-in autocomplete:

    Promise.resolve(active.source(context)).then(result => {

becomes something like

let promise = active.source(context);
let resolved = Promise.resolve(promise);
if (promise === resolved) { /* async */ }
else { /* sync */ }

You wouldn’t need any new config options and the API wouldn’t change. It just means that if there is a single sync source, it is used immediately, seeing as the results have already been computed.

Before the sources are called by the view plugin at all there has already been a debouncing delay, and I’m not interested in changing that—it’s how this extension is designed, and adding a synchronous mode just muddles things up. Also, the code intentionally doesn’t capture keys until a short delay after the completion dialog becomes visible, to avoid surprising users who were already pressing a key. The mental model of completion you are working with here (the user anticipates what will be completed and accepts it before it has even become visible on the screen) is just not the model this code works with.