Skip to content

Commit 9f19892

Browse files
committed
feat(google-maps): add support for dynamic library loading API
Currently the `@angular/google-maps` module is implemented on top of the legacy `<script>` API which is problematic, because: 1. It loads all of the JavaScript up-front, even if there are no maps on the page. 2. It requires the user to ensure that the API is fully loaded before the `<google-map>` component starts rendering. Lazy-loading in this scenario is error-prone and loading the API up-front leads to a lot of unused JavaScript being loaded. These changes add support for the [Dynamic Library Import API](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import) which requires a tiny script to be added up-front which then allows the `<google-map>` to load the chunks of the API it needs on-demand. The legacy API is still supported, but is no longer recommended. All the components also now have `initialized` outputs to make it easier to know when the underlying Maps classes have been created and can be interacted with.
1 parent 230bdb5 commit 9f19892

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+968
-783
lines changed

src/dev-app/google-map/google-map-demo.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class GoogleMapDemo {
123123
};
124124

125125
isGroundOverlayDisplayed = false;
126-
hasLoaded: boolean;
126+
hasLoaded = false;
127127
groundOverlayImages = [
128128
{
129129
title: 'Red logo',
@@ -284,24 +284,14 @@ export class GoogleMapDemo {
284284
}
285285

286286
private _loadApi() {
287-
this.hasLoaded = !!window.google?.maps;
288-
289287
if (this.hasLoaded) {
290288
return;
291289
}
292290

293291
if (!apiLoadingPromise) {
294-
// Key can be set through the `GOOGLE_MAPS_KEY` environment variable.
295-
const apiKey: string | undefined = (window as any).GOOGLE_MAPS_KEY;
296-
297-
apiLoadingPromise = Promise.all([
298-
this._loadScript(
299-
`https://maps.googleapis.com/maps/api/js?libraries=visualization${
300-
apiKey ? `&key=${apiKey}` : ''
301-
}`,
302-
),
303-
this._loadScript('https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js'),
304-
]);
292+
apiLoadingPromise = this._loadScript(
293+
'https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js',
294+
);
305295
}
306296

307297
apiLoadingPromise.then(

src/dev-app/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,11 @@
1818
<dev-app>Loading...</dev-app>
1919
<script src="zone.js/bundles/zone.umd.js"></script>
2020
<script src="bundles/dev-app/main.js" type="module"></script>
21+
<script>
22+
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");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=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({
23+
v: "weekly",
24+
key: window.GOOGLE_MAPS_KEY || 'invalid'
25+
});
26+
</script>
2127
</body>
2228
</html>

src/google-maps/README.md

Lines changed: 15 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,63 +14,25 @@ Follow [these steps](https://developers.google.com/maps/gmp-get-started) to get
1414

1515
## Loading the API
1616

17-
The API can be loaded when the component is actually used by using the Angular HttpClient jsonp
18-
method to make sure that the component doesn't load until after the API has loaded.
19-
20-
```typescript
21-
// google-maps-demo.module.ts
22-
23-
import { NgModule } from '@angular/core';
24-
import { provideHttpClient, withJsonpSupport } from '@angular/common/http';
25-
26-
import { GoogleMapsDemoComponent } from './google-maps-demo.component';
27-
28-
@NgModule({
29-
imports: [GoogleMapsDemoComponent],
30-
providers: [provideHttpClient(withJsonpSupport())]
31-
})
32-
export class GoogleMapsDemoModule {}
33-
34-
35-
// google-maps-demo.component.ts
36-
37-
import { Component } from '@angular/core';
38-
import { HttpClient } from '@angular/common/http';
39-
import { GoogleMap } from '@angular/google-maps';
40-
import { Observable, of } from 'rxjs';
41-
import { catchError, map } from 'rxjs/operators';
42-
43-
@Component({
44-
selector: 'google-maps-demo',
45-
templateUrl: './google-maps-demo.component.html',
46-
standalone: true,
47-
imports: [GoogleMap]
48-
})
49-
export class GoogleMapsDemoComponent {
50-
apiLoaded: Observable<boolean>;
51-
52-
constructor(httpClient: HttpClient) {
53-
// If you're using the `<map-heatmap-layer>` directive, you also have to include the `visualization` library
54-
// when loading the Google Maps API. To do so, you can add `&libraries=visualization` to the script URL:
55-
// https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=visualization
56-
57-
this.apiLoaded = httpClient.jsonp('https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE', 'callback')
58-
.pipe(
59-
map(() => true),
60-
catchError(() => of(false)),
61-
);
62-
}
63-
}
64-
```
17+
Include the [Dynamic Library Import script](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import) in the `index.html` of your app. When a Google Map is being rendered, it'll use the Dynamic Import API to load the necessary JavaScript automatically.
6518

6619
```html
67-
<!-- google-maps-demo.component.html -->
68-
69-
@if (apiLoaded | async) {
70-
<google-map />
71-
}
20+
<!-- index.html -->
21+
<!DOCTYPE html>
22+
<body>
23+
...
24+
<script>
25+
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");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=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({
26+
v: "weekly",
27+
key: YOUR_API_KEY_GOES_HERE
28+
});
29+
</script>
30+
</body>
31+
</html>
7232
```
7333
34+
**Note:** the component also supports loading the API using the [legacy script tag](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#use-legacy-tag), however it isn't recommended because it requires all of the Google Maps JavaScript to be loaded up-front, even if it isn't used.
35+
7436
## Components
7537
7638
- [`GoogleMap`](./google-map/README.md)

src/google-maps/google-map/google-map.spec.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('GoogleMap', () => {
116116
it('sets center and zoom of the map', () => {
117117
const options = {center: {lat: 3, lng: 5}, zoom: 7, mapTypeId: DEFAULT_OPTIONS.mapTypeId};
118118
mapSpy = createMapSpy(options);
119-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
119+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
120120

121121
const fixture = TestBed.createComponent(TestApp);
122122
fixture.componentInstance.center = options.center;
@@ -142,7 +142,7 @@ describe('GoogleMap', () => {
142142
mapTypeId: DEFAULT_OPTIONS.mapTypeId,
143143
};
144144
mapSpy = createMapSpy(options);
145-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
145+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
146146

147147
const fixture = TestBed.createComponent(TestApp);
148148
fixture.componentInstance.options = options;
@@ -160,7 +160,7 @@ describe('GoogleMap', () => {
160160
it('should set a default center if the custom options do not provide one', () => {
161161
const options = {zoom: 7};
162162
mapSpy = createMapSpy(options);
163-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
163+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
164164

165165
const fixture = TestBed.createComponent(TestApp);
166166
fixture.componentInstance.options = options;
@@ -172,7 +172,7 @@ describe('GoogleMap', () => {
172172
it('should set a default zoom level if the custom options do not provide one', () => {
173173
const options = {};
174174
mapSpy = createMapSpy(options);
175-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
175+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
176176

177177
const fixture = TestBed.createComponent(TestApp);
178178
fixture.componentInstance.options = options;
@@ -184,7 +184,7 @@ describe('GoogleMap', () => {
184184
it('should not set a default zoom level if the custom options provide "zoom: 0"', () => {
185185
const options = {zoom: 0};
186186
mapSpy = createMapSpy(options);
187-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
187+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
188188

189189
const fixture = TestBed.createComponent(TestApp);
190190
fixture.componentInstance.options = options;
@@ -216,7 +216,7 @@ describe('GoogleMap', () => {
216216

217217
it('exposes methods that change the configuration of the Google Map', () => {
218218
mapSpy = createMapSpy(DEFAULT_OPTIONS);
219-
createMapConstructorSpy(mapSpy).and.callThrough();
219+
createMapConstructorSpy(mapSpy);
220220

221221
const fixture = TestBed.createComponent(TestApp);
222222
fixture.detectChanges();
@@ -238,7 +238,7 @@ describe('GoogleMap', () => {
238238

239239
it('exposes methods that get information about the Google Map', () => {
240240
mapSpy = createMapSpy(DEFAULT_OPTIONS);
241-
createMapConstructorSpy(mapSpy).and.callThrough();
241+
createMapConstructorSpy(mapSpy);
242242

243243
const fixture = TestBed.createComponent(TestApp);
244244
fixture.detectChanges();
@@ -275,7 +275,7 @@ describe('GoogleMap', () => {
275275

276276
it('initializes event handlers that are set on the map', () => {
277277
mapSpy = createMapSpy(DEFAULT_OPTIONS);
278-
createMapConstructorSpy(mapSpy).and.callThrough();
278+
createMapConstructorSpy(mapSpy);
279279

280280
const addSpy = mapSpy.addListener;
281281
const fixture = TestBed.createComponent(TestApp);
@@ -303,7 +303,7 @@ describe('GoogleMap', () => {
303303

304304
it('should be able to add an event listener after init', () => {
305305
mapSpy = createMapSpy(DEFAULT_OPTIONS);
306-
createMapConstructorSpy(mapSpy).and.callThrough();
306+
createMapConstructorSpy(mapSpy);
307307

308308
const addSpy = mapSpy.addListener;
309309
const fixture = TestBed.createComponent(TestApp);
@@ -321,7 +321,7 @@ describe('GoogleMap', () => {
321321

322322
it('should set the map type', () => {
323323
mapSpy = createMapSpy(DEFAULT_OPTIONS);
324-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
324+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
325325

326326
const fixture = TestBed.createComponent(TestApp);
327327
fixture.componentInstance.mapTypeId = 'terrain' as unknown as google.maps.MapTypeId;
@@ -341,7 +341,7 @@ describe('GoogleMap', () => {
341341
it('sets mapTypeId through the options', () => {
342342
const options = {mapTypeId: 'satellite'};
343343
mapSpy = createMapSpy(options);
344-
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
344+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
345345
const fixture = TestBed.createComponent(TestApp);
346346
fixture.componentInstance.options = options;
347347
fixture.detectChanges();

src/google-maps/google-map/google-map.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import {isPlatformBrowser} from '@angular/common';
3030
import {Observable} from 'rxjs';
3131
import {MapEventManager} from '../map-event-manager';
32+
import {take} from 'rxjs/operators';
3233

3334
interface GoogleMapsWindow extends Window {
3435
gm_authFailure?: () => void;
@@ -307,15 +308,19 @@ export class GoogleMap implements OnChanges, OnInit, OnDestroy {
307308
// Create the object outside the zone so its events don't trigger change detection.
308309
// We'll bring it back in inside the `MapEventManager` only for the events that the
309310
// user has subscribed to.
310-
this._ngZone.runOutsideAngular(() => {
311-
this.googleMap = new google.maps.Map(this._mapEl, this._combineOptions());
311+
this._ngZone.runOutsideAngular(async () => {
312+
const mapConstructor =
313+
google.maps.Map ||
314+
((await google.maps.importLibrary('maps')) as google.maps.MapsLibrary).Map;
315+
this.googleMap = new mapConstructor(this._mapEl, this._combineOptions());
316+
this._eventManager.setTarget(this.googleMap);
317+
this.mapInitialized.emit(this.googleMap);
312318
});
313-
this._eventManager.setTarget(this.googleMap);
314-
this.mapInitialized.emit(this.googleMap);
315319
}
316320
}
317321

318322
ngOnDestroy() {
323+
this.mapInitialized.complete();
319324
this._eventManager.destroy();
320325

321326
if (this._isBrowser) {
@@ -483,6 +488,11 @@ export class GoogleMap implements OnChanges, OnInit, OnDestroy {
483488
return this.googleMap.overlayMapTypes;
484489
}
485490

491+
/** Returns a promise that resolves when the map has been initialized. */
492+
async _resolveMap(): Promise<google.maps.Map> {
493+
return this.googleMap || this.mapInitialized.pipe(take(1)).toPromise();
494+
}
495+
486496
private _setSize() {
487497
if (this._mapEl) {
488498
const styles = this._mapEl.style;

src/google-maps/map-base-layer.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,19 @@ export class MapBaseLayer implements OnInit, OnDestroy {
2626

2727
ngOnInit() {
2828
if (this._map._isBrowser) {
29-
this._ngZone.runOutsideAngular(() => {
30-
this._initializeObject();
29+
this._ngZone.runOutsideAngular(async () => {
30+
const map = await this._map._resolveMap();
31+
await this._initializeObject();
32+
this._setMap(map);
3133
});
32-
this._assertInitialized();
33-
this._setMap();
3434
}
3535
}
3636

3737
ngOnDestroy() {
3838
this._unsetMap();
3939
}
4040

41-
private _assertInitialized() {
42-
if (!this._map.googleMap) {
43-
throw Error(
44-
'Cannot access Google Map information before the API has been initialized. ' +
45-
'Please wait for the API to load before trying to interact with it.',
46-
);
47-
}
48-
}
49-
50-
protected _initializeObject() {}
51-
protected _setMap() {}
41+
protected async _initializeObject() {}
42+
protected _setMap(_map: google.maps.Map) {}
5243
protected _unsetMap() {}
5344
}

src/google-maps/map-bicycling-layer/map-bicycling-layer.spec.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Component} from '@angular/core';
2-
import {TestBed} from '@angular/core/testing';
2+
import {TestBed, fakeAsync, flush} from '@angular/core/testing';
33

44
import {DEFAULT_OPTIONS, GoogleMap} from '../google-map/google-map';
55
import {
@@ -16,24 +16,23 @@ describe('MapBicyclingLayer', () => {
1616

1717
beforeEach(() => {
1818
mapSpy = createMapSpy(DEFAULT_OPTIONS);
19-
createMapConstructorSpy(mapSpy).and.callThrough();
19+
createMapConstructorSpy(mapSpy);
2020
});
2121

2222
afterEach(() => {
2323
(window.google as any) = undefined;
2424
});
2525

26-
it('initializes a Google Map Bicycling Layer', () => {
26+
it('initializes a Google Map Bicycling Layer', fakeAsync(() => {
2727
const bicyclingLayerSpy = createBicyclingLayerSpy();
28-
const bicyclingLayerConstructorSpy =
29-
createBicyclingLayerConstructorSpy(bicyclingLayerSpy).and.callThrough();
30-
28+
const bicyclingLayerConstructorSpy = createBicyclingLayerConstructorSpy(bicyclingLayerSpy);
3129
const fixture = TestBed.createComponent(TestApp);
3230
fixture.detectChanges();
31+
flush();
3332

3433
expect(bicyclingLayerConstructorSpy).toHaveBeenCalled();
3534
expect(bicyclingLayerSpy.setMap).toHaveBeenCalledWith(mapSpy);
36-
});
35+
}));
3736
});
3837

3938
@Component({

src/google-maps/map-bicycling-layer/map-bicycling-layer.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// Workaround for: https://github.com/bazelbuild/rules_nodejs/issues/1265
1010
/// <reference types="google.maps" />
1111

12-
import {Directive} from '@angular/core';
12+
import {Directive, EventEmitter, Output} from '@angular/core';
1313

1414
import {MapBaseLayer} from '../map-base-layer';
1515

@@ -31,19 +31,25 @@ export class MapBicyclingLayer extends MapBaseLayer {
3131
*/
3232
bicyclingLayer?: google.maps.BicyclingLayer;
3333

34-
protected override _initializeObject() {
35-
this.bicyclingLayer = new google.maps.BicyclingLayer();
34+
/** Event emitted when the bicycling layer is initialized. */
35+
@Output() readonly bicyclingLayerInitialized: EventEmitter<google.maps.BicyclingLayer> =
36+
new EventEmitter<google.maps.BicyclingLayer>();
37+
38+
protected override async _initializeObject() {
39+
const layerConstructor =
40+
google.maps.BicyclingLayer ||
41+
((await google.maps.importLibrary('maps')) as google.maps.MapsLibrary).BicyclingLayer;
42+
this.bicyclingLayer = new layerConstructor();
43+
this.bicyclingLayerInitialized.emit(this.bicyclingLayer);
3644
}
3745

38-
protected override _setMap() {
46+
protected override _setMap(map: google.maps.Map) {
3947
this._assertLayerInitialized();
40-
this.bicyclingLayer.setMap(this._map.googleMap!);
48+
this.bicyclingLayer.setMap(map);
4149
}
4250

4351
protected override _unsetMap() {
44-
if (this.bicyclingLayer) {
45-
this.bicyclingLayer.setMap(null);
46-
}
52+
this.bicyclingLayer?.setMap(null);
4753
}
4854

4955
private _assertLayerInitialized(): asserts this is {bicyclingLayer: google.maps.BicyclingLayer} {

0 commit comments

Comments
 (0)