question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

splitting the "behavior/rendering" logic out of schemas

See original GitHub issue

This 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 into state 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:closed
  • Created 6 years ago
  • Reactions:1
  • Comments:5 (3 by maintainers)

github_iconTop GitHub Comments

2reactions
ianstormtaylorcommented, Oct 19, 2017

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…


The switch case is expressed as data instead of code, thanks to the match function. It’s just simpler to compose that way. What is the benefit of moving away from the existing rendering data structure ? Full disclosure: I don’t like writing switch in javascript…

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 own render into two other methods for convenience renderMarks(props) and renderText(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.

{
  match: obj => obj.kind == 'block' && obj.type == 'quote',
  render: QuoteBlockComponent,
}

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 like component={...} is a strict, convenience-only subset in react-router.) Because the above can easily be re-written as:

function renderNode(props) {
  if (props.node.kind == 'block' && props.node.type == 'quote') {
    return <QuoteBlockComponent {...props} />
  }
}

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:

  • You can add any props you want, since you’re creating the element yourself.
  • We could allow for plugins to compose the rendered children of other plugins.
  • You could render two different components depending on whether 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:

  • The 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.)
  • It might not be as obvious when node’s re-render. (But this is already an issue in the current system, as evidenced by lots of the issues/questions, so it doesn’t feel worse.)

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 the renderEditor 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 the match/* 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:

{
  match(obj) {
    return obj.kind == 'block' && obj.type == 'quote',
  },
  validate(quote) {
    const invalidChildren = quote.nodes.filter(n => n.kind != 'block')
    if (!invalidChildren.size) return
    return invalidChildren
  },
  normalize(change, quote, invalidChildren) {
    invalidChildren.forEach((node) => {
      change.removeNodeByKey(node.key)
    })
  },
}

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 fold match into validate, like so:


{
  validate(node) {
    if (obj.kind != 'block') return
    if (obj.type != 'quote') return
    const invalidChildren = quote.nodes.filter(n => n.kind != 'block')
    if (!invalidChildren.size) return
    return invalidChildren
  },
  normalize(change, quote, invalidChildren) {
    invalidChildren.forEach((node) => {
      change.removeNodeByKey(node.key)
    })
  },
}

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 that validate 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 remains validate(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 to normalize 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 to normalize 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:

function validateNode(node) {
    if (obj.kind != 'block') return
    if (obj.type != 'quote') return
    const invalidChildren = quote.nodes.filter(n => n.kind != 'block')
    if (!invalidChildren.size) return

    return (change) => {
      invalidChildren.forEach((node) => {
        change.removeNodeByKey(node.key)
      })
    }
  }
}

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…

return {
  onKeyDown,
  onChange,
  ...

  renderNode,
  renderMark,
  renderEditor,
  renderPlaceholder,

  decorateNode,

  validateNode,
}
0reactions
ianstormtaylorcommented, Oct 23, 2017

@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.

Read more comments on GitHub >

github_iconTop Results From Across the Web

consider adding `schema` into `state` objects · Issue #1111 ... - GitHub
We really need to split up plugins in two parts. A Slate plugin should expose a separate plugin and schema . One part...
Read more >
Fragments - Apollo GraphQL Docs
Because of this similarity, you can use fragments to split query logic up between components, so that each component requests exactly the fields...
Read more >
Schemas | Frontend Development | commercetools
A schema is a JSON file that defines which data is collected for the schema ... Keep as much business logic outside of...
Read more >
Schema – Tiptap Editor
toDOM defines how it will be rendered in the DOM. In Tiptap every node, mark and extension is living in its own file....
Read more >
Reference manual - ProseMirror
EditorState var state = EditorState.create({schema: mySchema}). Or, using ES6 syntax: ... Allows you to pass custom rendering and behavior logic for nodes.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found