Skip to content

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

Description

@mikeyfarina

Description

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

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 === oldContainer → true.

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:

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions