dispatchTransaction called before plugin's update. Should be the reverse?

Hi, I am studying Prosemirror, testing the examples and guidance, Tooltip and dispatchTransaction. I found out that dispatchTransaction was called before plugin’s update, which stopped Tooltip example from working, because the code of dispatchTransaction example here

appState.editor = appState.editor.apply(event.transaction)

whould make the code of Tooltip here always return, as lastState.selection.eq(state.selection) will always be true, making the lastState selection always equals to state.selection.

    if (lastState && lastState.doc.eq(state.doc) &&
        lastState.selection.eq(state.selection)) return

Shouldn’t plugins’ update be called before dispatchTransaction?

No. Dispatching a transaction is what causes an editor view update.

In the tooltip plugin’s update method, lastState will be the state that was current before the dispatch, so everything should be in order.

I understand this logic. It could be a bug with lastState and current state’s selection processing code.

When I combined the dispatchTransaction example in your guidance and also the tooltip example, I found that that dispatchTransaction is called before plugin’s update, the code

    if (event.type == "EDITOR_TRANSACTION") {
        editorView.state = editorView.state.apply(event.transaction)

would make this test in your tooltip example always return true when selection is changed:

    if (lastState && lastState.doc.eq(state.doc) &&
        lastState.selection.eq(state.selection)) 

I added a console.log in tooltip’s update method to verify and it confirms that lastState.selection always equal to state.selection.

        if( lastState && lastState.selection )
            console.log( lastState.selection.eq(state.selection) ) //always print true when selection is changed.

        if (lastState && lastState.doc.eq(state.doc) &&
            lastState.selection.eq(state.selection)) return

The lastState passed to the plugin’s update method comes from the editor view, which won’t have the new state yet no matter what dispatchTransaction is doing, so I really think you’re misdiagnosing this.

Let me post the whole code of update from the tooltip example as below. This line: if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return checks if the selection has changed. If not, then it does nothing, i.e. not to update tooltip.

It is quite clear that lastState should be previous state if selection has changed, while ‘view.state’ should be the new state. With dispatchTransaction called, 'lastState.selectionis always the same toview.state.selection`, even when selection has changed. That’s why tooltip example stopped working when dispatchTransaction example was merged with tooltip example. I don’t think this is right.

  update(view, lastState) {
    let state = view.state
    // Don't do anything if the document/selection didn't change
    if (lastState && lastState.doc.eq(state.doc) &&
        lastState.selection.eq(state.selection)) return 

    // Hide the tooltip if the selection is empty
    if (state.selection.empty) {
      this.tooltip.style.display = "none"
      return
    }

    // Otherwise, reposition it and update its content
    this.tooltip.style.display = ""
    let {from, to} = state.selection
    // These are in screen coordinates
    let start = view.coordsAtPos(from), end = view.coordsAtPos(to)
    // The box in which the tooltip is positioned, to use as base
    let box = this.tooltip.offsetParent.getBoundingClientRect()
    // Find a center-ish x position from the selection endpoints (when
    // crossing lines, end may be more to the left)
    let left = Math.max((start.left + end.left) / 2, start.left + 3)
    this.tooltip.style.left = (left - box.left) + "px"
    this.tooltip.style.bottom = (box.bottom - start.top) + "px"
    this.tooltip.textContent = to - from
  }

  destroy() { this.tooltip.remove() }
}

What do you mean when you say ‘dispatchTransaction example’? Can you post a minimal self-contained piece of code that demonstrates the issue?

Below code integrates dispatchTransaction example and Tooltip example. Tooltip stopped working because in update, this test lastState.selection.eq(state.selection) is always true when selection is changed. I believe it is a bug of Prosemirror that after dispatchTransaction is called, when plugin’s update is called, either lastState is not the old state or view.state is not new state as they are supposed to be.

import { EditorState,Plugin } from "prosemirror-state"
import { EditorView } from "prosemirror-view"
import { Schema, DOMParser } from "prosemirror-model"
import { schema } from "prosemirror-schema-basic"
import { addListNodes } from "prosemirror-schema-list"

import menu from './plugins/qieditor-menu'
import {exampleSetup} from "prosemirror-example-setup"

// Mix the nodes from prosemirror-schema-list into the basic schema to
// create a schema with list support.
const mySchema = new Schema({
    nodes: addListNodes(schema.spec.nodes as any, "paragraph block*", "block"),
    marks: schema.spec.marks
})

const doc = DOMParser.fromSchema(mySchema).parse(document.querySelector("#content"))

/**
 * setup plugins
 *
 */

let selectionSizePlugin = new Plugin({
    view(editorView) { return new SelectionSizeTooltip(editorView) }
})

class SelectionSizeTooltip {

    tooltip: any

    constructor(view: EditorView) {
        this.tooltip = document.createElement("div")
        this.tooltip.className = "tooltip"
        view.dom.parentNode.appendChild(this.tooltip)

        this.update(view, null)
    }

    update(view: EditorView, lastState:EditorState) {
        let state = view.state
        // Don't do anything if the document/selection didn't change
        if( lastState && lastState.selection )
            console.log( lastState.selection.eq(state.selection) ) //aways print true.
        if (lastState && lastState.doc.eq(state.doc) &&
            lastState.selection.eq(state.selection)) return

        // Hide the tooltip if the selection is empty
        if (state.selection.empty) {
            this.tooltip.style.display = "none"
            return
        }
        console.log("updating tooltip")

        // Otherwise, reposition it and update its content
        this.tooltip.style.display = ""
        let {from, to} = state.selection
        // These are in screen coordinates
        let start = view.coordsAtPos(from), end = view.coordsAtPos(to)
        // The box in which the tooltip is positioned, to use as base
        let box = this.tooltip.offsetParent.getBoundingClientRect()
        // Find a center-ish x position from the selection endpoints (when
        // crossing lines, end may be more to the left)
        let left = Math.max((start.left + end.left) / 2, start.left + 3)
        this.tooltip.style.left = (left - box.left) + "px"
        this.tooltip.style.bottom = (box.bottom - start.top) + "px"
        this.tooltip.textContent = to - from
    }

    destroy() { this.tooltip.remove() }
}

const plugins = exampleSetup({ schema: mySchema }).concat(selectionSizePlugin)

/**
 * Create editor view and mount to node
 *
 */
const editorArea = "#editorArea"

const editorView = new EditorView(document.querySelector(editorArea), {
    state: EditorState.create({
        doc,
        plugins
    }),
    dispatchTransaction(transaction) {
        update({type: "EDITOR_TRANSACTION", transaction})
    }
})

/**
 * Update the whole application's state
 * Do post transaction processing.
 * @param {any} event - Applicationevent
 */

function update(event: any) {
    if (event.type == "EDITOR_TRANSACTION") {
        editorView.state = editorView.state.apply(event.transaction)
    }
    //    else if (event.type == "SCORE_POINT")
    //        appState.score++
    draw()
}

/**
 * Update editor view
 *
 */
function draw() {
    editorView.updateState(editorView.state)
}

Here’s your problem:

        editorView.state = editorView.state.apply(event.transaction)

The state property should not be directly mutated. Use the updateState method.

1 Like

I got it. Thanks. But the code is from your guidance section data flow. Suggest to update it.

// A crude app state update function, which takes an update object,
// updates the `appState`, and then refreshes the UI.
function update(event) {
  if (event.type == "EDITOR_TRANSACTION")
    appState.editor = appState.editor.apply(event.transaction)
  else if (event.type == "SCORE_POINT")
    appState.score++
  draw()
}

The part you pasted is, but that isn’t the part that had the bug in it, so no, the code in the guide is correct.

Yes technically you are right. But wouldn’t it be more helpful to users of your library in your guide to use the recommended way to update the state inside dispatchTransaction?