Skip to content

Support cross-renderer portals #13332

Open

Description

@gaearon

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.

Activity

  1. franciscop-sc commented on Jul 19, 2019

    @franciscop-sc

    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')
    );

    ❤️❤️❤️

  2. drcmda commented on Nov 6, 2019

    @drcmda

    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.

  3. mabdullahsari commented on Jan 30, 2020

    @mabdullahsari

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

  4. anion155 commented on Apr 3, 2020

    @anion155

    Hi! Any plans on making it come true?

  5. cometkim commented on Apr 15, 2020

    @cometkim

    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.

  6. franciscop-sc commented on Sep 1, 2020

    @franciscop-sc

    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!

  7. stephencorwin commented on Nov 12, 2020

    @stephencorwin

    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 :)

  8. dennemark commented on Oct 27, 2021

    @dennemark

    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.

  9. dennemark commented on Nov 30, 2021

    @dennemark

    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

  10. drcmda commented on Sep 22, 2022

    @drcmda

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions