-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathgoogle-maps-api-loader.ts
213 lines (183 loc) · 6.61 KB
/
google-maps-api-loader.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js';
export type ApiParams = {
key: string;
v?: string;
language?: string;
region?: string;
libraries?: string;
authReferrerPolicy?: 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 to adopt it into a different document.
*/
const tmpDoc = new DOMParser().parseFromString('<html></html>', '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<void> | 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<void> {
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;
}
// loading has already started, but the parameters didn't change
if (
this.loadingState >= LoadingState.LOADING &&
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) {
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'
);
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);
});
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<void> {
// 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)) {
if (value === undefined) {
continue;
}
url.searchParams.set(
// camelCase to snake_case conversion
key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`),
value
);
}
return url;
}
private static serializeParams(params: ApiParams): string {
return [
params.v,
params.key,
params.language,
params.region,
params.libraries,
params.authReferrerPolicy
].join('/');
}
}
declare global {
interface Window {
__gmcb__: () => void;
}
}