CM6 - how to get completionFrom for active completion

I currently have the following working albeit hacky code that detects when the auto completion opens/closes together with what options are shown when it opens.

import { completionStatus } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";

const autocompleteListenerExtension = EditorState.changeFilter.of((v) => {
  const startStatus = completionStatus(v.startState);
  const endStatus = completionStatus(v.state);
  if (startStatus !== "active" && endStatus === "active") {
    const { effects } = v;
    if (effects && effects.length === 1) {
      const { value: activeResults } = effects[0];
      if (activeResults.length === 1) {
        const activeResult = activeResults[0];
        const { result } = activeResult;
        const { from, options } = result;
        onAutocompleteOpenChanged(true, { from, options });
      }
    }
  } else if (startStatus !== null && endStatus === null) {
    onAutocompleteOpenChanged(false);
  }
}

I have a couple of questions about how it could be improved and made less hacky:

  1. Is there something better than EditorState.changeFilter to detect changes to the completion state more specifically? (I tried and failed to find one if it exists).

  2. Assuming using that using Editor.changeFilter is the right approach, I was hoping there was some state accessor like “currentCompletionsFrom” to match the available “currentCompletions” that’s exported by the autocomplete package. If there is something like that, then I could improve the code above to the following:

// same imports as above, plus:
import { currentCompletions } from "@codemirror/autocomplete";

const autocompleteListenerExtension = EditorState.changeFilter.of((v) => {
  const startStatus = completionStatus(v.startState);
  const endStatus = completionStatus(v.state);
  if (startStatus === "pending" && endStatus === "active") {
    const options = currentCompletions(v.state);
    const from = currentCompletionsFrom(v.state); // THIS ONE IS MISSING?
    onAutocompleteOpenChanged(true, { from, options });
  } else if (startStatus === "active" && endStatus !== "active") {
    onAutocompleteOpenChanged(false);
  }
}

Any advice on how to improve this code would be greatly appreciated, thanks!

Yes. A change filter is a very odd place to do this—they should not have side effects, and are run when applying a transaction, which doesn’t necessarily mean that new state is going to end up in the view (it may be filtered, or just created but never dispatched). Possibly, depending on what you want to do with the information, an update listener would work better.

I don’t understand what you’re asking here. currentCompletions exists, and gives you the completions in a given state.

Ah yes, my code already has an update listener for something else and I’ve confirmed it works if I move the code there, thanks for that suggestion!

As for the currentCompletionsFrom, I was trying to show that my callback expects to have both the completions AND the position in the document where they all start from.

I can get the list of completions using currentCompletions, but then I’m still missing the detail of the from position in the document for all those completions.

I was hoping to find a way to retrieve that information so I can remove the hacky code above that finds the from position by parsing the effects object.

Ah, you are looking for the minimum from position. What are you using that for?

We had e2e tests against cm5 that triggered the autocompletion, and tested whether it updated properly when some external changes modify the completion suggestions, to do that we have an onAutocompleteChanged(open, openFrom, openItems) type of setup to make sure it’s doing exactly what is expected.

const changedArgs = [];

const autocompleteChanged = (open, openFrom, openItems) = > {
  changedArgs.push({ open, openFrom, openItems });
};

editor.onAutocompleteChanged(autocompleteChanged);
editor.setPosition(5);
editor.showAutocomplete();
editor.setAutocompleteSchema({ ... });

expect(changedArgs.length).toBe(2);
expect(changedArgs[0]).toEqual({ open: true, openFrom: 5, openItems: [{ ... }] });
expect(changedArgs[1]).toEqual({ open: true, openFrom: 4, openItems: [{ ... }] });

I’m getting the impression that this from value is currently not easily accessible because there wasn’t a motivation to export a state accessor function somewhere?

Are you sure you’re getting all that much added value from testing the completion position here? You could set up a unit test that calls your completion source directly to confirm that that part works.

I agree that unit tests on the completion extension would give good coverage, but we have an existing e2e test suite that we were running against our cm5 implementation, and it’s really nice to be running the same e2e test suite against our new cm6 implementation to make sure there are no regressions.

In case anyone is interested, here is the solution I settled on after this discussion:

import { EditorView } from "@codemirror/view";
import { completionStatus } from "@codemirror/autocomplete";

const onAutocompleteChanged = (open:boolean, from:number, options:[{ type?:string, label?:string, detail?:string }]) => {
  // fired whenever open, from, options change
};

const updateListenerExtension = EditorView.updateListener.of((v) => {
  const startStatus = completionStatus(v.startState);
  const endStatus = completionStatus(v.state);
  if (startStatus !== "active" && endStatus === "active") {
    const { transactions } = v;
    const autocompleteResults = [];

    for (let transaction of transactions) {
      const { effects } = transaction;
      if (effects) {
        for (let effect of effects) {
          const { value: values } = effect;
          if (values) {
            for (let value of values) {
              const { result } = value;
              if (result && typeof result === "object") {
                const { from, options } = result;
                if (from !== undefined && options !== undefined) {
                  autocompleteResults.push({ from, options });
                }
              }
            }
          }
        }
      }
    }

    if (autocompleteResults.length > 0) {
      if (autocompleteResults.length > 1) {
        console.error(
          "multiple autocomplete results found in update transactions"
        );
      }
      const { from, options } = autocompleteResults[0];
      onAutocompleteChanged(true, from, options);
    }
  } else if (startStatus !== null && endStatus === null) {
    onAutocompleteChanged(false);
  }
});