Repository: googlemaps/js-api-loader (ts)

Issue:
Calling the loader after API has failed does nothing except throw an error. It should retry fetching the library instead
Calling the loader after API has failed does nothing except throw an error. It should retry fetching the google maps library instead.


#### Environment details

1. Specify the API at the beginning of the title (for example, "Places: ...")
2. OS type and version: Linux
3. Library version and other environment information: React

#### Steps to reproduce

  1. Block the `maps.googleapis.com` domain in the browser network tab
  2. Call the loader.
  3. Unblock the previously blocked domain/URL
  4. Call the loader again, it will fail to do anything.


This is important for recovery purposes, [google-maps](https://www.npmjs.com/package/google-maps) sends a request again if it originally failed and you called the loader again.


Pull request description:
feat: add retries
The singleton loader will retry on script failures up to `retries` attempts. A new instance will not retry again.

closes #93 

Patch:
diff --git a/src/index.ts b/src/index.ts
index 234e17f..4dd06c8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,8 @@ declare global {
   }
 }
 
+export const DEFAULT_ID = "__googleMapsScriptId";
+
 type Libraries = (
   | "drawing"
   | "geometry"
@@ -37,6 +39,8 @@ type Libraries = (
  * The Google Maps JavaScript API
  * [documentation](https://developers.google.com/maps/documentation/javascript/tutorial)
  * is the authoritative source for [[LoaderOptions]].
+/**
+ * Loader options
  */
 export interface LoaderOptions {
   /**
@@ -157,6 +161,10 @@ export interface LoaderOptions {
    * Use a cryptographic nonce attribute.
    */
   nonce?: string;
+  /**
+   * The number of script load retries.
+   */
+  retries?: number;
 }
 
 /**
@@ -223,6 +231,11 @@ export class Loader {
    */
   nonce: string | null;
 
+  /**
+   * See [[LoaderOptions.retries]]
+   */
+  retries: number;
+
   /**
    * See [[LoaderOptions.url]]
    */
@@ -234,6 +247,7 @@ export class Loader {
   private loading = false;
   private onerrorEvent: Event;
   private static instance: Loader;
+  private errors: ErrorEvent[] = [];
 
   /**
    * Creates an instance of Loader using [[LoaderOptions]]. No defaults are set
@@ -248,25 +262,27 @@ export class Loader {
     apiKey,
     channel,
     client,
-    id = "__googleMapsScriptId",
+    id = DEFAULT_ID,
     libraries = [],
     language,
     region,
     version,
     mapIds,
     nonce,
+    retries = 3,
     url = "https://maps.googleapis.com/maps/api/js",
   }: LoaderOptions) {
     this.version = version;
     this.apiKey = apiKey;
     this.channel = channel;
     this.client = client;
-    this.id = id;
+    this.id = id || DEFAULT_ID; // Do not allow empty string
     this.libraries = libraries;
     this.language = language;
     this.region = region;
     this.mapIds = mapIds;
     this.nonce = nonce;
+    this.retries = retries;
     this.url = url;
 
     if (Loader.instance) {
@@ -299,6 +315,7 @@ export class Loader {
       url: this.url,
     };
   }
+
   /**
    * CreateUrl returns the Google Maps JavaScript API script url given the [[LoaderOptions]].
    *
@@ -380,7 +397,7 @@ export class Loader {
    * Set the script on document.
    */
   private setScript(): void {
-    if (this.id && document.getElementById(this.id)) {
+    if (document.getElementById(this.id)) {
       // TODO wrap onerror callback for cases where the script was loaded elsewhere
       this.callback();
       return;
@@ -402,9 +419,31 @@ export class Loader {
     document.head.appendChild(script);
   }
 
-  private loadErrorCallback(e: Event): void {
-    this.onerrorEvent = e;
-    this.callback();
+  deleteScript(): void {
+    const script = document.getElementById(this.id);
+    if (script) {
+      script.remove();
+    }
+  }
+
+  private loadErrorCallback(e: ErrorEvent): void {
+    this.errors.push(e);
+
+    if (this.errors.length <= this.retries) {
+      const delay = this.errors.length * 2 ** this.errors.length;
+
+      console.log(
+        `Failed to load Google Maps script, retrying in ${delay} ms.`
+      );
+
+      setTimeout(() => {
+        this.deleteScript();
+        this.setScript();
+      }, delay);
+    } else {
+      this.onerrorEvent = e;
+      this.callback();
+    }
   }
 
   private setCallback(): void {


Test patch:
diff --git a/src/index.test.ts b/src/index.test.ts
index bb9c7b9..7774607 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -14,7 +14,9 @@
  * limitations under the License.
  */
 
-import { Loader, LoaderOptions } from ".";
+import { DEFAULT_ID, Loader, LoaderOptions } from ".";
+
+jest.useFakeTimers();
 
 afterEach(() => {
   document.getElementsByTagName("html")[0].innerHTML = "";
@@ -54,6 +56,10 @@ test.each([
   expect(loader.createUrl()).toEqual(expected);
 });
 
+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", () => {
   const loader = new Loader({ apiKey: "foo" });
 
@@ -110,7 +116,7 @@ test("loadCallback callback should fire", () => {
 });
 
 test("script onerror should reject promise", async () => {
-  const loader = new Loader({ apiKey: "foo" });
+  const loader = new Loader({ apiKey: "foo", retries: 0 });
 
   const rejection = expect(loader.load()).rejects.toBeInstanceOf(ErrorEvent);
 
@@ -119,11 +125,12 @@ test("script onerror should reject promise", async () => {
   await rejection;
   expect(loader["done"]).toBeTruthy();
   expect(loader["loading"]).toBeFalsy();
+  expect(loader["errors"].length).toBe(1);
 });
 
 test("script onerror should reject promise with multiple loaders", async () => {
-  const loader = new Loader({ apiKey: "foo" });
-  const extraLoader = new Loader({ apiKey: "foo" });
+  const loader = new Loader({ apiKey: "foo", retries: 0 });
+  const extraLoader = new Loader({ apiKey: "foo", retries: 0 });
 
   let rejection = expect(loader.load()).rejects.toBeInstanceOf(ErrorEvent);
   loader["loadErrorCallback"](document.createEvent("ErrorEvent"));
@@ -139,6 +146,25 @@ test("script onerror should reject promise with multiple loaders", async () => {
   expect(extraLoader["loading"]).toBeFalsy();
 });
 
+test("script onerror should retry", async () => {
+  const loader = new Loader({ apiKey: "foo", retries: 1 });
+  const deleteScript = jest.spyOn(loader, "deleteScript");
+  const rejection = expect(loader.load()).rejects.toBeInstanceOf(ErrorEvent);
+  // eslint-disable-next-line @typescript-eslint/no-empty-function
+  console.log = jest.fn();
+
+  loader["loadErrorCallback"](document.createEvent("ErrorEvent"));
+  loader["loadErrorCallback"](document.createEvent("ErrorEvent"));
+  jest.runAllTimers();
+
+  await rejection;
+  expect(loader["done"]).toBeTruthy();
+  expect(loader["loading"]).toBeFalsy();
+  expect(loader["errors"].length).toBe(2);
+  expect(deleteScript).toHaveBeenCalledTimes(1);
+  expect(console.log).toHaveBeenCalledTimes(loader.retries);
+});
+
 test("singleton should be used", () => {
   const loader = new Loader({ apiKey: "foo" });
   const extraLoader = new Loader({ apiKey: "foo" });
@@ -195,3 +221,14 @@ test("loader should resolve immediately when google.maps defined", async () => {
   delete window.google;
   expect(console.warn).toHaveBeenCalledTimes(1);
 });
+
+test("deleteScript removes script tag from head", () => {
+  const loader = new Loader({ apiKey: "foo" });
+  loader["setScript"]();
+  expect(document.head.childNodes.length).toBe(1);
+  loader.deleteScript();
+  expect(document.head.childNodes.length).toBe(0);
+  // should work without script existing
+  loader.deleteScript();
+  expect(document.head.childNodes.length).toBe(0);
+});