In 0.11, ProseMirror moved to a linear dataflow architecture, where the editor state isn’t imperatively updated, but only changes in response to actions that must be explicitly applied to a state to get a new state, which must then be explicitly handed to the editor view.
This makes the library quite a bit less accessible to people not used to that style, but has had amazing effects on how easy it is to write robust extensions, and on how clean it allows the implementation of the view to be. So I’m going ahead and declaring the departure from imperative state management a resounding success.
However, I jumped into this with very little experience with these kind of systems, and assumed that if I just did what Elm and Redux did, everything would fit into place. It’s not quite turning out that way—the theory behind this kind of architecture isn’t all that mature yet, and ProseMirror has a number of requirements that make it harder to apply this stuff than it is in TodoMVC (or, in fact, most web apps).
The main complicating requirement is modularity. Most of the literature around Elm or Redux assumes that the action types and reducers that act on a given piece of state are designed and written as a single coherent module. But ProseMirror’s plugin architecture means that plugins can define new action types, add state fields with their own reducers, and may need to respond to actions that influence other parts of the state (the document or selection, usually).
These are some issues I or users of the library have been running into:
-
Since the reducer for
state.doc
is provided by the library, and because other reducers have to be able to recognize transforms, only thetransform
action type can update the document. This means that undo/redo actions or collaborative editing updates have to mask themselves astransform
actions. -
That also means that an action can’t be both a document transform and update the set of stored marks (the marks that are applied the next time you type). In general, actions can’t be composed in this approach, which really gets in the way when writing plugins.
-
Another thing we’re not really providing in a safe way at the moment is a way to follow up on actions with some kind of invariant-preserving check. You an wire your
onAction
callback to inspect the action and apply another action if it wants to, but you can’t do this at a lower level (except sort of, with the horrifically hackyextendTransformAction
), you can’t do it from a plugin, and if you do multiple such things, it’s hard to ensure that they don’t interact in problematic ways.
I’ve been thinking hard about how to adjust the way actions work in ProseMirror to solve these, but I haven’t really found any acceptable approach yet. So I’m writing this to lay out the problems, sketch some possible directions, and ask for feedback or links to relevant resources.
Requirements
These are the existing features that I think should be preserved:
-
An editor view is completely defined by its current state, and updating it is just a matter of giving it a new state.
-
It must remain possible to extend the editor state with new fields from plugins.
-
Reducers, as well as code that routes actions, must be able to reason about actions. That means it must be able to recognize effects that the action has, at least on parts of the state that the code knows about.
-
Related to the previous point—it must remain easy to guarantee that the state remains consistent. State updates must happen in a way that makes it easy for reducers to ensure that the piece of state that they are responsible for stays in sync with other pieces of state.
-
Preserving some degree of conceptual simplicity would be really nice.
These are new requirements that I’d like to support:
-
Composability. It must be easy to write new action types that have the effect of existing action types among their result when applied.
-
We need some way for action routing code to add more actions or more effects without doing dangerous things like
extendTransformAction
—extending a previously created action, which represents a coherent state update, is wrong. But what to do instead isn’t clear to me yet. -
Similarly, plugins should somehow be able to respond to actions by fixing up the state. For example to enforce document constraints that the schema can’t express, or to prevent certain actions for being applied entirely.
The following are some ideas I’ve been pursuing, along with the reasons why they might be problematic.
Batched actions
If the thing you dispatch is not a single action, but an array or other sequence structure of actions, which then all get applied to create a new state, that solves some of these problems. There’s some precedent for this in Redux-land.
This makes it easy for action dispatchers to append more actions to a batch if they wanted to.
It could also be seen as a way to dispatch multiple effects at the same time—simply precede your custom action by standard actions that have the desired effects.
But that is somewhat scary, since it breaks the atomicity of actions. What if something ends up cancelling some but not all of your actions? Boom, inconsistent state.
Also, when adding more actions to such a batch, it is not trivial to figure out the actual end state of those actions (which you’ll want to base your new actions on), since it hasn’t been computed yet. That could be worked around with…
Transaction objects
We could compute the new state right away, and dispatch the new state instead of an action. But that makes it impossible for the code that receives that state to figure out what happened.
As an alternative, we could define some kind of object that holds a new state and an array of actions. Maybe call it Transaction
. You’d build it up one action at a time, and then dispatch it. The dispatch code could inspect the actions and, if it wanted to, add more, and finally call updateState
on the editor view with the resulting state.
Plugins could be allowed to respond to an action being added to a transaction by cancelling the action or appending another action. It remains really tricky to do this in a way that guarantees consistency, though. See the section on action pipelines later on.
It would add one more object type to worry about, and take us further away from the other architectures in this space.
Properties as effects
Right now, only actions with a type property of "transform"
can change the document. Another approach to composability would be to not make the state field reducers dispatch on type, but rather on the presence of some property. For example, a transform
property would mean that this action transforms the document, a selection
property would mean that it updates the selection, and so on. (We already kind of do this in the optional selection
property of transform
type actions.)
The idea would be to use plain old string properties for the state effects defined by the core library, and expect other modules to use symbols (or some polyfill). Thus, an undo
action, instead of pretending to be a transform
action with some extra properties, could have its own type, and include properties that describe the change to the document as well as the way the history should be updated for that action.
A downside is that it, too, takes us further from classical reducer architecture, and it might make the role of the type
action attribute somewhat difficult to grasp for people coming from there. Rather than a tagged-union style data structure, actions would become something more like a tuple of effects, and the type
would be a descriptive string hinting at the action’s origin rather than a description of its payload.
Plugins and the action pipeline
Our current plugin and state field system already makes the story of how a new state gets computed from an old state and an action somewhat more involved than it is in its purest form. Allowing plugins to directly influence the way actions are applied allows you to do some very useful things, but would complicate this even further.
As I touched on before, it’d be nice if plugins could cancel actions and respond to actions with more actions and maybe even replace actions. The question is how to do this in a way that’s not terribly error-prone.
As long as, as in the current system, a state update is a single invocation of the reducers for a single action, it is relatively clear what’s going on. Once applying an action can spawn more actions, things get subtle.
Say plugin X adds action A2 in response to action A1. But then plugin Y cancels A1 when it gets a turn, should probably cancel A2 as well, since the start state of A2 is no longer available.
What if only A2 was cancelled, but plugin X relied on that to guarantee some kind of consistency?
Maybe only the starting action should be cancellable, and once that goes through, plugins can add their followup actions. But note that if plugin X has already added A2, then if plugin Y wants to add another action in response to A1, it has to base that action on the state after A2.
Things get much more awkward once you start dealing with chunks of actions.
So then you might be tempted to think that replacing actions is a better approach than adding more actions. But updating/replacing things that you don’t fully understand (this action might be fired by some plugin that your code has never heard of and contain who-knows-what kind of properties and consistency constraints) is probably never going to be safe either.
Also, unless we implement something like the transaction data type introduced earlier, if plugins mess with actions at apply-time, the code that delivered the action (say, the onAction
callback or something that called it) doesn’t get a complete view of what happened. But it might be responsible for keeping some piece of state external to the editor state consistent with the editor state, which is rather hard to do if you don’t know how the editor state was updated.
Conclusion
In conclusion, there are real issues with the current design, and addressing them appears to be hard. Yet, I’d really like to hammer this out before 1.0. So I’m going to continue obsessing over it, and if you have any ideas or resources that might be helpful, let’s hear them.