reactjs / react-rails

Integrate React.js with Rails views and controllers, the asset pipeline, or webpacker.
Apache License 2.0
6.75k stars 759 forks source link

PoC: Loading components in an included Turbo Frame #1113

Open multiplegeorges opened 3 years ago

multiplegeorges commented 3 years ago

Hey all,

Like many others, I've been experimenting with Turbo and Turbo Frames. We have a large set of one-off React components we like to sprinkle into our templates using react-rails and that pattern works great for enhancing interactivity.

But, like many others, we quickly realized that Turbo doesn't emit any events when a frame loads. This is by design and their logic makes sense.

To fix this we've put together a quick proof of concept MutationObserver that watches the document tree for changes. These changes could come from any source, but in our case it's always a Turbo Frame load. I don't think there any need to differentiate based on the source of the change.

import React from 'react'
import ReactDOM from 'react-dom'

declare const ReactRailsUJS

document.addEventListener("DOMContentLoaded", () => {
  const findComponents = (childNodes: NodeList, testFn: (n: Node) => Boolean, nodes: Node[] = []): Node[] => {
    for (let child of childNodes) {
      if (child.childNodes.length > 0) {
        nodes = findComponents(child.childNodes, testFn, nodes)
      } else if (testFn(child)) {
        nodes = nodes.concat([child])
      }
    }

    return nodes
  }

  const mountComponents = (nodes: Node[]) => {
    for (let child of nodes) {
      const className = (child as Element).getAttribute(ReactRailsUJS.CLASS_NAME_ATTR)
      if (className) {
        // Taken from ReastRailsUJS as is.
        const constructor = ReactRailsUJS.getConstructor(className)
        const propsJson = (child as Element).getAttribute(ReactRailsUJS.PROPS_ATTR)
        const props = propsJson && JSON.parse(propsJson)

        // Improvement:
        // Was this component already rendered? Just hydrate it with the props coming in.
        // This is currently acceptable since all our components are expected to be reset
        // on page navigation.
        const component = React.createElement(constructor, props) as any
        ReactDOM.render(component, child as Element)
      }
    }
  }

  const callback = function (mutationsList: MutationRecord[], observer: MutationObserver) {
    const start = performance.now()
    console.log("ReactRails: Mutation callback started...", mutationsList)

    for (const mutation of mutationsList) {
      if (mutation.type === 'childList') {
        if (mutation.addedNodes.length > 0) {
          const mountableNodes = findComponents(mutation.addedNodes, (child) => {
            return !!(child as HTMLElement).dataset?.reactClass
          })

          mountComponents(mountableNodes)
        }
      }
    }

    console.log("ReactRails: Mutation callback complete.", performance.now() - start)
  };

  const observer = new MutationObserver(callback)

  console.log("ReactRails: Start mutation observer...")
  observer.observe(document, { childList: true, subtree: true })
})

We've simply added this to our application.js pack file and we've found that this works quite well.

Hopefully this helps someone else out and/or starts a discussion about moving react-rails to this model for mounting/unmounting components. It's a lot more robust than watching for Turbo events, I think, but I'm sure there's a ton of edge cases covered by the existing code.

Cheers!

phoozle commented 3 years ago

Thanks very much!

buncis commented 2 years ago

is this a solution when the react component not attached/loaded in the page that reloaded using turbo?

multiplegeorges commented 2 years ago

@buncis Yes, this code should help when you need to initialize a React component that is lazy loaded in a Turbo Frame after the initial page load.