Skip to content

Commit a5e15af

Browse files
bloveclaude
andcommitted
fix(ag-ui): gate <google-map> on Maps API load (blank-page-on-reload crash)
Reloading the demo with App mode persisted on rendered a blank page (only the toolbar). Root cause (confirmed against @angular/google-maps google-maps.mjs:136): the GoogleMap constructor throws "Namespace google not found" when window.google is absent — and the Maps script loads async, so on a fresh load it loses the race, the map throws during the shell's initial render, and the whole shell render aborts (dev-mode only, ngDevMode-gated, which is why #732 CI/smoke missed it; the P2 smoke only toggled App mode at runtime, never reloaded with it persisted on). Fix: a GoogleMapsLoader service exposing a `loaded` signal (owns the script injection, flips on script.onload); gate <google-map> with @if (loader.loaded()) so it never constructs before the API is present. app.config loads eagerly through the same service so the GeocodingService keeps working. markerOptions uses the numeric SymbolPath literal (0) as defense-in-depth against eager google.* reads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01ed35e commit a5e15af

3 files changed

Lines changed: 108 additions & 45 deletions

File tree

examples/ag-ui/angular/src/app/app.config.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// SPDX-License-Identifier: MIT
22
import {
33
ApplicationConfig,
4+
inject,
45
provideBrowserGlobalErrorListeners,
56
provideEnvironmentInitializer,
67
provideZonelessChangeDetection,
@@ -13,6 +14,7 @@ import { environment } from '../environments/environment';
1314
import { routes } from './app.routes';
1415
import { ItineraryStore } from './itinerary-store';
1516
import { ITINERARY_AGENT } from './client-tools';
17+
import { GoogleMapsLoader } from './google-maps-loader';
1618

1719
export const appConfig: ApplicationConfig = {
1820
providers: [
@@ -35,21 +37,12 @@ export const appConfig: ApplicationConfig = {
3537
// Typed agent provider: flows ItineraryState through DI so every
3638
// injectAgent(ITINERARY_AGENT) call returns AgUiAgent<ItineraryState>.
3739
provideAgent(ITINERARY_AGENT, { url: environment.agentUrl }),
38-
// Load the Google Maps JS API once at bootstrap so the map canvas and the
39-
// GeocodingService both run against the same loaded script. Skips cleanly
40-
// when no key is configured (the googleMapsApiKey is '' in that case).
41-
provideEnvironmentInitializer(() => {
42-
const key = (environment.googleMapsApiKey as string) ?? '';
43-
if (!key) return;
44-
const g = globalThis as { google?: unknown };
45-
if (g.google) return;
46-
if (document.querySelector('script[data-google-maps]')) return;
47-
const script = document.createElement('script');
48-
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding`;
49-
script.async = true;
50-
script.setAttribute('data-google-maps', '');
51-
document.head.appendChild(script);
52-
}),
40+
// Eagerly load the Google Maps JS API at bootstrap via the loader service,
41+
// which owns the single `loaded` signal the map canvas gates its
42+
// <google-map> on. Loading early lets the GeocodingService work before App
43+
// mode is opened; the map still waits for `loaded()` so its component never
44+
// constructs before the API is present. Skips cleanly with no key.
45+
provideEnvironmentInitializer(() => inject(GoogleMapsLoader).ensureLoaded()),
5346
provideChat({ license: environment.license }),
5447
// The frontend-owned itinerary is a single shared instance: the panel,
5548
// the App component, and the client-tool ask component all inject it, so
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: MIT
2+
import { Injectable, signal } from '@angular/core';
3+
import { environment } from '../environments/environment';
4+
5+
/**
6+
* Loads the Google Maps JS API on demand and exposes a `loaded` signal.
7+
*
8+
* Why this exists: `<google-map>` (from `@angular/google-maps`) THROWS in its
9+
* constructor when `window.google` is absent ("Namespace google not found…",
10+
* dev-mode only). The Maps script loads asynchronously, so rendering the map
11+
* before it resolves aborts the host component's change-detection pass — which
12+
* (on a fresh load with App mode persisted on) blanks the whole shell. The fix
13+
* is the documented `@angular/google-maps` contract: only render `<google-map>`
14+
* once the API is present. Consumers gate their template on `loaded()` and call
15+
* `ensureLoaded()` once.
16+
*/
17+
@Injectable({ providedIn: 'root' })
18+
export class GoogleMapsLoader {
19+
/** Becomes true once `window.google.maps` is available. */
20+
readonly loaded = signal(false);
21+
private started = false;
22+
23+
/** Idempotent: injects the Maps script once (if a key is present) and flips
24+
* `loaded` when ready. Safe to call from multiple components. */
25+
ensureLoaded(): void {
26+
if (this.loaded() || this.started) return;
27+
const w = globalThis as { google?: { maps?: unknown }; document?: Document };
28+
if (w.google?.maps) {
29+
this.loaded.set(true);
30+
return;
31+
}
32+
this.started = true;
33+
34+
const key = (environment.googleMapsApiKey as string) ?? '';
35+
if (!key) return; // No key → map stays gated off (the toolbar toggle is also disabled).
36+
37+
const doc = w.document;
38+
if (!doc) return;
39+
40+
const existing = doc.querySelector('script[data-google-maps]');
41+
if (existing) {
42+
// A load is already in flight (e.g. a prior instance). Poll for readiness.
43+
const poll = setInterval(() => {
44+
if ((globalThis as { google?: { maps?: unknown } }).google?.maps) {
45+
clearInterval(poll);
46+
this.loaded.set(true);
47+
}
48+
}, 100);
49+
return;
50+
}
51+
52+
const script = doc.createElement('script');
53+
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding`;
54+
script.async = true;
55+
script.setAttribute('data-google-maps', '');
56+
script.addEventListener('load', () => this.loaded.set(true));
57+
doc.head.appendChild(script);
58+
}
59+
}

examples/ag-ui/angular/src/app/map-canvas.component.ts

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@angular/core';
1313
import { GoogleMap, MapInfoWindow, MapMarker, MapPolyline } from '@angular/google-maps';
1414
import { ItineraryStop, ItineraryStore } from './itinerary-store';
15+
import { GoogleMapsLoader } from './google-maps-loader';
1516

1617
const DARK_STYLE: google.maps.MapTypeStyle[] = [
1718
{ elementType: 'geometry', stylers: [{ color: '#1d2c4d' }] },
@@ -37,36 +38,41 @@ const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 };
3738
imports: [GoogleMap, MapInfoWindow, MapMarker, MapPolyline],
3839
changeDetection: ChangeDetectionStrategy.OnPush,
3940
template: `
40-
<google-map
41-
width="100%"
42-
height="100%"
43-
[center]="center()"
44-
[zoom]="zoom()"
45-
[options]="mapOptions"
46-
>
47-
@for (s of stopsWithCoords(); track s.id) {
48-
<map-marker
49-
#marker
50-
[position]="{ lat: s.lat!, lng: s.lng! }"
51-
[options]="markerOptions(s)"
52-
(mapClick)="onMarkerClick(s)"
53-
/>
54-
}
55-
@for (line of polylines(); track line.day) {
56-
<map-polyline [path]="line.path" [options]="polylineOptions(line.day)" />
57-
}
58-
<map-info-window>
59-
@if (focused(); as f) {
60-
<div class="info">
61-
<strong>{{ f.place }}</strong>
62-
@if (f.note) {
63-
<div class="info__note">{{ f.note }}</div>
64-
}
65-
<button type="button" class="info__remove" (click)="removeFocused()">Remove</button>
66-
</div>
41+
<!-- Render <google-map> ONLY after the Maps API has loaded. Its constructor
42+
throws "Namespace google not found" when window.google is absent, which
43+
would abort the host shell's render on a fresh load with App mode on. -->
44+
@if (loader.loaded()) {
45+
<google-map
46+
width="100%"
47+
height="100%"
48+
[center]="center()"
49+
[zoom]="zoom()"
50+
[options]="mapOptions"
51+
>
52+
@for (s of stopsWithCoords(); track s.id) {
53+
<map-marker
54+
#marker
55+
[position]="{ lat: s.lat!, lng: s.lng! }"
56+
[options]="markerOptions(s)"
57+
(mapClick)="onMarkerClick(s)"
58+
/>
6759
}
68-
</map-info-window>
69-
</google-map>
60+
@for (line of polylines(); track line.day) {
61+
<map-polyline [path]="line.path" [options]="polylineOptions(line.day)" />
62+
}
63+
<map-info-window>
64+
@if (focused(); as f) {
65+
<div class="info">
66+
<strong>{{ f.place }}</strong>
67+
@if (f.note) {
68+
<div class="info__note">{{ f.note }}</div>
69+
}
70+
<button type="button" class="info__remove" (click)="removeFocused()">Remove</button>
71+
</div>
72+
}
73+
</map-info-window>
74+
</google-map>
75+
}
7076
`,
7177
styles: [
7278
`
@@ -83,6 +89,7 @@ const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 };
8389
})
8490
export class MapCanvasComponent {
8591
protected readonly store = inject(ItineraryStore);
92+
protected readonly loader = inject(GoogleMapsLoader);
8693
protected readonly center = signal<google.maps.LatLngLiteral>(PARIS_CENTER);
8794
protected readonly zoom = signal<number>(12);
8895
protected readonly mapOptions: google.maps.MapOptions = {
@@ -104,6 +111,8 @@ export class MapCanvasComponent {
104111
);
105112

106113
constructor() {
114+
this.loader.ensureLoaded();
115+
107116
effect(() => {
108117
const f = this.focused();
109118
if (!f || f.lat == null || f.lng == null) return;
@@ -140,7 +149,9 @@ export class MapCanvasComponent {
140149
protected markerOptions(s: ItineraryStop): google.maps.MarkerOptions {
141150
return {
142151
icon: {
143-
path: google.maps.SymbolPath.CIRCLE,
152+
// Numeric literal for google.maps.SymbolPath.CIRCLE (=0) — avoids an
153+
// eager google.* value read (defense-in-depth alongside the loader gate).
154+
path: 0,
144155
fillColor: DAY_COLORS[(s.day - 1) % DAY_COLORS.length],
145156
fillOpacity: 1,
146157
strokeColor: '#fff',

0 commit comments

Comments
 (0)