0

I'm working on a custom Gutenberg block that uses the Mapbox GL JS API for displaying a map. To create the map I have to call the following constructor.

const map = new mapboxgl.Map({
  container: 'map', // container ID
  style: 'mapbox://styles/mapbox/streets-v12', // style URL
  center: [-74.5, 40], // starting position [lng, lat]
  zoom: 9 // starting zoom
});

According to the documentation, the container property can be either a string with the ID of the container or an instance of an HTMLElement. Since my implementation is a Gutenberg block, I went with the latter by using useRef from the @wordpress/element package to reference the container element.

The following code is my implementation:

import mapboxgl from 'mapbox-gl';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { useEffect, useRef, useState } from '@wordpress/element';

mapboxgl.accessToken = '*myaccesstoken*';

const Edit = ({ attributes, setAttributes }) => {
  const blockProps = useBlockProps();

  const mapContainer = useRef(null);
  const mapRef = useRef(null);
  const [lng, setLng] = useState(-70.9);
  const [lat, setLat] = useState(42.35);

  useEffect(() => {
    if (mapRef.current !== null || mapContainer.current === null) {
      return;
    }

    mapRef.current = new mapboxgl.Map({
      container: mapContainer.current,
      style: 'mapbox://styles/mapbox/streets-v12',
      center: [lng, lat],
      zoom: 9
    });
  }, []);

  return (
    <div {...blockProps}>
      <div id="map" ref={mapContainer}></div>;
    </div>
  )
};

export default Edit;

Whenever I use this block in the Full Site Editing (FSE) mode, the mapboxgl.Map constructor throws the following error:

Error: Invalid type: 'container' must be a String or HTMLElement.

(This doesn't happen whenever the block is used on a single post).

When logging mapContainer.current I can see the <div #map> element being logged, though the constructor is not accepting it. Is the element referenced in useRef somehow not an actual HTMLElement instance?

I based my implementation on this Mapbox + React tutorial from their own site.

Selecting a random element outside the block with the DOM API does work as expected. For example container: document.getElementById('some-id'). This does indicate that the API works.

Selecting the <div #map> from within the useEffect hook with the DOM API results in the element not being found.

container: 'map' // -> Error: Container 'map' not found
container: document.getElementById('map') // -> Error: Invalid type: 'container' must be a String or HTMLElement

Github Logo Github repo of block

7
  • I've never used Mapbox GL JS, but I don't think refs are needed. Just use <div id="map"></div>, and then the map should initialize correctly by simply passing the container's id - useEffect( () => { const map = new mapboxgl.Map( { container: 'map' } ); ... }, [] ), or the container's element - just replace the 'map' with document.getElementById( 'map' ). Have you already tried any of that?
    – Sally CJ
    Jul 24 at 8:33
  • I have indeed tried that. Both container: 'map' and container: document.getElementById('map') fail to find the element. The former throws the error Error: Container 'map' not found and the latter show the error mentioned in the question. I've also turned to the react-map-gl package (a React wrapper for the Mapbox GL JS API) but that too threw the same error as in the question. Jul 24 at 8:48
  • In that case, then could you please share your full source code, e.g. a link to your block's GitHub repo?
    – Sally CJ
    Jul 24 at 12:29
  • @SallyCJ Thanks for looking into it. I've added a link to the repo to the question. Jul 24 at 13:23
  • Well, I built your block and tried it on a WordPress v6.2.2 site, and your code worked just fine for me - no errors in the console. However, you should remove the lines 1 (import mapboxgl from 'mapbox-gl';) and 52 (mapLib={mapboxgl}) - I've already tried removing them and your block still worked fine. see screenshot here
    – Sally CJ
    Jul 24 at 15:12

2 Answers 2

2

Is the element referenced in useRef somehow not an actual HTMLElement instance?

No, it is.

But (as you've already known it), Mapbox GL JS (v2.15.0) requires that the container, when supplied as an element, be an instance of window.HTMLElement. ( note the window. which means the parent window )

And the thing is, on the FSE/Site editor page, the block's editor script which initializes the Mapbox map is in the parent window, but then div#map is inside a child window which is an iframe named editor-canvas, hence:

  1. document.getElementById('map'), where document refers to window.document, returned a null.

  2. The map div is no longer an instance of window.HTMLElement and instead, it's now an instance of the iframe's contentWindow.HTMLElement. I.e.

    • mapContainer.current instanceof HTMLElement (which is the same as mapContainer.current instanceof window.HTMLElement) returns false.

    • mapContainer.current instanceof document.querySelector( 'iframe[name="editor-canvas"]' ).contentWindow.HTMLElement returns true.

So for the time being, as you commented, you would need to use SSR (i.e. server-side rendering) instead.

I've created a GitHub repo with a working example where my edit function returns an element with an iframe which renders the Mapbox map, as you could see below: (I used Firefox)

Preview image

And here's my edit function: ( see full code here )

const Edit = ( { attributes, setAttributes } ) => {
    const { accessToken } = attributes;

    const mapContainer = useRef( null );

    const [ mapLoaded, setMapLoaded ] = useState( false );

    const onMapFrameLoad = () => {
        let map;

        // Note: getMap is defined on the SSR page. It's a custom function which
        // returns the Mapbox instance for the map initialized on that SSR page.
        try {
            map = mapContainer.current.contentWindow.getMap();
        } catch ( error ) {
            console.log( 'Error accessing the Mapbox\'s map instance!', error );
            return;
        }

        map.on( 'load', () => {
            setMapLoaded( true );
            console.log( 'map ready', map );
        } );
    };

    return (
        <div { ...useBlockProps() }>
            { /* In this demo, I'm deliberately not using <InspectorControls>. */ }
            <fieldset>
                <TextControl
                    label="Access Token"
                    value={ accessToken }
                    onChange={ ( value ) => setAttributes( { accessToken: value } ) }
                />
            </fieldset>

            { ( accessToken && ! mapLoaded ) && <i>Loading map..</i> }

            { accessToken && (
                <iframe
                    src={ ssrPageUrl( accessToken, 300 ) }
                    width="100%"
                    height="300"
                    style={ { border: 0, overflow: 'hidden' } }
                    id="map-frame"
                    onLoad={ onMapFrameLoad }
                    ref={ mapContainer }
                ></iframe>
            ) }
        </div>
    );
};
7
  • Thanks for your time and effort. Unfortunately the issue persists. I ran your code, as is in the Gist, but it had the the same behavior as my initial attempt. It seems that because the code is running in an iframe, the container can never evaluate instanceof window.HTMLElement to true because the element lives in a different window. And this is exactly what the library is evaluating and is what is causing the error. The only possible solution I can think of is SSR and injecting a JS file with the Mapbox lib. Jul 27 at 20:19
  • 1
    Well, I've been so much used to using Chrome that I forgot Firefox existed... 😛 And thank you for the kudos, I actually enjoyed creating that video :) So yes, you're right that the snippet did not work on Firefox - the mapContainer.current.appendChild( mapDiv ) changed the mapDiv to being an instance of the iframe's contentWindow.HTMLElement - :( ( it worked on Edge and Opera, desktop versions, so I'm not sure which browsers were to blame 🤷‍♀️ ) Anyway, I've completely revised my answer, so I hoped that also helped you.
    – Sally CJ
    Jul 28 at 15:49
  • 1
    Thanks again for taking the time with me. I might try to report it as a bug at Mozilla and hope to at least get an explainer as to why this is implemented differently than other vendors. Jul 29 at 19:36
  • 1
    I've made progress and wanted to share it with you. The solution is not to use SSR but Web Components and Shadow DOM. When the map container is within a Shadow DOM of a custom element, then the reference to the container element can always be found and will be a instance of window.HTMLElement regardless of the custom element being in an iframe or not. Aug 13 at 9:54
  • 1
    I did think about something like class MapDiv extends HTMLElement, but did not dig deeper at using custom elements, and thanks for sharing about shadow DOM 👍 which obviously I never used.. So maybe you should post your solution as an answer and accept it later - after unaccepting my answer 🙂
    – Sally CJ
    Aug 15 at 0:49
1

Thanks to the very helpful answer of @sally-cj we found out that Mapbox requires the container property to be an instance of window.HTMLElement or a valid string to query for an element. Because of the structure of the FSE environment, some browsers, like Firefox117.0b5, will not evaluate instanceof window.HTMLElement to true. This will result in the map throwing the following error.

Error: Invalid type: 'container' must be a String or HTMLElement.

As a solution we can wrap the creation of mapboxgl.Map in a custom element with a shadow DOM. This way we can reference the container element from within the custom element, and because the element is in the shadow DOM, it will always be an instance of window.HTMLElement.

A implementation of a custom element would look like the snippet below.

custom-element.js

This snippet will register the custom element as a mapbox-map HTML element. The mapboxgl.Map will run in the connectedCallback. It will fire a CustomEvent when the map loads which enables us to listen outside of the custom element for the load event.

import mapboxgl from 'mapbox-gl';

class MapboxElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' />

      <style>
        :host {
          contain: layout;
          display: block;
          position: relative;
          width: 100%;
        }

        #container {
          display: block;
          height: 100%;
          width: 100%;
        }
      </style>

      <div id="container"></div>
    `;
  }

  get container() {
    return this.shadowRoot.querySelector('#container');
  }

  get accessToken() {
    return this.getAttribute('access-token');
  }

  set accessToken(value) {
    if ('string' === typeof value) {
      this.setAttribute('access-token', value);
    } else {
      this.removeAttribute('access-token');
    }
  }

  connectedCallback() {
    const map = new mapboxgl.Map({
      accessToken: this.accessToken,
      container: this.container
    });

    map.on('load', () => {
      const mapReadyEvent = new CustomEvent('mapready', {
        bubbles: true,
        detail: {
          map
        }
      });

      this.dispatchEvent(mapReadyEvent);
    });
  }
}

customElements.define('mapbox-map', MapboxElement);

Edit.js

Here we render the mapbox-map element whenever the accessToken value isn't empty. It then adds an event listener to the custom element to set the isLoaded state whenever the map has been loaded.

The event will also expose the mapboxgl.Map instance in the detail property of the CustomEvent.

import { useEffect, useRef, useState } from '@wordpress/element';

const Edit = ({ attributes, setAttributes }) => {
  const { accessToken } = attributes;
  const [isLoaded, setIsLoaded] = useState(false);
  const blockProps = useBlockProps();
  const mapRef = useRef(null);

  useEffect(() => {
    if (!accessToken) {
      return;
    }

    setIsLoaded(false);

    mapRef.current.addEventListener('mapready', (event) => {
      const map = event.detail.map;
      // ... Use the map here.

      setIsLoaded(true);
    });
  }, [accessToken]);

  return (
    <div {...blockProps}>
      {accessToken && (
        <mapbox-map
          ref={mapRef}
          access-token={accessToken}
        ></mapbox-map>
      )}
    </div>
  );
}

block.json

To load the custom element into both the FSE and frontend, add the script property and reference the custom-element.js file.

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "domain/mapbox-map",
  "title": "Mapbox Map",
  "version": "1.0.0",
  "editorScript": "file:./index.js",
  "script": "file:./custom-element.js",
  ...
}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.