feat(slate-react): more flexible / performant dynamic decorations via small API change
See original GitHub issueProblem
With the existing decorations mechanism, it is not easy to make decorations dynamic, so they’re recomputed based on external state. Currently the only way to do it is to change the decorate
function, which causes every element in the document to re-render. (There was a recent change #4138 which improved this slightly by keeping the decorate
function in a context rather than passing it down as a prop, but still every Element
component accesses the context.)
For example: In my application I use decorations for error highlighting in code blocks. A change in one code block can affect highlighting in another block, so I need to redecorate on every edit to a code block. Changing the decorate
function on even a moderately-sized document is very slow.
Solution
A small change to the renderElement
API can greatly improve the situation: pass a new renderChildren
argument, which takes a decorations
argument. These decorations are added to any existing decorations when rendering the children.
Now in renderElement
, instead of e.g.
const renderElement = (props: RenderElementProps) => {
switch (props.element.type) {
case 'code':
return <code>{props.children}</code>
...
we can write
const renderElement = (props: RenderElementProps) => {
switch (props.element.type) {
case 'code':
const decorations = ... // syntax highlighting
return <code>{props.renderChildren({ decorations })}</code>
...
We can use whatever React state mechanism we like to trigger re-rendering / redecoration per node, rather than globally.
As a side benefit: with the existing decoration mechanism, the decorate
function is run on the immediate children of the editor on every edit (in useChildren
in Editable
), even when the function is not changed. This doesn’t cause the elements to be re-rendered (because Element
is memoized on the decorations list), but it can be expensive in itself (in my test case it was ~20ms). If we compute decorations per node in renderElement
, they are not recomputed on every edit.
I prototyped this change in my app and it makes an enormous difference—in a moderately-sized document, editing a code block drops from ~200ms to ~10ms.
Alternatives
I experimented with rendering the decorations manually in renderElement
. Unfortunately I wasn’t able to get this to work because Slate needs Text
nodes to be rendered with the Text
component (to populate various WeakMap
s used to map DOM elements to Slate nodes), and this component is not exported. Even if this did work, the decorations mechanism is nicer—it requires less code duplication, exposes less of Slate’s internals, and implements cross-node decorations already.
For backward compatibility, it’s necessary still to pass children
as well as renderChildren
to renderElement
. This can be made performant by making children
lazy, so we don’t do unnecessary work if renderChildren
is used instead.
For cases where we want to decorate Text
nodes directly (rather than decorating a parent Element
), I think it would make sense to add a renderText
handler to Editable
, so there is opportunity to pass decorations.
I think the approach of passing decorations to renderChildren
is better overall than a global decorate
function, and in some future revision it would make sense to remove decorate
entirely.
Context PR forthcoming.
Issue Analytics
- State:
- Created 2 years ago
- Reactions:16
- Comments:6 (5 by maintainers)
Top GitHub Comments
I’m going to experiment with calling React hooks from the existing
decorate
function (see comments on PR #4484) to see if we can achieve the goal without changing the API.@jaked I’m not sure what you mean by reactive but isn’t that kind of what
RangeRef
provides for this kinda case? Those are kept up-to-date with the editor state.