Seeing Double

Ok, but hear me out, what if there were two ReactDOM renderers?

~*~ record scratch ~*~

Why are we talking about this at all?

React & ProseMirror: Best Frenemies

  • Both want control over the DOM
  • So we give some DOM to ProseMirror, and some other DOM to React
  • But now there's too much DOM!

Here's our ProseMirror document:


                const doc = {
                  "type": "doc",
                  "content": [
                    {
                      "type": "paragraph",
                      "content": [
                        {
                          "type": "text",
                          "text": "Here's some text"
                        }
                      ]
                    }
                  ]
                }
              

Here's our DOM:


                
                

Here's some text

What's going on with those extra elements?


                
                

Here's some text

We just want this!


                
                

Here's some text

Why is this so hard?

What would we need to do?

The answer seems maybe not so bad at first glance.

We just need to ask React to render first, and then hand the result to ProseMirror.

Let's try it!

Seems alright so far...


              const container = document.createElement('div');
              const root = createRoot(container);
              flushSync(() => {
                root.render(<NodeViewWrapper { ... } />);
              });
            

But if we zoom out...


              useLayoutEffect(() => {
                ...
                const container = document.createElement('div');
                const root = createRoot(container);
                flushSync(() => {
                  root.render(<NodeViewWrapper { ... } />);
                });
                ...
              });
            
Warning: flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task.

🤦

Recap

  • We want to render React synchronously, so that we have something to hand our whiny little brother (ProseMirror).
  • We can't trigger a synchronous render of the React tree from within a React render.
  • ... We don't actually need to render the whole React tree.
  • We need a secondary renderer.

Secondary what now?

  • React is actually broken up into a few smaller parts.
  • There's a library, react-reconciler, that is responsible for producing the virtual document.
  • And then there are several "renderers", like React DOM and React Native, that know how to take changes indicated by the virtual document and turn them into "host" changes.

What does a renderer do?

Renderers implement methods like createTextInstance() and appendChildToContainer().


            createTextInstance(
              text,
              rootContainerInstance,
              hostContext,
              internalInstanceHandle,
            ) {
              return document.createTextNode(text);
            },
            
            appendChildToContainer(container, child) {
              container.appendChild(child);
            }
          

Primary vs Secondary Renderers

  • React DOM and React Native are "primary" renderers. There can be exactly one primary renderer per application.
  • There are also "secondary" renderers, like React ART, which is for rendering 2D graphics. There can be up to one secondary renderer per application.
  • React manages primary and secondary rendering contexts separately, which means that you can kick off a secondary render cycle from within a primary render cycle!

            import { createContainer, updateContainer } from 'react-reconciler';
              
            class Surface extends React.Component {
              componentDidMount() {
                const {height, width} = this.props;
                this._surface = Mode.Surface(+width, +height, this._tagRef);
                this._mountNode = createContainer(
                  this._surface,
                  LegacyRoot,
                  null,
                  false,
                  false,
                  '',
                );
                updateContainer(
                  this.props.children,
                  this._mountNode,
                  this
                );
              }
            ...
          

Can we do this with React DOM?

Turns out... yes??


            // export const isPrimaryRenderer = true;
            export const isPrimaryRenderer = false;  
          

            $ yarn build
            .../
          

            $ cp ./build/react-dom/react-dom.development.js \
              ../react-prosemirror/react-dom-secondary.development.js
          

Let's try this again...


            // import { createRoot } from 'react-dom/client';
            import { createRoot } from 'react-dom-secondary/client';
              
            useLayoutEffect(() => {
              ...
              const container = document.createElement('div');
              const root = createRoot(container);
              flushSync(() => {
                root.render(<NodeViewWrapper { ... } />);
              });
              ...
            });
          

🎉

Downsides

  • Multiple React trees — rather than a single React tree that parents all elements, including node views, we now have a separate root for each node view.
  • No context sharing — React context doesn't automatically flow from the primary renderer to the secondary renderer.
    • This can be manually mitigated, but it's not pretty!