From 8e678a48006190a46ed14687c87d9feba90eafe2 Mon Sep 17 00:00:00 2001 From: "Chris J. Shull" Date: Tue, 24 Jan 2023 17:47:07 -0800 Subject: [PATCH 1/4] WIP --- src/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index c7b21601..ae7982bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,14 +27,16 @@ declare global { export const DEFAULT_ID = "__googleMapsScriptId"; -export type Libraries = ( +export type Library = ( | "drawing" | "geometry" | "localContext" | "marker" | "places" | "visualization" -)[]; +); + +export type Libraries = Library[]; /** * The Google Maps JavaScript API @@ -441,6 +443,15 @@ export class Loader { }); }); } + + /** + * + */ + public importLibrary(name: Library): Promise { + return load().then((google) => { + return google.maps.importLibrary(name); + }); + } /** * Load the Google Maps JavaScript API script with a callback. From 159cb56eeb527acc4ff2fd33c4ec9c21e3ee6d6f Mon Sep 17 00:00:00 2001 From: "Chris J. Shull" Date: Tue, 30 May 2023 23:58:58 -0700 Subject: [PATCH 2/4] Adopt the inline bootstrap loader: https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import and provide an importLibrary() alias --- src/index.test.ts | 158 ++++++++++++++++--------------------- src/index.ts | 195 ++++++++++++++++++++++++---------------------- 2 files changed, 169 insertions(+), 184 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index e69e7164..65a66ca2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -21,103 +21,66 @@ jest.useFakeTimers(); afterEach(() => { document.getElementsByTagName("html")[0].innerHTML = ""; delete Loader["instance"]; -}); - -test.each([ - [{}, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback"], - [ - { apiKey: "foo" }, - "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&key=foo", - ], - [ - { - apiKey: "foo", - version: "weekly", - libraries: ["marker", "places"], - language: "language", - region: "region", - }, - "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&key=foo&libraries=marker,places&language=language®ion=region&v=weekly", - ], - [ - { mapIds: ["foo", "bar"] }, - "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&map_ids=foo,bar", - ], - [ - { url: "https://example.com/js" }, - "https://example.com/js?callback=__googleMapsCallback", - ], - [ - { client: "bar", channel: "foo" }, - "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&channel=foo&client=bar", - ], - [ - { authReferrerPolicy: "origin" }, - "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&auth_referrer_policy=origin", - ], -])("createUrl is correct", (options: LoaderOptions, expected: string) => { - const loader = new Loader(options); - expect(loader.createUrl()).toEqual(expected); - expect(loader.status).toBe(LoaderStatus.INITIALIZED); + if (window.google) delete window.google; }); test("uses default id if empty string", () => { expect(new Loader({ apiKey: "foo", id: "" }).id).toBe(DEFAULT_ID); }); -test("setScript adds a script to head with correct attributes", () => { +test("setScript adds a script to head with correct attributes", async () => { const loader = new Loader({ apiKey: "foo" }); loader["setScript"](); + await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.id).toEqual(loader.id); - expect(script.src).toEqual(loader.createUrl()); - expect(script.defer).toBeTruthy(); - expect(script.async).toBeTruthy(); - expect(script.onerror).toBeTruthy(); - expect(script.type).toEqual("text/javascript"); -}); - -test("setScript does not add second script with same id", () => { - new Loader({ apiKey: "foo", id: "bar" })["setScript"](); - new Loader({ apiKey: "foo", id: "bar" })["setScript"](); - - expect(document.head.childNodes.length).toBe(1); }); -test("setScript adds a script with id", () => { +test("setScript adds a script with id", async () => { const loader = new Loader({ apiKey: "foo", id: "bar" }); loader["setScript"](); + await 0; const script = document.head.childNodes[0] as HTMLScriptElement; - expect(script.id).toEqual(loader.id); + expect(script.localName).toEqual("script"); + expect(loader.id).toEqual("bar"); + expect(script.id).toEqual("bar"); +}); + +test("setScript does not add second script with same id", async () => { + new Loader({ apiKey: "foo", id: "bar" })["setScript"](); + new Loader({ apiKey: "foo", id: "bar" })["setScript"](); + await 0; + new Loader({ apiKey: "foo", id: "bar" })["setScript"](); + await 0; + + expect(document.head.childNodes.length).toBe(1); }); test("load should return a promise that resolves even if called twice", () => { const loader = new Loader({ apiKey: "foo" }); + loader.importLibrary = (() => Promise.resolve()) as any; expect.assertions(1); const promise = Promise.all([loader.load(), loader.load()]).then(() => { expect(loader["done"]).toBeTruthy(); }); - window.__googleMapsCallback(null); - return promise; }); test("loadCallback callback should fire", () => { const loader = new Loader({ apiKey: "foo" }); + loader.importLibrary = (() => Promise.resolve()) as any; expect.assertions(2); loader.loadCallback((e: Event) => { expect(loader["done"]).toBeTruthy(); expect(e).toBeUndefined(); }); - - window.__googleMapsCallback(null); }); test("script onerror should reject promise", async () => { @@ -163,74 +126,70 @@ test("script onerror should reject promise with multiple loaders", async () => { test("script onerror should retry", async () => { const loader = new Loader({ apiKey: "foo", retries: 1 }); const deleteScript = jest.spyOn(loader, "deleteScript"); + loader.importLibrary = (() => Promise.reject(new Error("fake error"))) as any; const rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); // eslint-disable-next-line @typescript-eslint/no-empty-function - console.log = jest.fn(); + console.error = jest.fn(); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); + // wait for the first failure + await 0; + expect(loader["errors"].length).toBe(1); + // trigger the retry delay: jest.runAllTimers(); await rejection; + expect(loader["errors"].length).toBe(2); expect(loader["done"]).toBeTruthy(); + expect(loader["failed"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); - expect(loader["errors"].length).toBe(2); expect(deleteScript).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledTimes(loader.retries); + expect(console.error).toHaveBeenCalledTimes(loader.retries); }); test("script onerror should reset retry mechanism with next loader", async () => { const loader = new Loader({ apiKey: "foo", retries: 1 }); const deleteScript = jest.spyOn(loader, "deleteScript"); + loader.importLibrary = (() => Promise.reject(new Error("fake error"))) as any; // eslint-disable-next-line @typescript-eslint/no-empty-function - console.log = jest.fn(); + console.error = jest.fn(); let rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); + // wait for the first first failure + await 0; + expect(loader["errors"].length).toBe(1); + // trigger the retry delay: jest.runAllTimers(); await rejection; + // try again... rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); expect(loader["done"]).toBeFalsy(); + expect(loader["failed"]).toBeFalsy(); expect(loader["loading"]).toBeTruthy(); expect(loader["errors"].length).toBe(0); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); + // wait for the second first failure + await 0; + expect(loader["errors"].length).toBe(1); + // trigger the retry delay: jest.runAllTimers(); await rejection; expect(deleteScript).toHaveBeenCalledTimes(3); - expect(console.log).toHaveBeenCalledTimes(loader.retries * 2); + expect(console.error).toHaveBeenCalledTimes(loader.retries * 2); }); test("script onerror should not reset retry mechanism with parallel loaders", async () => { const loader = new Loader({ apiKey: "foo", retries: 1 }); const deleteScript = jest.spyOn(loader, "deleteScript"); + loader.importLibrary = (() => Promise.reject(new Error("fake error"))) as any; // eslint-disable-next-line @typescript-eslint/no-empty-function - console.log = jest.fn(); + console.error = jest.fn(); const rejection1 = expect(loader.load()).rejects.toBeInstanceOf(Error); const rejection2 = expect(loader.load()).rejects.toBeInstanceOf(Error); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); - loader["loadErrorCallback"]( - new ErrorEvent("ErrorEvent(", { error: new Error("") }) - ); + // wait for the first first failure + await 0; jest.runAllTimers(); await Promise.all([rejection1, rejection2]); @@ -238,7 +197,7 @@ test("script onerror should not reset retry mechanism with parallel loaders", as expect(loader["loading"]).toBeFalsy(); expect(loader["errors"].length).toBe(2); expect(deleteScript).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledTimes(loader.retries); + expect(console.error).toHaveBeenCalledTimes(loader.retries); }); test("reset should clear state", () => { @@ -288,12 +247,12 @@ test("failed getter should be correct", () => { expect(loader["failed"]).toBeTruthy(); }); -test("loader should not reset retry mechanism if successfully loaded", () => { +test("loader should not reset retry mechanism if successfully loaded", async () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); const deleteScript = jest.spyOn(loader, "deleteScript"); + loader.importLibrary = (() => Promise.resolve()) as any; - loader["done"] = true; - expect(loader.load()).resolves.toBeUndefined(); + await expect(loader.load()).resolves.not.toBeUndefined(); expect(loader["done"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); @@ -344,10 +303,11 @@ test("loader should wait if already loading", () => { loader.load(); }); -test("setScript adds a nonce", () => { +test("setScript adds a nonce", async () => { const nonce = "bar"; const loader = new Loader({ apiKey: "foo", nonce }); loader["setScript"](); + await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.nonce).toBe(nonce); }); @@ -371,9 +331,10 @@ test("loader should not warn if done and google.maps is defined", async () => { expect(console.warn).toHaveBeenCalledTimes(0); }); -test("deleteScript removes script tag from head", () => { +test("deleteScript removes script tag from head", async () => { const loader = new Loader({ apiKey: "foo" }); loader["setScript"](); + await 0; expect(document.head.childNodes.length).toBe(1); loader.deleteScript(); expect(document.head.childNodes.length).toBe(0); @@ -381,3 +342,14 @@ test("deleteScript removes script tag from head", () => { loader.deleteScript(); expect(document.head.childNodes.length).toBe(0); }); + +test("importLibrary resolves correctly", async () => { + window.google = {maps: {}} as any; + google.maps.importLibrary = async (name) => ({ [name]: "fake" }) as any; + + const loader = new Loader({ apiKey: "foo" }); + const corePromise = loader.importLibrary("core"); + + const core = await corePromise; + expect(core).toEqual({ core: "fake" }); +}); diff --git a/src/index.ts b/src/index.ts index ae7982bf..f52a7756 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,25 +16,22 @@ import isEqual from "fast-deep-equal"; -/** - * @ignore - */ -declare global { - interface Window { - __googleMapsCallback: (e: Event) => void; - } -} - export const DEFAULT_ID = "__googleMapsScriptId"; -export type Library = ( - | "drawing" - | "geometry" - | "localContext" - | "marker" +// https://developers.google.com/maps/documentation/javascript/libraries#libraries-for-dynamic-library-import +export type Library = + | "core" + | "maps" | "places" - | "visualization" -); + | "geocoding" + | "routes" + | "marker" + | "geometry" + | "elevation" + | "streetView" + | "journeySharing" + | "drawing" + | "visualization"; export type Libraries = Library[]; @@ -270,7 +267,6 @@ export class Loader { */ public readonly authReferrerPolicy: "origin"; - private CALLBACK = "__googleMapsCallback"; private callbacks: ((e: ErrorEvent) => void)[] = []; private done = false; private loading = false; @@ -364,55 +360,6 @@ export class Loader { return this.done && !this.loading && this.errors.length >= this.retries + 1; } - /** - * CreateUrl returns the Google Maps JavaScript API script url given the [[LoaderOptions]]. - * - * @ignore - */ - public createUrl(): string { - let url = this.url; - - url += `?callback=${this.CALLBACK}`; - - if (this.apiKey) { - url += `&key=${this.apiKey}`; - } - - if (this.channel) { - url += `&channel=${this.channel}`; - } - - if (this.client) { - url += `&client=${this.client}`; - } - - if (this.libraries.length > 0) { - url += `&libraries=${this.libraries.join(",")}`; - } - - if (this.language) { - url += `&language=${this.language}`; - } - - if (this.region) { - url += `®ion=${this.region}`; - } - - if (this.version) { - url += `&v=${this.version}`; - } - - if (this.mapIds) { - url += `&map_ids=${this.mapIds.join(",")}`; - } - - if (this.authReferrerPolicy) { - url += `&auth_referrer_policy=${this.authReferrerPolicy}`; - } - - return url; - } - public deleteScript(): void { const script = document.getElementById(this.id); if (script) { @@ -422,6 +369,7 @@ export class Loader { /** * Load the Google Maps JavaScript API script and return a Promise. + * @deprecated, use importLibrary() instead. */ public load(): Promise { return this.loadPromise(); @@ -431,6 +379,7 @@ export class Loader { * Load the Google Maps JavaScript API script and return a Promise. * * @ignore + * @deprecated, use importLibrary() instead. */ public loadPromise(): Promise { return new Promise((resolve, reject) => { @@ -443,18 +392,40 @@ export class Loader { }); }); } - + /** - * - */ - public importLibrary(name: Library): Promise { - return load().then((google) => { - return google.maps.importLibrary(name); - }); + * See https://developers.google.com/maps/documentation/javascript/reference/top-level#google.maps.importLibrary + */ + public importLibrary(name: "core"): Promise; + public importLibrary(name: "maps"): Promise; + public importLibrary(name: "places"): Promise; + public importLibrary( + name: "geocoding" + ): Promise; + public importLibrary(name: "routes"): Promise; + public importLibrary(name: "marker"): Promise; + public importLibrary(name: "geometry"): Promise; + public importLibrary( + name: "elevation" + ): Promise; + public importLibrary( + name: "streetView" + ): Promise; + public importLibrary( + name: "journeySharing" + ): Promise; + public importLibrary(name: "drawing"): Promise; + public importLibrary( + name: "visualization" + ): Promise; + public importLibrary(name: Library): Promise { + this.execute(); + return google.maps.importLibrary(name); } /** * Load the Google Maps JavaScript API script with a callback. + * @deprecated, use importLibrary() instead. */ public loadCallback(fn: (e: ErrorEvent) => void): void { this.callbacks.push(fn); @@ -471,20 +442,66 @@ export class Loader { return; } - const url = this.createUrl(); - const script = document.createElement("script"); - script.id = this.id; - script.type = "text/javascript"; - script.src = url; - script.onerror = this.loadErrorCallback.bind(this); - script.defer = true; - script.async = true; - - if (this.nonce) { - script.nonce = this.nonce; + if (!window?.google?.maps?.importLibrary) { + // tweaked copy of https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import + // which also sets the url, the id, and the nonce + /* eslint-disable */ + ((g) => { + // @ts-ignore + let h, + a, + k, + p = "The Google Maps JavaScript API", + c = "google", + l = "importLibrary", + q = "__ib__", + m = document, + b = window; + // @ts-ignore + b = b[c] || (b[c] = {}); + // @ts-ignore + const d = b.maps || (b.maps = {}), + r = new Set(), + e = new URLSearchParams(), + u = () => + // @ts-ignore + h || (h = new Promise(async (f, n) => { + await (a = m.createElement("script")); + a.id = this.id; + e.set("libraries", [...r] + ""); + // @ts-ignore + for (k in g) e.set(k.replace(/[A-Z]/g, (t) => "_" + t[0].toLowerCase()), g[k]); + e.set("callback", c + ".maps." + q); + a.src = this.url + `?` + e; + d[q] = f; + a.onerror = () => (h = n(Error(p + " could not load."))); + // @ts-ignore + a.nonce = this.nonce || m.querySelector("script[nonce]")?.nonce || ""; + m.head.append(a); + })); + // @ts-ignore + d[l] ? console.warn(p + " only loads once. Ignoring:", g) : (d[l] = (f, ...n) => r.add(f) && u().then(() => d[l](f, ...n))); + })({ + key: this.apiKey, + channel: this.channel, + client: this.client, + libraries: this.libraries, + v: this.version, + mapIds: this.mapIds, + language: this.language, + region: this.region, + authReferrerPolicy: this.authReferrerPolicy, + }); + /* eslint-enable */ } - document.head.appendChild(script); + this.importLibrary("core").then( + () => this.callback(), + (error) => { + const event = new ErrorEvent("error", { error }); // for backwards compat + this.loadErrorCallback(event); + } + ); } /** @@ -510,7 +527,7 @@ export class Loader { if (this.errors.length <= this.retries) { const delay = this.errors.length * 2 ** this.errors.length; - console.log( + console.error( `Failed to load Google Maps script, retrying in ${delay} ms.` ); @@ -524,10 +541,6 @@ export class Loader { } } - private setCallback(): void { - window.__googleMapsCallback = this.callback.bind(this); - } - private callback(): void { this.done = true; this.loading = false; @@ -559,7 +572,7 @@ export class Loader { // do nothing but wait } else { this.loading = true; - this.setCallback(); + this.setScript(); } } From 1451ccd3e3f511900271c6e920fdca1a2dcc652c Mon Sep 17 00:00:00 2001 From: "Chris J. Shull" Date: Wed, 31 May 2023 11:47:01 -0700 Subject: [PATCH 3/4] linter fix --- src/index.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 65a66ca2..7e71f38e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ /* eslint @typescript-eslint/no-explicit-any: 0 */ -import { DEFAULT_ID, Loader, LoaderOptions, LoaderStatus } from "."; +import { DEFAULT_ID, Loader, LoaderStatus } from "."; jest.useFakeTimers(); @@ -344,8 +344,8 @@ test("deleteScript removes script tag from head", async () => { }); test("importLibrary resolves correctly", async () => { - window.google = {maps: {}} as any; - google.maps.importLibrary = async (name) => ({ [name]: "fake" }) as any; + window.google = { maps: {} } as any; + google.maps.importLibrary = async (name) => ({ [name]: "fake" } as any); const loader = new Loader({ apiKey: "foo" }); const corePromise = loader.importLibrary("core"); From 938c4d5e926cdb86f10173ef84ddc84266426b62 Mon Sep 17 00:00:00 2001 From: "Chris J. Shull" Date: Wed, 31 May 2023 13:25:27 -0700 Subject: [PATCH 4/4] put back createUrl, but deprecated --- src/index.test.ts | 40 ++++++++++++++++++++++++++++++++++++- src/index.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 7e71f38e..75f03a49 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ /* eslint @typescript-eslint/no-explicit-any: 0 */ -import { DEFAULT_ID, Loader, LoaderStatus } from "."; +import { DEFAULT_ID, Loader, LoaderOptions, LoaderStatus } from "."; jest.useFakeTimers(); @@ -24,6 +24,44 @@ afterEach(() => { if (window.google) delete window.google; }); +test.each([ + [{}, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback"], + [ + { apiKey: "foo" }, + "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&key=foo", + ], + [ + { + apiKey: "foo", + version: "weekly", + libraries: ["marker", "places"], + language: "language", + region: "region", + }, + "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&key=foo&libraries=marker,places&language=language®ion=region&v=weekly", + ], + [ + { mapIds: ["foo", "bar"] }, + "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&map_ids=foo,bar", + ], + [ + { url: "https://example.com/js" }, + "https://example.com/js?callback=__googleMapsCallback", + ], + [ + { client: "bar", channel: "foo" }, + "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&channel=foo&client=bar", + ], + [ + { authReferrerPolicy: "origin" }, + "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&auth_referrer_policy=origin", + ], +])("createUrl is correct", (options: LoaderOptions, expected: string) => { + const loader = new Loader(options); + expect(loader.createUrl()).toEqual(expected); + expect(loader.status).toBe(LoaderStatus.INITIALIZED); +}); + test("uses default id if empty string", () => { expect(new Loader({ apiKey: "foo", id: "" }).id).toBe(DEFAULT_ID); }); diff --git a/src/index.ts b/src/index.ts index f52a7756..42fe9093 100644 --- a/src/index.ts +++ b/src/index.ts @@ -360,6 +360,56 @@ export class Loader { return this.done && !this.loading && this.errors.length >= this.retries + 1; } + /** + * CreateUrl returns the Google Maps JavaScript API script url given the [[LoaderOptions]]. + * + * @ignore + * @deprecated + */ + public createUrl(): string { + let url = this.url; + + url += `?callback=__googleMapsCallback`; + + if (this.apiKey) { + url += `&key=${this.apiKey}`; + } + + if (this.channel) { + url += `&channel=${this.channel}`; + } + + if (this.client) { + url += `&client=${this.client}`; + } + + if (this.libraries.length > 0) { + url += `&libraries=${this.libraries.join(",")}`; + } + + if (this.language) { + url += `&language=${this.language}`; + } + + if (this.region) { + url += `®ion=${this.region}`; + } + + if (this.version) { + url += `&v=${this.version}`; + } + + if (this.mapIds) { + url += `&map_ids=${this.mapIds.join(",")}`; + } + + if (this.authReferrerPolicy) { + url += `&auth_referrer_policy=${this.authReferrerPolicy}`; + } + + return url; + } + public deleteScript(): void { const script = document.getElementById(this.id); if (script) {