From cc43efa2deeb1fb7312d98f7fe352f232d873904 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Tue, 31 Jan 2023 09:08:31 +0100 Subject: [PATCH 1/4] feat(core): add new API-loader implementation --- .eslintrc | 4 +- library/src/google-maps-api-loader.ts | 180 ++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 library/src/google-maps-api-loader.ts diff --git a/.eslintrc b/.eslintrc index 0043770..013b2a7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -148,7 +148,6 @@ {"allowShortCircuit": true, "allowTernary": true} ], "no-useless-call": 2, - "no-void": 2, "no-warning-comments": 1, "no-with": 2, "radix": 2, @@ -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, @@ -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" } } diff --git a/library/src/google-maps-api-loader.ts b/library/src/google-maps-api-loader.ts new file mode 100644 index 0000000..65e6eba --- /dev/null +++ b/library/src/google-maps-api-loader.ts @@ -0,0 +1,180 @@ +const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js'; + +export type ApiParams = { + v: string; + key: string; + language: string; + region: string; + libraries: string; + auth_referrer_policy: 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 + */ +const tmpDoc = new DOMParser().parseFromString('', '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 | 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 { + const serializedParams = this.serializeParams(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) { + return this.loadPromise; + } + + // loading has already started, but the parameters didn't change + if ( + this.loadingState >= LoadingState.LOADING && + serializedParams === this.serializedParams && + this.loadPromise + ) { + return this.loadPromise; + } + + // if parameters did change, and we already loaded the API, we need + // to unload it first. + if (this.loadPromise) { + await this.unloadAsync(); + } + + this.loadingState = LoadingState.QUEUED; + this.loadPromise = new Promise(async (resolve, reject) => { + // 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(); + + // 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) { + reject(new Error('map loading canceled')); + return; + } + + this.loadingState = LoadingState.LOADING; + const url = this.getApiUrl(this.params); + + // setup the callback + window.__gmcb__ = () => { + this.loadingState = LoadingState.LOADED; + resolve(); + }; + url.searchParams.set('callback', '__gmcb__'); + + // 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(); + } + + private static async unloadAsync(): Promise { + // 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)) { + url.searchParams.set(key, value); + } + + return url; + } + + private static serializeParams(params: ApiParams): string { + return [ + params.v, + params.key, + params.language, + params.region, + params.libraries, + params.auth_referrer_policy + ].join('/'); + } +} + +declare global { + interface Window { + __gmcb__: () => void; + } +} From 9f9634b6a9cac3f63e7bcc3e150654af8d3758a9 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Tue, 31 Jan 2023 12:30:34 +0100 Subject: [PATCH 2/4] feat(core): refactor provider implementation to use new loader (squash later) --- library/src/google-maps-api-loader.ts | 51 ++++++++++-- library/src/google-maps-provider.tsx | 108 +++++++------------------- library/src/index.ts | 1 + library/tsconfig.json | 2 +- 4 files changed, 72 insertions(+), 90 deletions(-) diff --git a/library/src/google-maps-api-loader.ts b/library/src/google-maps-api-loader.ts index 65e6eba..de4fdd6 100644 --- a/library/src/google-maps-api-loader.ts +++ b/library/src/google-maps-api-loader.ts @@ -1,12 +1,12 @@ const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js'; export type ApiParams = { - v: string; key: string; - language: string; - region: string; - libraries: string; - auth_referrer_policy: string; + v?: string; + language?: string; + region?: string; + libraries?: string; + authReferrerPolicy?: string; }; const enum LoadingState { @@ -44,12 +44,13 @@ export class GoogleMapsApiLoader { */ static async load(params: ApiParams): Promise { 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; } @@ -59,44 +60,70 @@ export class GoogleMapsApiLoader { 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); }); @@ -155,7 +182,15 @@ export class GoogleMapsApiLoader { const url = new URL(MAPS_API_BASE_URL); for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, value); + if (value === undefined) { + continue; + } + + url.searchParams.set( + // camelCase to snake_case conversion + key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), + value + ); } return url; @@ -168,7 +203,7 @@ export class GoogleMapsApiLoader { params.language, params.region, params.libraries, - params.auth_referrer_policy + params.authReferrerPolicy ].join('/'); } } diff --git a/library/src/google-maps-provider.tsx b/library/src/google-maps-provider.tsx index 0f498eb..19832f6 100644 --- a/library/src/google-maps-provider.tsx +++ b/library/src/google-maps-provider.tsx @@ -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 { @@ -43,6 +42,9 @@ export const GoogleMapsContext = React.createContext({ googleMapsAPIIsLoaded: false }); +const DEFAULT_LANGUAGE = navigator.language.slice(0, 2); +const DEFAULT_REGION = navigator.language.slice(3, 5); + /** * The global Google Maps provider */ @@ -66,92 +68,36 @@ export const GoogleMapsProvider: React.FunctionComponent< const [isLoadingAPI, setIsLoadingAPI] = useState(true); const [map, setMap] = useState(); - // 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, diff --git a/library/src/index.ts b/library/src/index.ts index d8faab9..81ac457 100644 --- a/library/src/index.ts +++ b/library/src/index.ts @@ -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'; diff --git a/library/tsconfig.json b/library/tsconfig.json index 60718e1..c5ae02e 100644 --- a/library/tsconfig.json +++ b/library/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "typeRoots": ["./src/types"] + "typeRoots": ["./src/types", "../node_modules/@types"] }, "exclude": ["dist"] } From 8cf5241904e1d192e5df4623119f89c00d890475 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Tue, 31 Jan 2023 12:33:58 +0100 Subject: [PATCH 3/4] fix: comment --- library/src/google-maps-api-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/google-maps-api-loader.ts b/library/src/google-maps-api-loader.ts index de4fdd6..61b0046 100644 --- a/library/src/google-maps-api-loader.ts +++ b/library/src/google-maps-api-loader.ts @@ -20,7 +20,7 @@ const enum LoadingState { /** * 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 + * preparation from executing is to adopt it into a different document. */ const tmpDoc = new DOMParser().parseFromString('', 'text/html'); From f8419dd664830987a6f13c14547ce046901ca295 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Tue, 31 Jan 2023 19:54:56 +0100 Subject: [PATCH 4/4] fix: add error-message for multiple loads with different parameters --- library/src/google-maps-api-loader.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/library/src/google-maps-api-loader.ts b/library/src/google-maps-api-loader.ts index 61b0046..c63e4e5 100644 --- a/library/src/google-maps-api-loader.ts +++ b/library/src/google-maps-api-loader.ts @@ -67,19 +67,17 @@ export class GoogleMapsApiLoader { // 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.' - // ); - // } + if (this.loadingState >= LoadingState.LOADING) { + // unloading hasn't started yet; this can only be the case if the loader + // was called with different parameters for multiple provider instances + 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' );