Helixbassment

Using dynamic portals in React to "play nice with" autoplay policy

Jun 9, 2019

The problem

On the current project, we have a video inside a “lightbox”-style modal. We got design feedback that it would be nice for the video to autoplay when the modal is opened

However, by default when setting the video to autoplay, it was playing muted in Safari/iOS Safari (requiring an additional tap to unmute). Since iPad is the primary deployment target for this project and the design feedback was that it should only autoplay if it can play unmuted, we decided to try and figure out a way to achieve that

We felt like in theory it should be doable since even though browsers have been tightening up their autoplay policies, there has to be a way to play a video unmuted based on explicit user action, and here there was an explicit user action taking place before playing the video (clicking to open the modal)

So Alex figured that (from a React perspective) effectively if the video were already mounted before the modal was opened and then we did something more like an imperative .play() command when the modal was opened, it was likely that the video would play unmuted

The obstacle

If the modal itself could be mounted “up front” and simply kept visually hidden (via CSS) until it was toggled open, that would be straightforward, as the video component inside the modal would also be pre-mounted along with the modal itself

However, on this project we’re using react-bootstrap, which states in its Modal docs that “Modals are unmounted when closed”

And it didn’t seem viable to try and use a different type of modal just for this particular instance

So the only option for having the video pre-mounted before the modal opened seemed to be to somehow mount it somewhere and then “move” it to inside the modal once the modal had mounted

Portals to the rescue?

The React-y way to achieve that would be with a portal. If you haven’t seen React portals before, basically they allow you to render something inside any arbitrary DOM node (rather than inside its React parent)

I had never seen a “dynamic portal” that changed its target parent DOM node “mid-flight”, but that seemed like the most obvious way to structure it - mount the video inside some visually hidden parent container initially (via a portal) and then when the modal opened, *poof* change the portal destination to the modal!

tl;dr it worked

Using this dynamic portal to premount the video, it did in fact play unmuted in Safari!

The code

So let’s look at the specific code patterns used to create this “dynamic portal”

Portals expect to be given an actual DOM node (for the destination). So in React, when you hear “actual DOM node” you should be thinking “ref”

Here, we need two refs since we need two DOM nodes for the portal destination - a visually hidden container and then the modal

Initially I tried using “new-style refs”, since these days I’d default to using that style of ref. But I wasn’t seeing the portal rerendering reliably once the refs were populated

This was new to me, but apparently that’s known/expected behavior when using new-style refs via the useRef hook and if you need to trigger rerendering based on a ref changing, you need to use callback-style refs instead

So here’s how I wired up the two callback-style refs:

import {flow} from 'lodash/fp'

const VideoAnswer = flow(
  ...
  addCallbackRefAndNode(
    'lightboxPortalTargetCallbackRef',
    'lightboxPortalTargetNode'
  ),
  addCallbackRefAndNode(
    'visuallyHiddenVideoContainerCallbackRef',
    'visuallyHiddenVideoContainerNode'
  ),
  ...
  ({
    ...
    lightboxPortalTargetCallbackRef,
    visuallyHiddenVideoContainerCallbackRef,
    ...
  }) =>
    ...
      <div
        css={a11yStyles.visuallyHidden}
        aria-hidden="true"
        ref={visuallyHiddenVideoContainerCallbackRef}
      />
      <Lightbox
        ...
        childPortalTargetRef={lightboxPortalTargetCallbackRef}
      />
    ...
)

First of all, this is an ad-hok-style component using flow(). ad-hok is allowing us to build up component functionality in a highly composable way similar to Recompose, but where you’re using React hooks instead of higher-order components as your building blocks

addCallbackRefAndNode() is a helper for exposing a callback-style ref and the node it references:

import {flow} from 'lodash/fp'
import {upperFirst} from 'lodash'
import {addState, addCallback} from 'ad-hok'

const addCallbackRefAndNode = flow(
  (refPropName, nodePropName) => ({
    refPropName,
    nodePropName,
    setNodePropName: `set${upperFirst(nodePropName)}`,
  }),
  ({refPropName, nodePropName, setNodePropName}) =>
    flow(
      addState(nodePropName, setNodePropName),
      addCallback(
        refPropName,
        ({[setNodePropName]: setNode}) => node => {
          setNode(node)
        },
        []
      )
    )
)

Here, addState() and addCallback() are ad-hok helpers that wrap the useState() and useCallback() hooks, respectively. So addCallbackRefAndNode() is encapsulating a state variable for the callback ref to assign the DOM node reference to

And the childPortalTargetRef is a new prop I added to our <Lightbox> component to allow wiring up the ref to the modal body:

const Lightbox = ({
  ...
  childPortalTargetRef,
}) => (
  <Modal
    ...
  >
    <Modal.Body
      ...
      ref={childPortalTargetRef}
    >
      ...
    </Modal.Body>
  </Modal>
)

So that takes care of getting references to the two DOM nodes we need

Then we want to dynamically change which one the portal uses as its destination based on whether the modal is open:

      <Portal
        to={showingModal ? lightboxPortalTargetNode : visuallyHiddenVideoContainerNode}
      >
        <Video
          ...
          playing={showingModal}
        />
      </Portal>

where <Portal> is just a simple wrapper around React.createPortal():

import {createPortal} from 'react-dom'
import {flowMax, branch, renderNothing} from 'ad-hok'

import {childrenPropType, domNodePropType} from 'util/propTypes'

const Portal = flowMax(
  branch(({to}) => !to, renderNothing()),
  ({to, children}) => createPortal(children, to)
)

Portal.propTypes = {
  to: domNodePropType,
  children: childrenPropType.isRequired,
}

That’s the gist of the dynamic portal implementation

For completeness’ sake, I also ran into a little weird behavior in Chrome: for some reason when the modal was closed, the video’s audio was restarting and continuing to play (rather than the video stopping when the modal closed). We’re using react-player as an abstraction around the actual HTML5 <video> inside our <Video> component, so it’s possible its internal state got wonky. But regardless, we can seize control of the situation by making sure the video is fully unmounted after the modal has been closed

First, set up a hasModalBeenClosed state variable:

import {..., addStateHandlers} from 'ad-hok'

const addVideoUnmountingOnModalClose = flow(
  addStateHandlers(
    {hasModalBeenClosed: false},
    {
      onModalClose: () => () => ({
        hasModalBeenClosed: true,
      }),
    }
  ),
  addEffectOnPropChange(
    ['showingModal'],
    ({showingModal, onModalClose}, prevProps) => {
      if (!showingModal && prevProps.showingModal) {
        onModalClose()
      }
    }
  )
)

This uses the handy addEffectOnPropChange() helper

And then we’ll consider the value of hasModalBeenClosed when deciding what the portal destination should be

Putting it all together:

const addVideoUnmountingOnModalClose = flow(
  addStateHandlers(
    {hasModalBeenClosed: false},
    {
      onModalClose: () => () => ({
        hasModalBeenClosed: true,
      }),
    }
  ),
  addEffectOnPropChange(
    ['showingModal'],
    ({showingModal, onModalClose}, prevProps) => {
      if (!showingModal && prevProps.showingModal) {
        onModalClose()
      }
    }
  )
)

const getVideoPortalTarget = ({
  showingModal,
  lightboxPortalTargetNode,
  hasModalBeenClosed,
  visuallyHiddenVideoContainerNode,
}) => {
  if (showingModal) return lightboxPortalTargetNode
  if (hasModalBeenClosed) return null
  return visuallyHiddenVideoContainerNode
}

const VideoAnswer = flow(
  ...
  addState('showingModal', 'setShowingModal', false),
  addCallbackRefAndNode(
    'lightboxPortalTargetCallbackRef',
    'lightboxPortalTargetNode'
  ),
  addCallbackRefAndNode(
    'visuallyHiddenVideoContainerCallbackRef',
    'visuallyHiddenVideoContainerNode'
  ),
  addVideoUnmountingOnModalClose,
  ({
    ...
    showingModal,
    lightboxPortalTargetCallbackRef,
    lightboxPortalTargetNode,
    visuallyHiddenVideoContainerCallbackRef,
    visuallyHiddenVideoContainerNode,
    hasModalBeenClosed,
  }) => (
    <>
      ...
      <div
        css={a11yStyles.visuallyHidden}
        aria-hidden="true"
        ref={visuallyHiddenVideoContainerCallbackRef}
      />
      <Portal
        to={getVideoPortalTarget({
          showingModal,
          lightboxPortalTargetNode,
          hasModalBeenClosed,
          visuallyHiddenVideoContainerNode,
        })}
      >
        <Video
          ...
          playing={showingModal}
        />
        ...
      </Portal>
      <Lightbox
        show={showingModal}
        ...
        childPortalTargetRef={lightboxPortalTargetCallbackRef}
      />
    </>
  )
)

Unresolved: a11y

This pattern seems to have achieved the desired behavior of getting unmuted autoplay to work cross-browser. But there’s an outstanding accessibility issue with it that I’m not sure if there’s a great solution to:

When the video is initially mounted inside the visually-hidden container, it’s hidden from screen readers using aria-hidden="true". But it’s still keyboard-navigable (ie you can hit Tab and it will focus on the interactive elements of the video component while it’s offscreen)

I tried using the HTML5 hidden attribute instead of aria-hidden="true", but that reverted the autoplay muted behavior

So I’m not sure if there’s a general a11y technique for making all interactive elements under a given parent container element non-keyboard-navigable?

Regardless, I think in this case achieving the desired autoplay behavior on the target iPad platform outweighs the potentially confusing keyboard navigation. But it would be nice to cover our bases a11y-wise especially when thinking about reusing this technique in the future


← Back to all posts