The challenges of rendering an OpenLayers map in a popup through React

noriste

Stefano Magni

Posted on April 21, 2022

The challenges of rendering an OpenLayers map in a popup through React

RouteManager is a Route Optimization Software for delivery businesses, heavily based on maps. We completely rewrote the software from scratch (250K React+TypeScript LOC) during the last two years. Still, we left at the end an important feature: the Extracted map that allows the customers to use a second browser tab to keep an eye on the orders and vehicles situation on the map.

The right approaches

The best approach in terms of scalability and robustness would be to create a dedicated app that communicates with the Web Worker that stores the application state (the app is already split in the main UI thread driven by the state owned by the Web Worker).

Anyway, the need to include the feature in the planned deadline pushed us to be creative, even at the cost of keeping a more fragile solution for the next year or so.

The following solution in the row would have been to create two map instances, but it was not easy to implement because of the app's architecture.

Rendering a portion of the component tree in a popup

Technically speaking, rendering a portion of a React app in a popup is very handy, thanks to React Portals. A sort of teleportation.

Image description

Thanks to a friend of mine (👋 Simone), I stumbled upon to this great Scott Logic's Investigating Multi-Window Browser Applications article. Simone also shared withe the source of a POC that allows rendering a component children in a popup (it's just a quick example)

import { StylesProvider, ThemeProvider as MuiThemeProvider, useTheme } from '@material-ui/core'
import { useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'

interface NewWindowPortalProps {
  onClose: () => void
  title: string
}

function copyStyles(sourceDoc: Document, targetDoc: Document) {
  Array.from(sourceDoc.querySelectorAll('link[rel="stylesheet"], style')).forEach(link => {
    targetDoc.head.appendChild(link.cloneNode(true))
  })
}

const NewWindowPortal: React.FC<NewWindowPortalProps> = ({ children, title, onClose }) => {
  const theme = useTheme()
  const nodeRef = useRef<HTMLElement>(document.createElement('div'))
  const windowRef = useRef<Window | null>(null)

  const [isLoading, setLoading] = useState<boolean>(true)

  useEffect(() => {
    // TODO: make height/width/position configurable?
    windowRef.current = window.open('/widget.html', '', 'width=1000,height=600,left=200,top=200')

    if (windowRef.current) {
      windowRef.current.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
        // safari loads (and unloads) about:blank before loading /widget.html
        if ((e.target as Document).URL !== 'about:blank') {
          onClose()
        }
      })
      windowRef.current.addEventListener('load', () => {
        if (windowRef.current) {
          windowRef.current.document.body.appendChild(nodeRef.current)
          windowRef.current.document.title = title
        }
        setLoading(false)
      })
    }
    return () => {
      windowRef.current?.close()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    if (!isLoading && windowRef.current) {
      copyStyles(document, windowRef.current.document)
    }
  }, [isLoading])

  return isLoading
    ? null
    : ReactDOM.createPortal(
        <StylesProvider>
          <MuiThemeProvider theme={theme}>{children}</MuiThemeProvider>
        </StylesProvider>,
        nodeRef.current,
      )
}

export default NewWindowPortal
Enter fullscreen mode Exit fullscreen mode

The advantages are the components rendered in the popup are part of the main React app for real, and they can leverage the same application state! We opted to go this way to respect the deadline but a lot of caveats popped out during the development that confirmed us that the solution is suboptimal.

Caveats

Working in the unknown means facing problems when they appear, not anticipating them. Following a quick list of all of the issues, hoping it could help the reader to know them before starting to write code.

No window.focus()

While focusing the popup is not a problem, the main window cannot be focused back. All the browsers have some internal rules to manage to focus the main window, but none of them (and the tens of workarounds found on StackOverflow) work reliably.

Keeping styles in sync between the windows

The benefit of rendering some components in a popup is that, from a React perspective, the components are rendered inside the application. This is not true from a browser perspective, though.

The browser cannot correctly style the application in the popup because the styles are in the main window's head. The CSS-in-JS libraries have their way of adding styles to the page.

I worked around the problem by:

  1. Cloning the CSS files loaded externally
  2. Cloning the CSS rules added to the page by the CSS-in-JS libraries
  3. Keeping the CSS rules added to the page by the CSS-in-JS libraries in sync. This is necessary because every dynamically-shown component' CSS rules are not added to the page in advance (ex. the modal and the tooltip ones).

When copying the CSS-in-JS-based styles, you also have to filter out the browser extension styles.

Here how I managed to clone the external stylesheets

type Options = {
  mainWindowDoc: Document
  popupDoc: Document

  // Avoid cloning the same stylesheet over and over again.
  mutableCache: WeakMap<Node, Node>

  measurePerformance?: boolean
}

/**
 * Clone all the externally-loaded stylesheets (ex. the fonts).
 */
export function copyExternalStylesheets(options: Options) {
  const { mainWindowDoc, popupDoc, mutableCache, measurePerformance = false } = options

  const start = measurePerformance ? performance.now() : 0

  // Retrieve all the links from the main window
  const stylesheets = mainWindowDoc.querySelectorAll(
    'link[rel="stylesheet"]',
  ) as NodeListOf<HTMLLinkElement>

  // Clone the stylesheets and append them to the popup window
  for (const stylesheet of stylesheets) {
    // Avoid cloning the stylesheet if not needed
    if (mutableCache.has(stylesheet)) continue

    const clonedStylesheet = stylesheet.cloneNode(true) as HTMLLinkElement
    popupDoc.head.appendChild(clonedStylesheet)

    mutableCache.set(stylesheet, clonedStylesheet)
  }

  // Log the overall performance
  if (measurePerformance) {
    console.log(
      `Cloning the external stylesheets in the popup took ${performance.now() - start} ms`,
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Here how I managed to clone the internal CSS rules

type Options = {
  mainWindowDoc: Document

  // The container that will host all the CSS rules.
  mutableContainer: HTMLStyleElement

  measurePerformance?: boolean
}

/**
 * Clone all the internal CSS rules of a document. The purpose of this function is to clone the
 * dynamically-created stylesheets (all the CSS-in-JS ones).
 *
 * Please note that the styles created by the CSS-in-JS libraries don't have a set 'innerHTML' but
 * they can only be clones by reading `document.styleSheets`.
 *
 * see: https://github.com/mui/material-ui/issues/16756#issue-473252809
 */
export function copyCssRules(options: Options) {
  const { mainWindowDoc, mutableContainer, measurePerformance = false } = options

  const start = performance.now()

  const allStylesheets = mainWindowDoc.styleSheets

  // Filter out the browser extension stylesheets
  // see: https://betterprogramming.pub/how-to-fix-the-failed-to-read-the-cssrules-property-from-cssstylesheet-error-431d84e4a139
  const appStyleSheets = Array.from(allStylesheets).filter(
    styleSheet => !styleSheet.href || styleSheet.href.startsWith(window.location.origin),
  )

  let allCssRules = `
/* Dynamically-copied CSS rules, see copyDocumentStylesheets */
  `

  // Create a string containing all the CSS rules
  for (const stylesheet of appStyleSheets) {
    for (const rule of stylesheet.cssRules) {
      allCssRules += `
${rule.cssText}
`
    }
  }

  mutableContainer.textContent = allCssRules

  // Log the overall performance
  if (measurePerformance) {
    console.log(
      `Updating the popup styles with all the CSS rules took ${performance.now() - start} ms`,
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Here how I managed to keep the CSS rules in sync

import { copyCssRules } from './copyCssRules'

type Options = {
  mainWindowDoc: Document
  mutableContainer: HTMLStyleElement
  initialSync?: boolean
  measurePerformance?: boolean
}

/**
 * Clone all the CSS rule of a document every time something changes in the main window's head.
 * The purpose is intercepting every CSS-in-JS-related change and update the popup's styles accordingly.
 */
export function keepCssRulesInSync(options: Options) {
  const {
    mainWindowDoc,
    mutableContainer,
    initialSync = true,
    measurePerformance = false,
  } = options

  function onStylesChange() {
    copyCssRules({
      mainWindowDoc,
      mutableContainer,
      measurePerformance,
    })
  }

  const observer = new MutationObserver(onStylesChange)
  const mainWindowHead = mainWindowDoc.querySelector('head')

  if (!mainWindowHead) throw new Error('No head found in the main window')

  observer.observe(mainWindowHead, { subtree: false, childList: true })

  if (initialSync) {
    copyCssRules({
      mainWindowDoc,
      mutableContainer,
      measurePerformance,
    })
  }

  return () => observer.disconnect()
}
Enter fullscreen mode Exit fullscreen mode

No asynchronous code (hence, no React Hooks-based code)

We built our React wrapper around OL, based on many React Hooks. But the asynchronous nature of React hooks (both useEffect and useLayoutEffect are executed asynchronously) caused most of the problems we had with the OpenLayers' (OL, from now on) map. When the map moves back and forth from the main window and the popup one, it seemingly looses its target, and stops rendering correctly (in the next screenshot you can see the empty map).

Image description

As this issue reports, I'm sure the problem relies in our React wrapper, not in OL itself.

My solution: when the popup closes, the map target is synchronously set to an always-existing div of the main window. Later, our OL React wrapper moves the map to the new container asynchronously (and delayed 200 ms, always to work around some map odds).

Here how I managed it

import type OlMap from 'ol/Map'
import type { PopupPlugin } from '../types'

type Params = {
  map: OlMap

  /**
   * The container that must always be available to allow the map rendered in the popup to
   * immediately switch back to the main window when the popup close. Theoretically, this is not
   * necessary since OL already answered to this issue https://github.com/openlayers/openlayers/issues/13525
   * but the mentioned solution sometimes does not work for the markers (but always works for the
   * other layers). The bug happens in our own code since in this CodeSandbox
   * https://codesandbox.io/s/external-map-bug-markers-jew6cd?file=/main.js:2021-2043
   * you can see that both the standard layers and the marker one always work.
   */
  mainWindowTemporaryContainerId: string
}

/**
 * Create a popup plugin that works around the map limitations when being moved to a popup.
 */
export function createMapPopupPlugin(params: Params) {
  const { map, mainWindowTemporaryContainerId } = params

  const plugin: PopupPlugin = {
    // --------------------------------------------------
    // POPUP CLOSE
    // --------------------------------------------------
    onClose: () => {
      const currentTarget = map.getTarget()

      // The map is not mounted yet
      if (!currentTarget) return

      // With the introduction of the Extracted Map, the map's target could not be a string anymore.
      if (typeof currentTarget === 'string') return

      const mainWindowTemporaryContainer = getMainWindowTemporaryContainer(
        mainWindowTemporaryContainerId,
      )

      const mapWasInPopup = currentTarget.ownerDocument.defaultView !== window

      if (!mapWasInPopup) return

      // Immediately set the map target to a temporary container of the main window to avoid render
      // errors with the map layers.
      // ATTENTION: setting the target to a main window element must be performed synchronously whrn
      // the popup closes!
      map.setTarget(mainWindowTemporaryContainer)
    },
  }

  return { plugin }
}

function getMainWindowTemporaryContainer(id: string) {
  const container = document.getElementById(id)

  // Prompt the developer in case of missing temporary container
  if (!container) {
    throw new Error('No temporary map container available')
  }

  return container
}
Enter fullscreen mode Exit fullscreen mode

No string-based map targets

OL allows setting the target in various modes, we were using the string-based one, but I refactored it because OL retrieves the target HTML element from the main window, without supporting external windows.

Controlling what is rendered in the popup

The popup renders a couple of controlled children for some specific sections and a common fallback for all the other sections.

I control what is rendered in the popup through a sort of LIFO queue that allows the more nested components to overwrite the content of the popup. Note that the queue is necessary because React portals do not prevent from rendering multiple children inside them. As a result, the popup could have both the map and the fallback rendered simultaneously.

The map is unusable when the main window is hidden

You can refer to this issue I opened to OL to know all the details, but the TL;DR is that if the main window gets hidden, the map becomes unresponsive. Then, I copied Ol's approach of their "External map" example, where a div completely covers the map when the main window gets hidden.

Disable MUI popover's portals

Thanks to React Portals, a component can be rendered in a popup window, and the popup window can render a component back in the main window. This was exactly what was happening with our map tooltips, where MUI rendered the tooltip back in the main window.

MUI exposes a disablePortal prop purposefully for the Popover
component, setting it to true gets the component correctly rendered in the popup.

MUI wrong popover's position

The tooltips' left CSS property was not correctly calculated in the
popup. I then forced an update of all the tooltips rendered inside the popup, thanks to the imperative APIs exposed by MUI.

The involved code

You can take a look at all the involved code (it's just a git diff run on our main monorepo to include only the popup-related code) I created a dedicated repository.

Last but not least...

Many thanks to Simone D'Avico to point me to this great Scott Logic's Investigating Multi-Window Browser Applications article.

If you want to know more about how we work in the WorkWave's RouteManager team, take a look at the following articles

💖 💪 🙅 🚩
noriste
Stefano Magni

Posted on April 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related