[Bug] reuseMaps: Mapbox.reuse() infinite-loops (tab freeze) when the recycled map's container is the mount container

Author: mikeyfarinaCreated Aug 28, 2026Updated Aug 28, 2026

Description

With reuseMaps enabled, Mapbox.reuse() reparents the pooled map's DOM into the new mount container:

javascript
const oldContainer = map.getContainer();
container.className = oldContainer.className;
while (oldContainer.childNodes.length > 0) {
  container.appendChild(oldContainer.childNodes[0]);
}

If container === oldContainer, appendChild moves the first child to the end of the same node, so childNodes.length never decreases and the loop never terminates. The main thread pins at 100% CPU and the tab is unrecoverable — DevTools cannot attach, and on weaker machines the whole browser goes down.

The same-container case is real, including in production builds: any environment that re-runs the <Map> mount effect while the DOM node survives triggers it. We hit two:

  1. Production: Next.js 16 App Router back/forward navigation restores the previous page while re-running effects (React Activity semantics). The mount effect's cleanup ran recycle() when the user navigated away; on back-navigation reuse() pops that instance and mounts it into the very container it was parked with. First back-navigation to the map page froze the tab, every time, for every visitor.
  2. Development: React <StrictMode> effect replay reaches the same state when the mapLib import is already cached — effect run 1's .then creates/reuses the map, the replay cleanup recycles it, and effect run 2 reuses it into the same still-mounted container. (A first-ever mount survives only because the import('mapbox-gl') resolves too slowly for run 1 to finish.)

Verified by pausing the wedged VM over CDP: execution sits inside reuse() with container === oldContainertrue.

Two related consequences we found while fixing it:

  • On the same-container path, the [mapboxgl-children] div is lost: recycle() removed it, and since reuse() returns the same instance, setMapInstance() bails out on Object.is and React never re-inserts it — any plain-JSX map child silently disappears after the reuse.
  • Mapbox.savedMaps is a global LIFO with no map↔container association, so with two simultaneously pooled maps a reuse can pop the other map and append its DOM into a container that still holds the first map's canvas.

Environment

  • @vis.gl/react-mapbox 8.1.2 (via react-map-gl v8); the unguarded loop is also present on current master, and byte-identical copies ship in @vis.gl/react-maplibre and the mapbox-legacy entry
  • mapbox-gl 3.28.1, React 19.2, Next.js 16.3
  • Reproduces deterministically on the first back-navigation to a page rendering <Map reuseMaps> behind a Suspense boundary

Suggested fix

Treat container === oldContainer as "already in place", and move the [mapboxgl-children] cleanup out of recycle() into the different-container branch so the surviving React tree keeps its children div:

javascript
static reuse(props, container) {
  const that = Mapbox.savedMaps.pop();
  if (!that) return null;
  const map = that.map;
  const oldContainer = map.getContainer();
  if (oldContainer !== container) {
    container.className = oldContainer.className;
    oldContainer.querySelector('[mapboxgl-children]')?.remove();
    while (oldContainer.childNodes.length > 0) {
      container.appendChild(oldContainer.childNodes[0]);
    }
  }
  // ...
}

recycle() {
  Mapbox.savedMaps.push(this);
}

We've been running exactly this as a pnpm patch in production since 2026-08-27 with no regressions (including a browser test that walks pin → popup → team page → back). Happy to send it as a PR.