diff --git a/src/index.test.ts b/src/index.test.ts index e69e7164..75f03a49 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -21,6 +21,7 @@ jest.useFakeTimers(); afterEach(() => { document.getElementsByTagName("html")[0].innerHTML = ""; delete Loader["instance"]; + if (window.google) delete window.google; }); test.each([ @@ -65,59 +66,59 @@ 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 +164,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 +235,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 +285,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 +341,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 +369,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 +380,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 c7b21601..42fe9093 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,25 +16,24 @@ import isEqual from "fast-deep-equal"; -/** - * @ignore - */ -declare global { - interface Window { - __googleMapsCallback: (e: Event) => void; - } -} - export const DEFAULT_ID = "__googleMapsScriptId"; -export type Libraries = ( - | "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[]; /** * The Google Maps JavaScript API @@ -268,7 +267,6 @@ export class Loader { */ public readonly authReferrerPolicy: "origin"; - private CALLBACK = "__googleMapsCallback"; private callbacks: ((e: ErrorEvent) => void)[] = []; private done = false; private loading = false; @@ -366,11 +364,12 @@ export class Loader { * CreateUrl returns the Google Maps JavaScript API script url given the [[LoaderOptions]]. * * @ignore + * @deprecated */ public createUrl(): string { let url = this.url; - url += `?callback=${this.CALLBACK}`; + url += `?callback=__googleMapsCallback`; if (this.apiKey) { url += `&key=${this.apiKey}`; @@ -420,6 +419,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(); @@ -429,6 +429,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) => { @@ -442,8 +443,39 @@ export class Loader { }); } + /** + * 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); @@ -460,20 +492,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); + } + ); } /** @@ -499,7 +577,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.` ); @@ -513,10 +591,6 @@ export class Loader { } } - private setCallback(): void { - window.__googleMapsCallback = this.callback.bind(this); - } - private callback(): void { this.done = true; this.loading = false; @@ -548,7 +622,7 @@ export class Loader { // do nothing but wait } else { this.loading = true; - this.setCallback(); + this.setScript(); } }