simplify commands, queries, and middleware
See original GitHub issueDo you want to request a feature or report a bug?
Improvement / debt.
What’s the current behavior?
Right now we’ve got three separate concepts: middleware handlers, command functions, and query functions. This is fine, but I’ve been bumping up against limitations with it that stem from the distinctions.
It has already been pointed out that commands and queries aren’t any different from a technical point of view. It’s just that some return values and some apply operations. But there’s nothing stopping them from doing the other, or even one from doing both at once. (Technically commands are chainable, but this such a small benefit that shouldn’t prevent better solutions.)
Also, as more and more logic is ported to editor-level queries, to allow it to be customizable in userland for more advanced editors, the special-cased middleware handlers start to seem odder and odder. For example, what is renderBlock
if not a query that returns React elements? And what is onKeyDown
if not a special command that is used in DOM environments.
I think we’d stand to gain in simplicity by combining these concepts.
The only difference right now is their arguments ordering, which presents a problem. Each one is slightly different. The middleware take the editor last:
(props, editor, next)
(event, editor, next)
...
Whereas the queries and commands take it first:
(editor, range)
(editor, point)
...
But this brings us to a second issue that is mentioned in https://github.com/ianstormtaylor/slate/issues/2466 and in https://github.com/ianstormtaylor/slate/issues/2342 that is that the middleware stacks are awkward from a few standpoints—especially in terms of debugging.
I originally borrowed the middleware concept from Koa which seems to have solved it nicely. But having played with Micro, I’ve come to realize that using a next()
pattern is actually just a leftover in the API from before async/await
. And since we don’t have the asynchronous issue—and even if we did async/await
can solve it—we can eliminate the next()
pattern and use full composition instead. Micro does this to a really nice effect.
The only major difference is that Micro is concerned with a single stack, whereas we need to allow a huge number of functions to be composed and overridden.
But that’s not impossible, it just means that we need to use a dictionary of functions instead of a single one. Which would end up looking very similar to our existing plugin definitions for middleware handlers:
{
onKeyDown: fn => (event, editor) => {
if (...) {
...
} else {
return fn()
}
}
}
But this still leaves the problem of the ordering of editor
. Since we like keeping props
and event
in the first position, to mimic React’s event handlers and render prop patterns.
However, we can actually move the editor
itself to the set of composed arguments too, since it never changes once a plugin is initialized. So we’d get:
{
onKeyDown: (fn, editor) => event => {
if (...) {
...
} else {
return fn()
}
}
}
Which leaves us with middleware, commands and queries all having the same signature:
{
[name]: (fn, editor) => (...args) => {}
}
Meaning that plugins can be simplified to being just a dictionary of composition functions. Commands become middleware. Queries become middleware. And the React-specific commands and queries like onKeyDown
and renderBlock
are treated no differently.
(This requires extracting schema
into a separate plugin, which we’ve already wanted to do from https://github.com/ianstormtaylor/slate/issues/2333.)
Further, since fn
is actually the composed function itself, it gives us two more benefits that we are awkward with out current middleware setup:
-
Changing the arguments passed to a function is clear, you can just change what you call
fn(...)
with, and it won’t know the difference. This allows for more powerful overrides. -
Debugging is simpler, since you can step directly into the composed function, and continuing going all the way down the stack. Instead of stepping into the awkward middleware looping logic as happens now.
Moreover, this allows us to simplify the methods that Editor
exposes. Right now since things are slightly different we have:
editor.command(type, ...args)
editor.query(type, ...args)
editor.hasCommand(type)
editor.hasQuery(type)
editor.query(type, ...args)
editor.registerCommand(type)
editor.registerQuery(type)
editor.run(type, ...args)
Which could then be simplified to:
editor.exec(name, ...args)
editor.has(name)
(This leaves open another question of whether it’s better to have plugins be simple dictionaries of composition functions, or for them to a function that is passed the editor
to use an imperative API like editor.register(name, fn)
. But we can choose to change this later if we discover it has benefits.)
Issue Analytics
- State:
- Created 4 years ago
- Reactions:5
- Comments:11 (8 by maintainers)
Top GitHub Comments
Yeah, I think it’s just that I’m used to it, and have a lot of functions that are using that style. I suppose it would be easy enough to emulate in plugin-land if I needed it:
Yeah. What I meant was, it’s something we could solve later by wrapping
fn()
. You’d have a wrapper layer in between each plugin layer, but no client code would have to change, and the backtrace would still be pretty readable – at least, way more readable than it is today. Sounds like we need that immediately, though 😃Not really important, just agreeing with you that using return values from plugin functions will be flexible. Although it’s a shame we lose chaining because of it! Don’t think it’s possible to have both, though. And it’s not like I would have missed chaining if it hadn’t existed to begin with.
Yep, I think so.
That solves all of the places I’m using onCommand / onQuery for right now. I still think it would be nice to be able to intercept all commands in a plugin. Since I can’t think of a specific case where I’d actually do it, it’s probably not necessary.
Cool. Mirroring some existing thing was what I was going for, I couldn’t think of what
exec
was meant to mirror.Oh, something that’s not clear to me yet – are you planning to have
exec
work with anonymous functions, the waycommand
does today? What would their signature look like?