Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new API loader implementation #137

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@
{"allowShortCircuit": true, "allowTernary": true}
],
"no-useless-call": 2,
"no-void": 2,
"no-warning-comments": 1,
"no-with": 2,
"radix": 2,
Expand All @@ -160,8 +159,6 @@
"no-catch-shadow": 2,
"no-delete-var": 2,
"no-label-var": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-undefined": 0,
Expand Down Expand Up @@ -336,6 +333,7 @@
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/no-use-before-define": ["error"],
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-shadow": 2,
"codegen/codegen": "error"
}
}
215 changes: 215 additions & 0 deletions library/src/google-maps-api-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js';

export type ApiParams = {
key: string;
v?: string;
language?: string;
region?: string;
libraries?: string;
authReferrerPolicy?: string;
};

const enum LoadingState {
UNLOADED,
UNLOADING,
QUEUED,
LOADING,
LOADED
}

/**
* Temporary document used to abort in-flight scripts.
* The only way we found so far to stop a script that is already in
* preparation from executing is to adopt it into a different document.
*/
const tmpDoc = new DOMParser().parseFromString('<html></html>', 'text/html');

/**
* API loader to reliably load and unload the Google Maps API.
* The actual loading and unloading is delayed into the microtask queue, to
* allow for using this in a useEffect hook without having to worry about
* starting to load the API multiple times.
*/
export class GoogleMapsApiLoader {
private static params: ApiParams | null = null;
private static serializedParams: string | null = null;
private static loadPromise: Promise<void> | null = null;
private static loadingState = LoadingState.UNLOADED;

/**
* Loads the Google Maps API with the specified parameters, reloading
* it if neccessary. The returned promise resolves when loading completes
* and rejects in case of an error or when the loading was aborted.
* @param params
*/
static async load(params: ApiParams): Promise<void> {
const serializedParams = this.serializeParams(params);
console.debug('api-loader: load', params);
this.params = params;

// loading hasn't yet started, so the promise can be reused (loading will
// use parameters from the last call before loading starts)
if (this.loadingState <= LoadingState.QUEUED && this.loadPromise) {
console.debug('api-loader: loading is already queued');
return this.loadPromise;
}

// loading has already started, but the parameters didn't change
if (
this.loadingState >= LoadingState.LOADING &&
serializedParams === this.serializedParams &&
this.loadPromise
) {
console.debug('api-loader: loading already started');
return this.loadPromise;
}

// if parameters did change, and we already loaded the API, we need
// to unload it first.
if (this.loadPromise) {
// FIXME: in this case, we might want to report an error if we're not
// already unloading, since that would only be the case if the loader
// is taked with loading multiple instances of the API with different
// parameters.

// if (this.loadingState >= LoadingState.LOADING) {
// console.error(
// 'The Google Maps API Parameters passed to the `GoogleMapsProvider` ' +
// 'components do not match. The Google Maps API can only be loaded ' +
// 'once. Please make sure to pass the same API parameters to all ' +
// 'of your `GoogleMapsProvider` components.'
// );
// }
console.debug(
'api-loader: was already loaded with other params, unload first'
);
await this.unloadAsync();
}

console.debug('api-loader: queue request');

this.loadingState = LoadingState.QUEUED;
this.loadPromise = new Promise(async (resolve, reject) => {
console.debug('api-loader: defer to microtasks');
// this causes the rest of the function to be pushed back into the
// microtask-queue, allowing multiple synchronous calls before actually
// loading anything.
await Promise.resolve();
console.debug('api-loader: continue');

// if the load request was canceled in the meantime, we stop here and
// reject the promise. This is typically the case in react dev mode when
// load/unload are called from a hook.
if (this.loadingState !== LoadingState.QUEUED) {
console.debug('api-loader: no longer queued');
reject(new Error('map loading canceled'));
return;
}

console.debug('api-loader: start actually loading');
this.loadingState = LoadingState.LOADING;
const url = this.getApiUrl(this.params);

// setup the callback
window.__gmcb__ = () => {
console.debug(`api-loader: callback called, loading complete`);
this.loadingState = LoadingState.LOADED;
resolve();
};
url.searchParams.set('callback', '__gmcb__');
console.debug(`api-loader: URL: ${url}`);

// create the script
const script = document.createElement('script');
script.src = url.toString();
script.onerror = err => reject(err);

document.head.appendChild(script);
});

return this.loadPromise;
}

/**
* Unloads the Google Maps API by canceling any pending script downloads
* and removing the global `google.maps` object.
*/
static unload(): void {
void this.unloadAsync();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this is why I had to disable the no-void eslint rule. Using void here makes it very explicit that it wasn't just accidentally forgotten to return the promise.

}

private static async unloadAsync(): Promise<void> {
// if loading hasn't started, reset the loadingState.
// this will cause the loading to not start
if (this.loadingState <= LoadingState.QUEUED) {
this.loadingState = LoadingState.UNLOADED;
return;
}

const gmScriptTags = Array.from(
document.querySelectorAll(`script[src^="${MAPS_API_BASE_URL}"]`)
);
this.loadingState = LoadingState.UNLOADING;

// defer to the microtask-queue and check if the loading-state was
// changed in the meantime. If that is the case, unload was likely called
// in error (or by the React dev-mode calling the effect cleanup function
// just for fun). In this case, it is just ignored.
await Promise.resolve();

if (this.loadingState !== LoadingState.UNLOADING) {
return;
}

// The elements are removed from the document and adopted into a different
// one. This prevents the script from executing once it's loaded if it hasn't
// already.
for (const el of gmScriptTags) {
el.remove();
tmpDoc.adoptNode(el);
}

if (window.google && window.google.maps) {
// @ts-ignore
delete window.google.maps;
}
}

private static getApiUrl(params: ApiParams | null): URL {
if (params === null) {
throw new Error("api-params can't be null");
}

const url = new URL(MAPS_API_BASE_URL);
for (const [key, value] of Object.entries(params)) {
if (value === undefined) {
continue;
}

url.searchParams.set(
// camelCase to snake_case conversion
key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`),
BiniCodes marked this conversation as resolved.
Show resolved Hide resolved
value
);
}

return url;
}

private static serializeParams(params: ApiParams): string {
return [
params.v,
params.key,
params.language,
params.region,
params.libraries,
params.authReferrerPolicy
].join('/');
}
}

declare global {
interface Window {
__gmcb__: () => void;
}
}
108 changes: 27 additions & 81 deletions library/src/google-maps-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, {useState, useEffect, PropsWithChildren} from 'react';

const GOOGLE_MAPS_API_URL = 'https://maps.googleapis.com/maps/api/js';
import {GoogleMapsApiLoader} from './google-maps-api-loader';

// https://developers.google.com/maps/documentation/javascript/url-params
export interface GoogleMapsAPIUrlParameters {
Expand Down Expand Up @@ -43,6 +42,9 @@ export const GoogleMapsContext = React.createContext<GoogleMapsContextType>({
googleMapsAPIIsLoaded: false
});

const DEFAULT_LANGUAGE = navigator.language.slice(0, 2);
const DEFAULT_REGION = navigator.language.slice(3, 5);

/**
* The global Google Maps provider
*/
Expand All @@ -66,92 +68,36 @@ export const GoogleMapsProvider: React.FunctionComponent<
const [isLoadingAPI, setIsLoadingAPI] = useState<boolean>(true);
const [map, setMap] = useState<google.maps.Map>();

// Handle Google Maps API loading
// eslint-disable-next-line complexity
useEffect(() => {
const apiLoadingFinished = () => {
setIsLoadingAPI(false);
onLoadScript && onLoadScript();
};

const defaultLanguage = navigator.language.slice(0, 2);
const defaultRegion = navigator.language.slice(3, 5);
console.log('effect: start loading');

/* eslint-disable camelcase */
const params = new URLSearchParams({
const apiParams = {
key: googleMapsAPIKey,
language: language || defaultLanguage,
region: region || defaultRegion,
...(libraries?.length && {libraries: libraries.join(',')}),
...(version && {v: version}),
...(authReferrerPolicy && {auth_referrer_policy: authReferrerPolicy})
});
/* eslint-enable camelcase */

const existingScriptTag: HTMLScriptElement | null = document.querySelector(
`script[src^="${GOOGLE_MAPS_API_URL}"]`
);
language: language || DEFAULT_LANGUAGE,
region: region || DEFAULT_REGION,
libraries: libraries ? libraries.join(',') : undefined,
v: version,
authReferrerPolicy
};

// Check if Google Maps API was loaded with the passed parameters
if (existingScriptTag) {
const loadedURL = new URL(existingScriptTag.src);
const loadedParams = loadedURL.searchParams.toString();
const passedParams = params.toString();

if (loadedParams !== passedParams) {
console.error(
'The Google Maps API Parameters passed to the `GoogleMapsProvider` components do not match. The Google Maps API can only be loaded once. Please make sure to pass the same API parameters to all of your `GoogleMapsProvider` components.',
'\n\nExpected parameters:',
Object.fromEntries(loadedURL.searchParams),
'\n\nReceived parameters:',
Object.fromEntries(params)
);
setIsLoadingAPI(true);
GoogleMapsApiLoader.load(apiParams).then(
() => {
console.log('effect: loading done');
setIsLoadingAPI(false);
if (onLoadScript) {
onLoadScript();
}
},
err => {
console.error('effect: loading failed: ', err);
setIsLoadingAPI(false);
}
}

if (typeof google === 'object' && typeof google.maps === 'object') {
// Google Maps API is already loaded
apiLoadingFinished();
} else if (existingScriptTag) {
// Google Maps API is already loading
setIsLoadingAPI(true);

const onload = existingScriptTag.onload;
existingScriptTag.onload = event => {
onload?.call(existingScriptTag, event);
apiLoadingFinished();
};
} else {
// Load Google Maps API
setIsLoadingAPI(true);

// Add google maps callback
window.mapsCallback = () => {
apiLoadingFinished();
};

params.set('callback', 'mapsCallback');

const scriptTag = document.createElement('script');
scriptTag.type = 'text/javascript';
scriptTag.src = `${GOOGLE_MAPS_API_URL}?${params.toString()}`;
document.getElementsByTagName('head')[0].appendChild(scriptTag);
}
);

// Clean up Google Maps API
return () => {
// Remove all loaded Google Maps API scripts
document
.querySelectorAll('script[src^="https://maps.googleapis.com"]')
.forEach(script => {
script.remove();
});

// Remove google.maps global
if (typeof google === 'object' && typeof google.maps === 'object') {
// @ts-ignore: The operand of a 'delete' operator must be optional.
delete google.maps;
}
console.log('effect/cleanup: unload API');
GoogleMapsApiLoader.unload();
};
}, [
googleMapsAPIKey,
Expand Down
1 change: 1 addition & 0 deletions library/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// codegen:start {preset: barrel, include: ./**/*, exclude: [./index.ts, ./types/*]}
export * from './google-maps-api-loader';
export * from './google-maps-provider';
export * from './hooks/autocomplete-service';
export * from './hooks/autocomplete';
Expand Down
2 changes: 1 addition & 1 deletion library/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"typeRoots": ["./src/types"]
"typeRoots": ["./src/types", "../node_modules/@types"]
},
"exclude": ["dist"]
}