facebook/react

Support cross-renderer portals

gaearon opened this issue ยท 10 comments

Currently createPortal only works within the current renderer.

This means that if you want to embed one renderer into another (e.g. react-art in react-dom), your only option is to do an imperative render in a commit-time hook like componentDidMount or componentDidUpdate of the outer renderer's component. In fact that's exactly how react-art works today.

With this approach, nested renderers like react-art can't read the context of the outer renderers (#12796). Similarly, we can't time-slice updates in inner renderers because we only update the inner container at the host renderer's commit time.

At the time we originally discussed portals we wanted to make them work across renderers. So that you could do something like

<div>
  <Portal to={ReactART}>
    <surface>
      <rect />
    </surface>
  </Portal>
</div>

But it's not super clear how this should work because renderers can bundle incompatible Fiber implementations. Whose implementation takes charge?

We'll want to figure something out eventually. For now I'm filing this for future reference.

Thanks for working on this! This was a big pain point for us so we made a workaround. In case this is useful, here's an example of the Context barrier (for react-three-fiber) and how we are fixing it temporarily until there's a fix upstream:

CodeSandbox bridge example rotation in context

// ๐Ÿ‘Ž Context cannot go through <Canvas>, so <Square> cannot read the rotation
ReactDOM.render(
  <TickProvider>
    <Canvas>
      <Square />
    </Canvas>
    <Consumer>{value => value.toFixed(2)}</Consumer>
  </TickProvider>,
  document.getElementById('outside')
);

// ๐Ÿ‘Ž Context is all inside <Canvas> so it cannot be passed/read from outside
ReactDOM.render(
  <>
    <Canvas>
      <TickProvider>
        <Square />
      </TickProvider>
    </Canvas>
    No access to `rotation` here
  </>,
  document.getElementById('inside')
);

// ๐Ÿ‘ Passes the Context from above, bridging React and react-three-fiber Context
// ๐Ÿ‘Ž But this re-renders <Canvas> a lot, partially defeating the point of Context
// ๐Ÿ‘ memo() Canvas and Square well enough and there's no problem here!
ReactDOM.render(
  <TickProvider>
    <Consumer>
      {value => (
        <Canvas>
          <Provider value={value}>
            <Square />
          </Provider>
        </Canvas>
      )}
    </Consumer>
    <Consumer>{value => value.toFixed(2)}</Consumer>
  </TickProvider>,
  document.getElementById('bridge')
);

โค๏ธโค๏ธโค๏ธ

And just to bring it up once, this also applies to error boundaries and suspense. It would be really helpful if a reconciler could read out contextual information (context, errors, suspense) from its parent reconciler and apply it to itself - i think it shouldn't behave much different than a generic portal.

Well, I hope this gains traction at some point as it's a huge pain point for me right now.

Hi! Any plans on making it come true?

I made something similar to this through a simple implementation that can be shared between renderers.

import React from "react";
import ReactDOM from "react-dom";
import { Portal, createBridge } from "@devsisters/react-pixi";
import { CountContext } from "./countContext";

const $uiRoot = document.getElementById("ui-root");
const uiRoot = ReactDOM.createRoot($uiRoot);
const uiBridge = createBridge(uiRoot, {
  sharedContext: [CountContext],
});

const UIPortal = ({ children }) => {
  return <Portal bridge={uiBridge}>{children}</Portal>;
};

export default UIPortal;

Demo: https://codesandbox.io/s/react-pixi-portal-lsgh1

User can specify the context object to share, and Portal uses React internal readContext() to pass these contexts to another renderer.

I wanted to follow up to say that the solution I proposed above has been working very well for us for 1+ year. The main thing (as I edited in a comment there) is that you have to memo() both the <Canvas> and the <Square> very well, but then everything works very smooth!

We rolled out a useContextBridge hook in the @react-three/drei package for official use within the react-three-fiber ecosystem.

function SceneWrapper() {
  // bridge any number of contexts
  const ContextBridge = useContextBridge(ThemeContext, GreetingContext)
  return (
    <Canvas>
      <ContextBridge>
        <Scene />
      </ContextBridge>
    </Canvas>
  )
}

function Scene() {
  // we can now consume a context within the Canvas
  const theme = React.useContext(ThemeContext)
  const greeting = React.useContext(GreetingContext)
  return (
    //...
  )
}

It's been working out really well and the api is pretty friendly :)

Hi,
I was facing similar issues for react-babylonjs. I made a PR for an approach that seems to work, but might still need some testing.

brianzinn/react-babylonjs#168

Interface is rather simple:

const {TunnelEntrance, TunnelExit} = createTunnel()

Components within TunnelEntrance will be send to TunnelExit. They will use the renderer of TunnelExit and therefore can consume its contexts.

A tunnel allows to render components of one renderer inside another.
I.e. babylonjs components normally need to live within Engine component.
A tunnel entrance allows to position components in a different renderer, such as ReactDOM
and move it to the tunnel exit, that must exist within Engine component.

The nice thing is, even refs can be used outside of Engine context.

The createTunnel function creates a tunnel entrance and exit component.
The tunnel works one-directional.
TunnelEntrance only accepts components that are allowed to live within the renderer of TunnelExit.
Multiple entrances and exits are possible.

If components need to be rendererd the other way around, a second Tunnel is needed.

Edit: There seem to be some race-conditions leading to unwanted side-effects. I.e. if tunnel entrance is used earlier than tunnel exit, components within entrance will not be able to consume context properly. This means, that the components are partly processed already before being supplied to the exit node.

Currently createPortal only works within the current renderer.

At the time we originally discussed portals we wanted to make them work across renderers. So that you could do something like

<div>
  <Portal to={ReactART}>
    <surface>
      <rect />
    </surface>
  </Portal>
</div>

I would like to get back to the first idea of @gaearon . Currently we have solutions for portals within one renderer and context bridges to pass data to a child renderer. However, the idea of the above approach would allow bi-directional communication of renderers, maybe even while using them in parallel.

I am currently facing a situation, where my 2d renderer needs to communicate with a 3d renderer. I could use the react tree approach and change states within a branch of the 2d renderer, throwing it into a context that is bridged down to the 3d renderer and update states over there. ( I cannot do it the other way around with bridges ) In this case I have to deal with data in three different placces. The 2d component, the context and the 3d component.

If we would have a portal like above, we could just keep the logic within a single component.
My tunneling approach above comes close to it. I define a startpoint within one renderer and an endpoint in the other. It is one-directional, but with another tunnel, it is easy to share data in two directions, if necessary. But it is pretty useful to use 2d and 3d renderer logic within one component.

The use of zustand might not be preferable and it currently can lead to side effects in rare cases, that I do not fully understand yet. But I would hope, that there is a bit new research on this topic. Unfortunately I studied archtitecture and not software architecture, so there is a limit to my input :P

we added our fix to react-three-fiber today https://twitter.com/0xca0a/status/1573064826339094528

it's using https://github.com/pmndrs/its-fine#fiberprovider (this would work in any react-renderer)

if any of this could still land officially in react we would be relieved to say the least. not being able to access context across renderers is a major annoyance for our users, and even though forwarding context was always possible, there is a ton of libraries out there not willing to expose context.