Separating state and view plugins

Related to Generalized State Architecture, I want to initialize the ProseMirror state as part of the global application state. However, I’m running into a circular dependency issue because Plugins combine both “view” and “state” together. Let me explain:

EditorState takes plugins as an argument: EditorState.create({ doc, plugins }). This makes sense for “state plugins”, plugins that use the state argument: new Plugin({key, state}). But this makes less sense for “view plugins”, plugins that attach to the DOM and render: new Plugin({view, props}).

Ideally, new EditorView would accept an argument for “view plugins” so that creating the editor state doesn’t depend on the views.

For example, suppose I have a global application state that needs to tie into these “view plugins”:

This is tricky because now, to create the initial app state, we need to create all of the plugins which creates a circular dependency that doesn’t need to be there.

I think a clean solution here is to introduce the concept of a ViewPlugin vs a StatePlugin and allow EditorView to accept an array of ViewPlugins. This way, we can initialize the state with all of the relevant StatePlugins without creating this circular dependency. We can also maintain backward compatibility by allowing for StatePlugins to also have ViewPlugin arguments as well.

I have a working example of this issue that you can check out. Let me know what you think @marijn!

Thanks

Chet

Could you describe what you are proposing in terms of the API additions it would involve? I’m having a hard time figuring out the specifics from the example script.

Hey @marijn, I did some experimenting with different approaches…

I’ll assume that we are on board with wanting to have the document state encapsulated in the application state. I tried a couple different approaches…

First Approach - StatePlugins and ViewPlugins

The first thing I tried is to separate the StatePlugins from the ViewPlugins. Currently, in my application, it’s the ViewPlugins that need to be decoupled.

class StatePlugin<T = any, S extends Schema = any> extends Plugin<T, S> {
	constructor(spec: Omit<PluginSpec<T, S>, "props" | "view">) {
		super(spec)
	}
}

class ViewPlugin<T = any, S extends Schema = any> extends Plugin<T, S> {
	constructor(spec: Pick<PluginSpec<T, S>, "props" | "view">) {
		super(spec)
	}
}

As such, the API can look something like:

const state = EditorState.create({doc, selection, plugins}) // these plugins are StatePlugins
const view = new EditorView(node, {state, nodeViews, plugins}) // these plugins are ViewPlugins

Looking at someProp and updatePluginViews in the EditorView class, I was able to get this kind of abstraction to work just by tearing apart the plugins.spec into EditorView direct props. I couldn’t quite get decorations to merge though, and I had to monkey-patch over view.updateState in order to update the plugin views.

That said, this abstraction could definitely work and has the benefit that you can construct ViewPlugins that depend on the application state.

// The application state.
const appState = {
  hotkey: "meta-k",
  editorState: EditorState.create(...)
}

// Rendering the app.
function render(node, state, dispatch) {
  const view = new EditorView(node, {
    state: state.editorState, 
    dispatchTransaction(tr) { dispatch(tr) },
    plugins: [
      keymap({
       [state.hotkey]: () => dispatch(...)
      })
    ]
  })
}

Second Approach - Break apart StatePlugins

Something I realized is that, I can imagine wanting StatePlugins to be configured by the app state. These plugins shouldn’t (I think) ever need to call dispatch so I we don’t have a circular dependency issue, but it does make it a little bit annoying. For example:

const pluginKey = new PluginKey("mention-autocomplete")

// This is a state plugin that can be configured to work with different trigger characters.
function AutocompleteStatePlugin(triggerCharacter) {
	return new StatePlugin({
		key: pluginKey,
		state: {
			init() {
				return { active: false }
			},
			apply(tr, state) {
				if (!state.active) return state

				const { range } = state
				const from = range.from === range.to ? range.from : tr.mapping.map(range.from)
				const to = tr.mapping.map(range.to)
				const text = tr.doc.textBetween(from, to, "\n", "\0")
				
				if (!text.startsWith(triggerCharacter)) {
					// Close when deleting the trigger character.
					return { active: false }
				}

				const queryText = text.slice(1) // Remove the leading trigger character.
				return {...state, range: { from, to }, text: queryText}
			},
		}
	})
}

const triggerCharacter = "@"
const appState = {
  triggerCharacter,
  editorState: EditorState.create({doc, selection, plugins: [
    AutocompleteStatePlugin(triggerCharacter)
  ]})
}

When constructing the app state, it isn’t a circular dependency, which is good, but it’s still a bit awkward because part of the state depends on other parts of the state. If the user changes the trigger character, we need to make sure to reconstruct the editorState which doesn’t exactly fit the functional model of the “view” as a function of “data”.

I’m starting to go out into unchartered territory, but ideally, we could pull applyTransaction out of EditorState.

Suppose EditorState is just data:

const state = EditorState.create({
  doc, 
  selection, 
  pluginStates: {[pluginKey]: { active: false}}
})

Then to apply a transaction, we have a pure function that is imported from prosemirror-state:

import {applyTransaction} from "prosemirror-state"

const nextState = applyTransaction(
  tr, 
  state, 
  pluginUpdates: {[pluginKey]: (tr, state) => { /* ... */} 
})

The nice thing here is that the plugin update function can depend on the current app state. That is, we can apply the autocomplete update function as a function of the trigger character that may change at any time.

This feels a little bit more about the Elm Architecture in the way that we’ve pulled apart the init/update for both the plugins and the editor and compose them together however we see fit.

And in this second approach, the ViewPlugins stay the same as the first approach, where they are arguments to EditorView.

Let me know if that makes sense. Thanks,

Chet

Any approach that is going to introduce breaking changes isn’t going to fly—I’m planning to stay on version 1.x for a while yet. Do I understand correctly that both of your proposals are breaking?

I think that the first approach could be implemented without causing a breaking change, by adding a new kind of plugin only specific for EditorView configuration that could live together with current plugin system. The only change required is that we need to iterate in another set of plugins in order to execute a prop.

someProp(propName, f) {
    let prop = this._props && this._props[propName], value
    if (prop != null && (value = f ? f(prop) : prop)) return value
    let plugins = this.state.plugins
    if (plugins) for (let i = 0; i < plugins.length; i++) {
      let prop = plugins[i].props[propName]
      if (prop != null && (value = f ? f(prop) : prop)) return value
    }
    let viewPlugins = this.plugins
    if (viewPlugins) for (let i = 0; i < viewPlugins.length; i++) {
      let prop = viewPlugins[i].props[propName]
      if (prop != null && (value = f ? f(prop) : prop)) return value
    }
  }

If you want you can edit a EditorView property by either a normal plugin or a view plugin, keep in mind that executing the ViewPlugins after the StatePlugin would also assure that the exeuction order is not affected (State Plugins will be able to exit early).

The benefit here is that you can have a clear separation of state and view, even if StatePlugins allow you to configure the plugins, you can clearly lint and prevent the view props usage in your personal project.

It wouldn’t be too difficult to add a field that allows the injection of additional plugins to DirectEditorProps. We could make it a dynamic error to have any plugins with a state (or filterTransaction, or appendTransaction) field in there. But that does mean the ordering of view versus state plugins would be fixed—you can’t fine-tune their order, since they don’t appear in the same array. View plugins would either always take precedence, or never take precedence (not sure which would be more appropriate).

I’ve created an RFC with the thing I’m proposing. Please take a look and see whether it would cover your use case.

Edit to add actual link: Propose direct view plugins by marijnh · Pull Request #17 · ProseMirror/rfcs · GitHub

This seems manageable because if you’re using view plugins, then ideally any state plugins would not touch the view. And if they did, you can break them apart and recompose them appropriately.

It would be fairly easy to make a backward compatible change. But if we also want it to be forward compatible (new plugin library with an older prosemirror library), then it might be a little tricky. As you mention in your RFC, if you keep the same Plugin class, then it should be fine. And you can optionally throw a runtime error if a view plugin has state properties…

Just to be comprehensive, in addition to checking for view plugins in EditorView.someProp(), you’ll also have to handle updating view plugins in EditorView.updatePluginViews().

Lastly, you could also decouple the StatePlugins as outlined in the “Second Approach” in my previous post simply by allowing a second argument to EditorState.apply(tr, {plugins}). These plugins will override the apply function for any state plugins that have the same plugin key.

Recap

  1. The current method way of doing things.
const plugin = new MyPlugin()
const state = EditorState.create({doc, selection, plugins: [plugin]})
const view = new EditorView(node, {
  state,
  dispatchTransaction(tr) {
    this.updateState(this.state.apply(tr))
  }
})
  1. A compatible way of breaking apart view plugins and state plugins.
const {spec: {key, state, props, view}} = new MyPlugin()
const state = EditorState.create({doc, selection, plugins: [
  new Plugin({ key, state }) // A state plugin.
]})
const view = new EditorView(node, {
  state, 
  dispatchTransaction(tr) {
    this.updateState(this.state.apply(tr))
  }
  plugins: [new Plugin({ props, view })] // A view plugin.
})
  1. A compatible way of breaking apart applyTransaction as well:
const {spec: {key, state: {init, apply}, props, view}} = new MyPlugin()
const state = EditorState.create({doc, selection, plugins: [
  new Plugin({ key, state: {init} }) // Just need init().
]})
const view = new EditorView(node, {
  state, 
  dispatchTransaction(tr) {
    this.updateState(this.state.apply(tr, {plugins: [
      new Plugin({key, state: {apply}}) // Just need apply().
    ]}))
  }
  plugins: [new Plugin({ props, view })] // A view plugin.
})

Compatibility

So long as we use the Plugin class for everything, we should be fine. If someone breaks apart their plugin library into 3 separate plugins as shown above, someone using the old prosemirror abstraction can just put all three of them into EditorState.create and it should work just fine. So forward compatibility works.

For step 2 above, backward compatibility would just check both state and view plugins, and as previously mentioned for step 3, any plugins passed to state.apply() would just override other plugins with the same key.


Aside: Please let me know if you’d like to continue this conversation in the RFC instead.

Not a fan of this, since all call sites that use apply will have to remember to pass the plugins or really messy things will happen.

Conversation here or in the rfc is both fine.

True, but users typically only do this in one place… And if they forget, then I suppose the plugins will just ignore actions. That’s not so bad, I think. Or we could add a runtime exception if there’s no plugin for a given tr.meta.

In any case, things will get uglier if our goal is to maintain compatibility. Do it’s really a matter of how much we can stomach… :confused:

I’m leaning towards the minimal approach in the RFC. If you have specific concerns about that, feel free to discuss.

I think your RFC is good and covers a majority of these use-cases :+1:And I think that most of the time, your plugin reducers will remain unchanged by the rest of the application state – I was just trying to be comprehensive with my exploration.

That said, I did demonstrate a fairly non-contrived example: allowing the user to reconfigure the trigger character for a command menu without having to re-instantiate the their editor and plugins. I think the trade-off is that a little more grossness buys you 100% abstraction flexibility which means that you’ll never have to revisit this kind of issue again. But taking it one step at a time is fine and I will be very happy if we just get state and view plugins separated :slight_smile:

Wouldn’t reconfiguring the view plugins with setProps cover that?

setProps doesn’t work when you want to update a StastePlugin.state.apply function.

In theory, you can write this as a view plugin and dispatch an action when a user presses a specific key. But the way I implemented it (not sure if its better or worse) just as a state plugin that checks the document state:

You can always reconfigureState if you want to change state plugins. ProseMirror Reference manual

Oh, you’re right! That’s all I need :slight_smile: