splitting the "behavior/rendering" logic out of schemas
See original GitHub issueThis was brought up by @Soreine and @SamyPesse in #1111. I want to open a new issue for it so that the discussions can not get derailed in each place though.
I feel like there’s a sort of Catch-22 scenario occurring between
- This issue — splitting the “behavior/rendering” logic out of schemas
- #1111 — consider adding
schema
intostate
objects - #1258 — expressive schemas
We’d like to put the schema into state
, but to do that it would be best to separate it into two different pieces so that only the structural logic is in state
.
But to split it into structural pieces, we sort of want schemas to be more expressive otherwise it will be hard to convince plugins like edit-list/code/etc
to stop defining schemas inside the plugin itself.
But to make schemas more expressive, we kinda of need to see past the behavioral/rendering logic that is clumped in there.
So here, I just want to present a way to split out the behavioral/rendering logic from schemas, so that others can have this in mind when discussing those other issues. And maybe we can unblock this piece while figuring out the other two.
The parts we need to account for removing at:
render
for rendering nodes.placeholder
for rendering placeholders.decorate
for adding decorations
We actually already have some “rendering” logic in the plugins in the form of:
plugin.render
for rendering around the editor, adding toolbars, etc.plugin.renderPortal
for rendering extra portals.
And we also used to have plugin.renderNode
and plugin.renderMark
methods instead of the current “schema” concept. When the schema was created, we ported them there to have a single place to manage them.
But I think we did that too soon, without realizing that having the schema be structure-only would be valuable. So I think we could go back to that system (with a slight tweak, for people who remember that)…
What we’d end up with is a few new plugin properties:
renderNode(props: Object) => ReactElements|Void
renderMark(props: Object) => ReactElements|Void
renderPlaceholder(props: Object) => ReactElements|Void
These are actually very similar to react-router
’s Route
render
property. They take in a props
object, and return react elements to be rendered. It would look like this:
plugin.renderNode = (props) => {
if (props.node.type == 'paragraph') {
return <MyParagraphComponent {...props} />
}
}
Such that “converting” the existing schema would look like:
plugin.renderNode = (props) => {
switch (props.node.type) {
case 'paragraph': return <Paragraph {...props} />
case 'quote': return <Quote {...props} />
case 'image': return <Image {....props} />
...
}
}
Pretty simple, but powerful too. And I think it would feel more React-ish. And it would allow people to pass in extra props to node components, just like you can with react-router
. (As long as you are aware of when node’s rerender and when they don’t.)
Rendering marks and rendering placeholders would work in the same way. For example, here’s what the “current” default placeholder could look like…
core.renderPlaceholder = (props) => {
const { node, state } = props
if (node.kind != 'block') return
if (!Text.isTextList(node.nodes)) return
if (node.text != '') return
if (state.document.getBlocks().size > 1) return
const style = {
pointerEvents: 'none',
display: 'inline-block',
width: '0',
maxWidth: '100%',
whiteSpace: 'nowrap',
opacity: '0.333',
}
return (
<span contentEditable={false} style={style}>
{editor.props.placeholder}
</span>
)
}
I think it’s actually easier to understand how/why this works than the current implementation.
And then we can solve decorate
similarly, by adding a plugin.decorateNode
function that gets called with (node)
as the current one does. This should be fairly straightforward.
The only thing this approach leaves up in the air, is how the NodeComponent.shouldNodeComponentUpdate
functionality would be handled. Since Slate no longer has a reference to your custom node component, it can’t just read the static property from it. But I think we might be able to solve this with another plugin method:
plugin.shouldNodeComponentUpdate(props, nextProps) {
...
}
Which would be called at the same time as currently.
This would completely pull all of the behavioral/rendering logic out of the schema, so that schemas can be purely structural. And in doing so, I feel like it kind of becomes even more React-ish as a result.
Would love to hear people’s thoughts on this!
Issue Analytics
- State:
- Created 6 years ago
- Reactions:1
- Comments:5 (3 by maintainers)
Top GitHub Comments
Hey you two, thanks for the responses! This is super helpful to think about.
I actually had a few ideas this morning that I think can let us take the idea further, so I’ll try to explain why I think
renderNode/renderMark/etc
has potential to be good…I agree a bit, I try to avoid
switch
unless it really makes sense.But I actually really like the
react-router
v4 API. I feels like older versions (and most other routers) have always had to re-invent the wheel with new, non-native patterns to achieve the question of what to render when. Instead, with the<Router render={...} />
pattern they get to stick to a completely React-ish way of doing things that parallels the patterns people are already writing in their codebase.It’s common to break up
render
into separate functions inside a component, for example check out the Slate<Leaf>
node which breaks up its ownrender
into two other methods for conveniencerenderMarks(props)
andrenderText(props)
. This is a common pattern, and it is pretty easy to understand from a React user’s point of view in my opinion.And I think whenever you can “use the platform” instead of inventing a new construct to solve an issue, it tends to pay back dividends, often in ways you wouldn’t have foreseen.
But right now, thanks to my past decisions, we’re using an invented construct—the
match/render/decorate/placeholder
pattern.This is a new, unknown thing that new users have to learn.
On top of that, it’s composed in an awkward way where plugins are defining these objects, and they get mixed in with each other, then called internally in ways that aren’t immediately obvious. Once you understand it, it makes sense. But it’s an extra learning curve, and a constant abstraction barrier that you have to keep in mind.
And there isn’t even a great reason for why it needs to exist. As far as I can tell, we don’t gain any meaningful performance from break it into parts like that. I basically re-invented the
if
statement 😄What’s worse, I think, is that it’s a strict subset of what’s possible with
renderNode
. (Just likecomponent={...}
is a strict, convenience-only subset inreact-router
.) Because the above can easily be re-written as:And by doing so you get familiarity. Anyone who’s used React has a good idea just from looking at that piece of code what it is doing, so that’s good for onboarding.
But you also gain flexibility for potential behaviors in the future. (I haven’t fully fleshed these out, because it’s hard to foresee things like this.) A few things that might be possible:
children
of other plugins.props.editor.readOnly
.Again these are just random ideas. And these things are all technically also possible in the current system. But by using
renderNode
, the approach you’d take to do these things is more obvious I think, because it’s the same as the approach you’d take for rendering any other React component.The only two downsides I can think of right now are:
shouldNodeComponentUpdate
logic might be a bit more awkward. (But this is a 1% edge case that I don’t want to build the API around, when it will still be possible.)But those aren’t strong cons to me.
For the question of
renderEditor
,renderPlaceholder
,renderPortal
. I’m not as sure either.I think the goal of having entire “features” being able to be extracted completely as their own Slate plugins is a good one. For example, consider a
comments
feature. It might want to render it’s own portal (or maybe even just plain sidebar) to display the comments next to the blocks. But without some sort of top-level “editor rendering” hook, I’m not sure there’s a non-hacky way to do this.The
renderPortal
hook I think will be deprecated. With React 16 I believe this will be achievable by therenderEditor
hook instead, by rendering an array of[children, ReactDOM.createPortal()]
, so there’s no need for it to exist anymore.The
renderPlaceholder
hook, I’m not sure about, but there are some things it solves that I haven’t found another solution for yet. For example, now that we’ve switched to it from the old<Placeholder />
component, it’s possible to define placeholders that apply across block types (or even inline types), without having to manually extend each and every node component. This seems like it’s a good feature, but I could be convinced it isn’t too.Okay, going a bit further…
If we move all of the rendering logic away from the
match/render
pattern. I was thinking it myself that it would be great to be able to completely eliminate thematch/*
pattern if possible. Because even though it does the job, it’s kind of awkward and verbose. It feels like it has a bigger learning curve that if everything was able to be expressed by simple functions.So, here’s how I think we might actually improve the current schema validation API…
Right now we have:
The first thing I realized, is that
match
actually doesn’t need to exist. We’re not doing anything smart performance-wise with it, and as it’s currently written we can’t really anyways. The above could be easily re-written to just foldmatch
intovalidate
, like so:Same thing, one less function. But if we stop there, then there isn’t really a benefit to removing
match
because we haven’t meaningfully simplified anything.The problem is, we can’t just blindly combine them to create a single
validate(node, change)
function, because the fact thatvalidate
can be memoized is actually key for performance. Since each node can cache it’s “validity” with respect to a schema, if a node hasn’t changed, we don’t need to re-validate it, we know it’s still valid. The key is that the signature remainsvalidate(node)
, because we cache based on the arguments passed in.But! I realized, we can actually achieve that same thing, by changing the logic slightly. And I think it actually makes the API less awkward at the same time…
Right now we have
validate
either return nothing, in the case of the node being valid, or return the “invalidness” if it’s invalid. What it returns doesn’t matter, but it gets passed to tonormalize
to save on recalculating the same information twice.But this is a bit awkward. It leads to things like
return invalids.size ? invalids : null
which are confusing to read/write. I consistently do this backwards while writing validations.Not only that, but again I accidentally re-invented an already existing construct—closures! We’re kinda of doing this pseudo-closure thing where we pass the results of
validate
tonormalize
so that it doesn’t have to recalculate things to save performance.Instead, what if the validator still took
validate(node)
, and returns early if it’s already valid, but instead returns a “change function” that returns the node to a valid state if it is invalid. That way, it’s still memoize-able, and we get to benefit from closures.So the above could be re-written as:
Now we’re no longer re-inventing closures, we’re just using them. And we get to lean on that “change function” signature/pattern that is common through the codebase.
To me this feels meaningfully easier to understand, read and write.
So in summary, after thinking through some of these problems, I feel like we’re leaning on
match
in a way that’s actually bad, because we’re re-inventing lots of native paradigms. And by removing it we’d actually end up with simpler solutions, that are easier to understand for people.We’d be able to completely remove the
plugins.schema
concept, in favor of just using functions for everything…@oyeanuj I see where you’re coming from, but I think we’d end up just adding a learning curve on the side of learning about the different kinds of plugins. And there is a good use case for plugins that bridge multiple different areas I think. Even super plugins (I call them “features”) that bring a single feature across all the areas. So I think, for now at least, continuing to let plugins do anything is the way to go.