Skip to content

Commit 4c5eac0

Browse files
authored
feat: add retries for script.onerror with default of 5 retry attempts (#99)
1 parent 9468912 commit 4c5eac0

File tree

2 files changed

+86
-10
lines changed

2 files changed

+86
-10
lines changed

src/index.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Loader, LoaderOptions } from ".";
17+
import { DEFAULT_ID, Loader, LoaderOptions } from ".";
18+
19+
jest.useFakeTimers();
1820

1921
afterEach(() => {
2022
document.getElementsByTagName("html")[0].innerHTML = "";
@@ -54,6 +56,10 @@ test.each([
5456
expect(loader.createUrl()).toEqual(expected);
5557
});
5658

59+
test("uses default id if empty string", () => {
60+
expect(new Loader({ apiKey: "foo", id: "" }).id).toBe(DEFAULT_ID);
61+
});
62+
5763
test("setScript adds a script to head with correct attributes", () => {
5864
const loader = new Loader({ apiKey: "foo" });
5965

@@ -110,7 +116,7 @@ test("loadCallback callback should fire", () => {
110116
});
111117

112118
test("script onerror should reject promise", async () => {
113-
const loader = new Loader({ apiKey: "foo" });
119+
const loader = new Loader({ apiKey: "foo", retries: 0 });
114120

115121
const rejection = expect(loader.load()).rejects.toBeInstanceOf(ErrorEvent);
116122

@@ -119,11 +125,12 @@ test("script onerror should reject promise", async () => {
119125
await rejection;
120126
expect(loader["done"]).toBeTruthy();
121127
expect(loader["loading"]).toBeFalsy();
128+
expect(loader["errors"].length).toBe(1);
122129
});
123130

124131
test("script onerror should reject promise with multiple loaders", async () => {
125-
const loader = new Loader({ apiKey: "foo" });
126-
const extraLoader = new Loader({ apiKey: "foo" });
132+
const loader = new Loader({ apiKey: "foo", retries: 0 });
133+
const extraLoader = new Loader({ apiKey: "foo", retries: 0 });
127134

128135
let rejection = expect(loader.load()).rejects.toBeInstanceOf(ErrorEvent);
129136
loader["loadErrorCallback"](document.createEvent("ErrorEvent"));
@@ -139,6 +146,25 @@ test("script onerror should reject promise with multiple loaders", async () => {
139146
expect(extraLoader["loading"]).toBeFalsy();
140147
});
141148

149+
test("script onerror should retry", async () => {
150+
const loader = new Loader({ apiKey: "foo", retries: 1 });
151+
const deleteScript = jest.spyOn(loader, "deleteScript");
152+
const rejection = expect(loader.load()).rejects.toBeInstanceOf(ErrorEvent);
153+
// eslint-disable-next-line @typescript-eslint/no-empty-function
154+
console.log = jest.fn();
155+
156+
loader["loadErrorCallback"](document.createEvent("ErrorEvent"));
157+
loader["loadErrorCallback"](document.createEvent("ErrorEvent"));
158+
jest.runAllTimers();
159+
160+
await rejection;
161+
expect(loader["done"]).toBeTruthy();
162+
expect(loader["loading"]).toBeFalsy();
163+
expect(loader["errors"].length).toBe(2);
164+
expect(deleteScript).toHaveBeenCalledTimes(1);
165+
expect(console.log).toHaveBeenCalledTimes(loader.retries);
166+
});
167+
142168
test("singleton should be used", () => {
143169
const loader = new Loader({ apiKey: "foo" });
144170
const extraLoader = new Loader({ apiKey: "foo" });
@@ -195,3 +221,14 @@ test("loader should resolve immediately when google.maps defined", async () => {
195221
delete window.google;
196222
expect(console.warn).toHaveBeenCalledTimes(1);
197223
});
224+
225+
test("deleteScript removes script tag from head", () => {
226+
const loader = new Loader({ apiKey: "foo" });
227+
loader["setScript"]();
228+
expect(document.head.childNodes.length).toBe(1);
229+
loader.deleteScript();
230+
expect(document.head.childNodes.length).toBe(0);
231+
// should work without script existing
232+
loader.deleteScript();
233+
expect(document.head.childNodes.length).toBe(0);
234+
});

src/index.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ declare global {
2525
}
2626
}
2727

28+
export const DEFAULT_ID = "__googleMapsScriptId";
29+
2830
type Libraries = (
2931
| "drawing"
3032
| "geometry"
@@ -37,6 +39,8 @@ type Libraries = (
3739
* The Google Maps JavaScript API
3840
* [documentation](https://developers.google.com/maps/documentation/javascript/tutorial)
3941
* is the authoritative source for [[LoaderOptions]].
42+
/**
43+
* Loader options
4044
*/
4145
export interface LoaderOptions {
4246
/**
@@ -157,6 +161,10 @@ export interface LoaderOptions {
157161
* Use a cryptographic nonce attribute.
158162
*/
159163
nonce?: string;
164+
/**
165+
* The number of script load retries.
166+
*/
167+
retries?: number;
160168
}
161169

162170
/**
@@ -223,6 +231,11 @@ export class Loader {
223231
*/
224232
nonce: string | null;
225233

234+
/**
235+
* See [[LoaderOptions.retries]]
236+
*/
237+
retries: number;
238+
226239
/**
227240
* See [[LoaderOptions.url]]
228241
*/
@@ -234,6 +247,7 @@ export class Loader {
234247
private loading = false;
235248
private onerrorEvent: Event;
236249
private static instance: Loader;
250+
private errors: ErrorEvent[] = [];
237251

238252
/**
239253
* Creates an instance of Loader using [[LoaderOptions]]. No defaults are set
@@ -248,25 +262,27 @@ export class Loader {
248262
apiKey,
249263
channel,
250264
client,
251-
id = "__googleMapsScriptId",
265+
id = DEFAULT_ID,
252266
libraries = [],
253267
language,
254268
region,
255269
version,
256270
mapIds,
257271
nonce,
272+
retries = 3,
258273
url = "https://maps.googleapis.com/maps/api/js",
259274
}: LoaderOptions) {
260275
this.version = version;
261276
this.apiKey = apiKey;
262277
this.channel = channel;
263278
this.client = client;
264-
this.id = id;
279+
this.id = id || DEFAULT_ID; // Do not allow empty string
265280
this.libraries = libraries;
266281
this.language = language;
267282
this.region = region;
268283
this.mapIds = mapIds;
269284
this.nonce = nonce;
285+
this.retries = retries;
270286
this.url = url;
271287

272288
if (Loader.instance) {
@@ -299,6 +315,7 @@ export class Loader {
299315
url: this.url,
300316
};
301317
}
318+
302319
/**
303320
* CreateUrl returns the Google Maps JavaScript API script url given the [[LoaderOptions]].
304321
*
@@ -380,7 +397,7 @@ export class Loader {
380397
* Set the script on document.
381398
*/
382399
private setScript(): void {
383-
if (this.id && document.getElementById(this.id)) {
400+
if (document.getElementById(this.id)) {
384401
// TODO wrap onerror callback for cases where the script was loaded elsewhere
385402
this.callback();
386403
return;
@@ -402,9 +419,31 @@ export class Loader {
402419
document.head.appendChild(script);
403420
}
404421

405-
private loadErrorCallback(e: Event): void {
406-
this.onerrorEvent = e;
407-
this.callback();
422+
deleteScript(): void {
423+
const script = document.getElementById(this.id);
424+
if (script) {
425+
script.remove();
426+
}
427+
}
428+
429+
private loadErrorCallback(e: ErrorEvent): void {
430+
this.errors.push(e);
431+
432+
if (this.errors.length <= this.retries) {
433+
const delay = this.errors.length * 2 ** this.errors.length;
434+
435+
console.log(
436+
`Failed to load Google Maps script, retrying in ${delay} ms.`
437+
);
438+
439+
setTimeout(() => {
440+
this.deleteScript();
441+
this.setScript();
442+
}, delay);
443+
} else {
444+
this.onerrorEvent = e;
445+
this.callback();
446+
}
408447
}
409448

410449
private setCallback(): void {

0 commit comments

Comments
 (0)