@vis.gl/react-google-maps
Version:
React components and hooks for the Google Maps JavaScript API
203 lines (172 loc) • 6.43 kB
text/typescript
import {APILoadingStatus} from './api-loading-status';
export type ApiParams = {
key: string;
v?: string;
language?: string;
region?: string;
libraries?: string;
channel?: number;
solutionChannel?: string;
authReferrerPolicy?: string;
};
type LoadingStatusCallback = (status: APILoadingStatus) => void;
const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js';
/**
* A GoogleMapsApiLoader to reliably load and unload the Google Maps JavaScript API.
*
* The actual loading and unloading is delayed into the microtask queue, to
* allow using the API in an useEffect hook, without worrying about multiple API loads.
*/
export class GoogleMapsApiLoader {
/**
* The current loadingStatus of the API.
*/
public static loadingStatus: APILoadingStatus = APILoadingStatus.NOT_LOADED;
/**
* The parameters used for first loading the API.
*/
public static serializedApiParams?: string;
/**
* A list of functions to be notified when the loading status changes.
*/
private static listeners: LoadingStatusCallback[] = [];
/**
* Loads the Maps JavaScript API with the specified parameters.
* Since the Maps library can only be loaded once per page, this will
* produce a warning when called multiple times with different
* parameters.
*
* The returned promise resolves when loading completes
* and rejects in case of an error or when the loading was aborted.
*/
static async load(
params: ApiParams,
onLoadingStatusChange: (status: APILoadingStatus) => void
): Promise<void> {
const libraries = params.libraries ? params.libraries.split(',') : [];
const serializedParams = this.serializeParams(params);
this.listeners.push(onLoadingStatusChange);
// Note: if `google.maps.importLibrary` has been defined externally, we
// assume that loading is complete and successful.
// If it was defined by a previous call to this method, a warning
// message is logged if there are differences in api-parameters used
// for both calls.
if (window.google?.maps?.importLibrary as unknown) {
// no serialized parameters means it was loaded externally
if (!this.serializedApiParams) {
this.loadingStatus = APILoadingStatus.LOADED;
}
this.notifyLoadingStatusListeners();
} else {
this.serializedApiParams = serializedParams;
this.initImportLibrary(params);
}
if (
this.serializedApiParams &&
this.serializedApiParams !== serializedParams
) {
console.warn(
`[google-maps-api-loader] The maps API has already been loaded ` +
`with different parameters and will not be loaded again. Refresh the ` +
`page for new values to have effect.`
);
}
const librariesToLoad = ['maps', ...libraries];
await Promise.all(
librariesToLoad.map(name => google.maps.importLibrary(name))
);
}
/**
* Serialize the parameters used to load the library for easier comparison.
*/
private static serializeParams(params: ApiParams): string {
return [
params.v,
params.key,
params.language,
params.region,
params.authReferrerPolicy,
params.solutionChannel
].join('/');
}
/**
* Creates the global `google.maps.importLibrary` function for bootstrapping.
* This is essentially a formatted version of the dynamic loading script
* from the official documentation with some minor adjustments.
*
* The created importLibrary function will load the Google Maps JavaScript API,
* which will then replace the `google.maps.importLibrary` function with the full
* implementation.
*
* @see https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import
*/
private static initImportLibrary(params: ApiParams) {
if (!window.google) window.google = {} as never;
if (!window.google.maps) window.google.maps = {} as never;
if (window.google.maps['importLibrary']) {
console.error(
'[google-maps-api-loader-internal]: initImportLibrary must only be called once'
);
return;
}
let apiPromise: Promise<void> | null = null;
const loadApi = () => {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
const scriptElement = document.createElement('script');
const urlParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const urlParamName = key.replace(
/[A-Z]/g,
t => '_' + t[0].toLowerCase()
);
urlParams.set(urlParamName, String(value));
}
urlParams.set('loading', 'async');
urlParams.set('callback', '__googleMapsCallback__');
scriptElement.async = true;
scriptElement.src = MAPS_API_BASE_URL + `?` + urlParams.toString();
scriptElement.nonce =
(document.querySelector('script[nonce]') as HTMLScriptElement)
?.nonce || '';
scriptElement.onerror = () => {
this.loadingStatus = APILoadingStatus.FAILED;
this.notifyLoadingStatusListeners();
reject(new Error('The Google Maps JavaScript API could not load.'));
};
window.__googleMapsCallback__ = () => {
this.loadingStatus = APILoadingStatus.LOADED;
this.notifyLoadingStatusListeners();
resolve();
};
window.gm_authFailure = () => {
this.loadingStatus = APILoadingStatus.AUTH_FAILURE;
this.notifyLoadingStatusListeners();
};
this.loadingStatus = APILoadingStatus.LOADING;
this.notifyLoadingStatusListeners();
document.head.append(scriptElement);
});
return apiPromise;
};
// for the first load, we declare an importLibrary function that will
// be overwritten once the api is loaded.
google.maps.importLibrary = libraryName =>
loadApi().then(() => google.maps.importLibrary(libraryName));
}
/**
* Calls all registered loadingStatusListeners after a status update.
*/
private static notifyLoadingStatusListeners() {
for (const fn of this.listeners) {
fn(this.loadingStatus);
}
}
}
// Declare global maps callback functions
declare global {
interface Window {
__googleMapsCallback__?: () => void;
gm_authFailure?: () => void;
}
}