Code example for applying decorations asynchronously

I’m trying to figure out the proper way to apply decorations asynchronously. In my case, I have a spell-checking library that only works async. There are a couple of threads that already talk about this (I’ve linked them below), but they don’t have many details on how to do it or any code examples. I’ve spent a few days attempting to solve it, but I’ve gotten nowhere.

Is there a simple example of how to apply decorations async? This is a common need for linting, spell checking, grammar checking, etc. So an example of the proper way to do it would be immensely helpful. Maybe it could be added to the example section of the docs?

Related Threads:

2 Likes

Not really, but there’s not a lot to it. Start your request as appropriate (probably a debounce on document updates) from a view plugin. When it returns, dispatch a transaction with metadata that tells your state field to add the decorations. Either drop responses that are outdated, or record mappings in order to be able to map them to the updated document.

There is a github gist in the Asynchronous Initialization one, but here’s an updated version for syntax highlighting: ProseMirror CodeBlock Syntax Highlighting using CM6 · GitHub. It’s asynchronous because we’re dynamically importing languages.

2 Likes

Thanks for the replies! I’ve implemented what you’ve suggested, but it creates an infinite loop the minute the doc changes. I think the dispatch function calls the view update, which then calls dispatch again. What am I missing?

function update(view) {
    const { doc } = view.state.tr
    // Find all nodes with text to send to the spell checker
    const matching = findBlockNodes(doc).filter(item => item.node.isTextblock && !item.node.type.spec.code)

    // Simulate sending the text to a spell checker
    setTimeout(() => {
        console.log('UPDATE');

        let newDecs = decorateNodes(matching)
        view.dispatch(view.state.tr.setMeta("asyncDecorations", newDecs))
    }, 100)
}

let debouncedUpdate = debounce(update, 1000)

let plugin = new Plugin({
    props: {
        decorations(state) { return this.getState(state) },
    },
    state: {
        init: (config, state) => {
            return DecorationSet.create(state.doc, [])
        },
        apply: (tr, decorationSet) => {
            const asyncDecs = tr.getMeta("asyncDecorations")

            if (asyncDecs === undefined && !tr.docChanged) {
                return decorationSet
            }

            console.log('received async decorations', asyncDecs);

            return DecorationSet.create(tr.doc, asyncDecs)
        },
    },
    view: function(view) {
        return {
            update(view, prevState) {
                debouncedUpdate(view)
            }
        }
    }
})

I think the dispatch function calls the view update, which then calls dispatch again. What am I missing?

Yeah, that seems to be the issue. Instead of calling the api from within update, you want to initialize the debounced API request from within the apply method, and just use the update method of the view plugin to keep the plugin up to do with the editor.

E.g.


let EditorView;

const apiRequest = (matching) => {
  setTimeout(() => {
    let newDecs = decorateNodes(matching)
    EditorView.dispatch(EditorView.state.tr.setMeta("asyncDecorations", newDecs))
  }, 100);
}

let debouncedApiRequest = debounce(apiRequest , 1000);

let plugin = new Plugin({
    props: {
        decorations(state) { return this.getState(state) },
    },
    state: {
        init: (config, state) => {
            return DecorationSet.create(state.doc, [])
        },
        apply: (tr, decorationSet) => {
            if (tr.docChanged) {
              const { doc } = view.state.tr
              // Find all nodes with text to send to the spell checker
              const matching = findBlockNodes(doc).filter(item => item.node.isTextblock && !item.node.type.spec.code);
              debouncedApiRequest(matching);
            }

            const asyncDecs = tr.getMeta("asyncDecorations")
            if (asyncDecs === undefined && !tr.docChanged) {
                return decorationSet
            }

            console.log('received async decorations', asyncDecs);

            return DecorationSet.create(tr.doc, asyncDecs)
        },
    },
    view: function(view) {
        return {
            update(view, prevState) {
                EditorView = view;
            }
        }
    }
})
5 Likes

Thanks! That helped me get going in the right direction.

This seems to be a theme in both answers (assigning a global to track the EditorView) because I think ProseMirror state update cycle is flawed – you need to have access to EditorView from within state.apply in order to dispatch additional transactions.

Normally, in multiple systems I’ve worked with, action handlers accept an action and state, may or may not end up directly changing state via a return, and may or may not be async (which can hold up the main promise chain/queue thread), but often dispatch and initiate async chains, which then handle their success/error cases by dispatching at a later time. This whole system of ‘update the state first’ seems flawed because all data sent in the transaction needs to be cached in state purely so that the view.update knows which params to invoke the handler with. And even in this case, the view.update cannot directly set state by returning. It’s like a two part system, and it’s backwards if anything, when really it should be one update function that runs on any transaction. And that one function should potentially be async, though there are very few cases where you actually want to await and block the thread.

This just sounds like you’re trying to use apply, which is there for pure functions that update state components, in a way it isn’t intended to be used. There’s view plugins for imperative code.

1 Like

Yeah exactly, don’t think it’s intended to be used this way, and I wish I understood why, but I found a pattern that has been working well – allowing anything async to ‘fall through’ to the view.update phase. I wrap my Action objects (function name and parameters) as Transactions via meta field

export function createTypeaheadPlugin(): Plugin<TypeaheadState> {
  return new Plugin({
    key: typeaheadPluginKey,
    state: {
      init: initialState,
      /** set state, determine actionsToInvoke which can dispatch at a later time */
      apply: (transaction, state) => {
        if (isTypeaheadTransaction(transaction)) {
          const newState = onTransaction(transaction, state);
          return clearStaleActions(newState, state);
        } else {
          const newState = onEditorChange(transaction, state);
          return clearStaleActions(newState, state);
        }
      },
    },
    view: () => ({
      /** invoke actions which can dispatch at a later time */
      update: (view) => {
        const state = typeaheadPluginKey.getState(view.state);
        state?.actionsToInvoke.forEach((action) => {
          onAction(action, view);
        });
      },
    }),
    props: {
      decorations(editorState) {
        return (
          typeaheadPluginKey.getState(editorState)?.decorationSet ||
          DecorationSet.empty
        );
      },
    },
  });
}

clearStaleActions helper clears actionsToInvoke if it hasn’t changed since the last one


export const typeaheadPluginKey = new PluginKey<TypeaheadState>('typeahead');
export const enum TT {
  AcceptEntireSuggestion = 'TT_ACCEPT_ENTIRE_SUGGESTION',
  AcceptOneWord = 'TT_ACCEPT_ONE_WORD',
  CycleSelectionIndex = 'TT_CYCLE_SELECTION_INDEX',
  FetchSuggestionsSuccess = 'TT_FETCH_SUGGESTIONS_SUCCESS',
  HideTypeahead = 'TT_HIDE_TYPEAHEAD',
}
type TypeaheadTransactions = {
  [TT.AcceptEntireSuggestion]: AcceptEntireSuggestion;
  [TT.AcceptOneWord]: AcceptOneWord;
  [TT.CycleSelectionIndex]: CycleSelectionIndex;
  [TT.FetchSuggestionsSuccess]: FetchSuggestionsSuccess;
  [TT.HideTypeahead]: HideTypeahead;
};
export type GenericTypeaheadTransaction<Action> = Transaction & {
  getMeta(key: PluginKey<TypeaheadState>): Action;
};
export type TypeaheadTransaction = TypeaheadTransactions[keyof TypeaheadTransactions]; // prettier-ignore
export function getAction<T extends TypeaheadTransaction>(tx: T) {
  return tx.getMeta(typeaheadPluginKey) as ReturnType<T['getMeta']>;
}
export function isTypeaheadTransaction(
  transaction: Transaction
): transaction is TypeaheadTransaction {
  return !!transaction.getMeta(typeaheadPluginKey);
}
export const enum TA {
  FetchSuggestions = 'TA_FETCH_SUGGESTIONS',
  WriteText = 'TA_WRITE_TEXT',
}
type TypeaheadActions = {
  [TA.FetchSuggestions]: FetchSuggestions;
  [TA.WriteText]: WriteText;
};
/** used to enforce actionsToInvoke: TypeaheadAction[] */
export type TypeaheadAction =
  | TypeaheadActions[keyof TypeaheadActions] // dispatch Action -> onAction
  | ReturnType<TypeaheadTransaction['getMeta']> // dispatch Action -> onTransaction;
export function dispatch(view: EditorView, action: TypeaheadAction) {
  view.dispatch(view.state.tr.setMeta(typeaheadPluginKey, action));
}

Sorry for so much code, but hopefully it helps other people. It just allows state.apply / view.update to invoke onTransaction and onAction at later times, via actionsToInvoke / dispatch, respectively

Great example, I get a lot of inspiration from it, and make some demo about async highlighting

highlight-inline-code-3

Thanks!