Skip to content

Commit

Permalink
feat: handle API-key errors in map-component (#165)
Browse files Browse the repository at this point in the history
Introduces handling for API-key errors (via the gm_authFailed callback) for the map-component.

This also shifts responsibility for tracking the loading status into the GoogleMapsApiLoader class, to avoid problems when the APIProvider itself is rendered conditionally.
  • Loading branch information
usefulthink authored Jan 18, 2024
1 parent 6ecd7a9 commit 26ccc15
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 47 deletions.
17 changes: 14 additions & 3 deletions src/components/__tests__/api-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import '@testing-library/jest-dom';
import {importLibraryMock} from '../../libraries/__mocks__/lib/import-library-mock';

import {
APILoadingStatus,
APIProvider,
APIProviderContext,
APIProviderContextValue
} from '../api-provider';
import {ApiParams} from '../../libraries/google-maps-api-loader';
import {useApiIsLoaded} from '../../hooks/use-api-is-loaded';
import {APILoadingStatus} from '../../libraries/api-loading-status';

const apiLoadSpy = jest.fn();
const apiUnloadSpy = jest.fn();
Expand All @@ -30,10 +30,21 @@ let triggerMapsApiLoaded: () => void;

jest.mock('../../libraries/google-maps-api-loader', () => {
class GoogleMapsApiLoader {
static async load(params: ApiParams): Promise<void> {
static async load(
params: ApiParams,
onLoadingStatusChange: (s: APILoadingStatus) => void
): Promise<void> {
apiLoadSpy(params);
onLoadingStatusChange(APILoadingStatus.LOADING);

google.maps.importLibrary = importLibraryMock;
return new Promise(resolve => (triggerMapsApiLoaded = resolve));
return new Promise(
resolve =>
(triggerMapsApiLoaded = () => {
resolve();
onLoadingStatusChange(APILoadingStatus.LOADED);
})
);
}
static unload() {
apiUnloadSpy();
Expand Down
7 changes: 2 additions & 5 deletions src/components/__tests__/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks';
import '@testing-library/jest-dom';

import {Map as GoogleMap} from '../map';
import {
APILoadingStatus,
APIProviderContext,
APIProviderContextValue
} from '../api-provider';
import {APIProviderContext, APIProviderContextValue} from '../api-provider';
import {APILoadingStatus} from '../../libraries/api-loading-status';

jest.mock('../../libraries/google-maps-api-loader');

Expand Down
34 changes: 13 additions & 21 deletions src/components/api-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,7 @@ import React, {
} from 'react';

import {GoogleMapsApiLoader} from '../libraries/google-maps-api-loader';

export enum APILoadingStatus {
NOT_LOADED = 'NOT_LOADED',
LOADING = 'LOADING',
LOADED = 'LOADED',
FAILED = 'FAILED'
}

const {NOT_LOADED, LOADING, LOADED, FAILED} = APILoadingStatus;
import {APILoadingStatus} from '../libraries/api-loading-status';

type ImportLibraryFunction = typeof google.maps.importLibrary;
type GoogleMapsLibrary = Awaited<ReturnType<ImportLibraryFunction>>;
Expand Down Expand Up @@ -77,7 +69,7 @@ export type APIProviderProps = {
};

/**
* local hook to manage access to map-instances.
* local hook to set up the map-instance management context.
*/
function useMapInstances() {
const [mapInstances, setMapInstances] = useState<
Expand Down Expand Up @@ -107,7 +99,9 @@ function useMapInstances() {
function useGoogleMapsApiLoader(props: APIProviderProps) {
const {onLoad, apiKey, libraries = [], ...otherApiParams} = props;

const [status, setStatus] = useState<APILoadingStatus>(NOT_LOADED);
const [status, setStatus] = useState<APILoadingStatus>(
GoogleMapsApiLoader.loadingStatus
);
const [loadedLibraries, addLoadedLibrary] = useReducer(
(
loadedLibraries: LoadedLibraries,
Expand Down Expand Up @@ -147,17 +141,16 @@ function useGoogleMapsApiLoader(props: APIProviderProps) {

useEffect(
() => {
setStatus(LOADING);

(async () => {
try {
await GoogleMapsApiLoader.load({
key: apiKey,
libraries: librariesString,
...otherApiParams
});

setStatus(LOADED);
await GoogleMapsApiLoader.load(
{
key: apiKey,
libraries: librariesString,
...otherApiParams
},
status => setStatus(status)
);

for (const name of ['core', 'maps', ...libraries]) {
await importLibrary(name);
Expand All @@ -168,7 +161,6 @@ function useGoogleMapsApiLoader(props: APIProviderProps) {
}
} catch (error) {
console.error('<ApiProvider> failed to load Google Maps API', error);
setStatus(FAILED);
}
})();
},
Expand Down
43 changes: 43 additions & 0 deletions src/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {MapEventProps, useMapEvents} from './use-map-events';
import {useMapOptions} from './use-map-options';
import {useDeckGLCameraUpdate} from './use-deckgl-camera-update';
import {useInternalCameraState} from './use-internal-camera-state';
import {useApiLoadingStatus} from '../../hooks/use-api-loading-status';
import {APILoadingStatus} from '../../libraries/api-loading-status';

export interface GoogleMapsContextValue {
map: google.maps.Map | null;
Expand Down Expand Up @@ -66,6 +68,7 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
const {children, id, className, style, viewState, viewport} = props;

const context = useContext(APIProviderContext);
const loadingStatus = useApiLoadingStatus();

if (!context) {
throw new Error(
Expand All @@ -92,6 +95,16 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
[style, isViewportSet]
);

if (loadingStatus === APILoadingStatus.AUTH_FAILURE) {
return (
<div
style={{position: 'relative', ...(className ? {} : combinedStyle)}}
className={className}>
<AuthFailureMessage />
</div>
);
}

return (
<div
ref={mapRef}
Expand All @@ -109,6 +122,36 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
};
Map.deckGLViewProps = true;

const AuthFailureMessage = () => {
const style: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
zIndex: 999,
display: 'flex',
flexFlow: 'column nowrap',
textAlign: 'center',
justifyContent: 'center',
fontSize: '.8rem',
color: 'rgba(0,0,0,0.6)',
background: '#dddddd',
padding: '1rem 1.5rem'
};

return (
<div style={style}>
<h2>Error: AuthFailure</h2>
<p>
A problem with your API key prevents the map from rendering correctly.
Please make sure the value of the <code>APIProvider.apiKey</code> prop
is correct. Check the error-message in the console for further details.
</p>
</div>
);
};

/**
* The main hook takes care of creating map-instances and registering them in
* the api-provider context.
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/__tests__/api-loading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import {initialize} from '@googlemaps/jest-mocks';
import {renderHook} from '@testing-library/react';

import {
APILoadingStatus,
APIProviderContext,
APIProviderContextValue
} from '../../components/api-provider';

import {useApiLoadingStatus} from '../use-api-loading-status';
import {useApiIsLoaded} from '../use-api-is-loaded';
import {APILoadingStatus} from '../../libraries/api-loading-status';

let wrapper: ({children}: {children: React.ReactNode}) => JSX.Element | null;
let mockContextValue: jest.MockedObject<APIProviderContextValue>;
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/__tests__/use-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks';

import {useMap} from '../use-map';
import {
APILoadingStatus,
APIProviderContext,
APIProviderContextValue
} from '../../components/api-provider';
import {Map as GoogleMap} from '../../components/map';
import {APILoadingStatus} from '../../libraries/api-loading-status';

let MockApiContextProvider: ({
children
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-api-is-loaded.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {APILoadingStatus} from '../components/api-provider';
import {useApiLoadingStatus} from './use-api-loading-status';
import {APILoadingStatus} from '../libraries/api-loading-status';
/**
* Hook to check if the Google Maps API is loaded
*/
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/use-api-loading-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useContext} from 'react';
import {APILoadingStatus, APIProviderContext} from '../components/api-provider';
import {APIProviderContext} from '../components/api-provider';
import {APILoadingStatus} from '../libraries/api-loading-status';

export function useApiLoadingStatus(): APILoadingStatus {
return useContext(APIProviderContext)?.status || APILoadingStatus.NOT_LOADED;
Expand Down
13 changes: 9 additions & 4 deletions src/libraries/__mocks__/google-maps-api-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import type {GoogleMapsApiLoader as ActualLoader} from '../google-maps-api-loade

// FIXME: this should no longer be needed with the next version of @googlemaps/jest-mocks
import {importLibraryMock} from './lib/import-library-mock';
import {APILoadingStatus} from '../api-loading-status';

export class GoogleMapsApiLoader {
static load: typeof ActualLoader.load = jest.fn(() => {
google.maps.importLibrary = importLibraryMock;
return Promise.resolve();
});
static loadingStatus: APILoadingStatus = APILoadingStatus.LOADED;
static load: typeof ActualLoader.load = jest.fn(
(_, onLoadingStatusChange) => {
google.maps.importLibrary = importLibraryMock;
onLoadingStatusChange(APILoadingStatus.LOADED);
return Promise.resolve();
}
);
}
9 changes: 9 additions & 0 deletions src/libraries/api-loading-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const APILoadingStatus = {
NOT_LOADED: 'NOT_LOADED',
LOADING: 'LOADING',
LOADED: 'LOADED',
FAILED: 'FAILED',
AUTH_FAILURE: 'AUTH_FAILURE'
};
export type APILoadingStatus =
(typeof APILoadingStatus)[keyof typeof APILoadingStatus];
54 changes: 44 additions & 10 deletions src/libraries/google-maps-api-loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {APILoadingStatus} from './api-loading-status';

export type ApiParams = {
key: string;
v?: string;
Expand All @@ -12,7 +14,7 @@ export type ApiParams = {
declare global {
interface Window {
__googleMapsCallback__?: () => void;
__googleMapsApiParams__?: string;
gm_authFailure?: () => void;
}
}

Expand All @@ -25,6 +27,9 @@ const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js';
* allow using the API in an useEffect hook, without worrying about multiple API loads.
*/
export class GoogleMapsApiLoader {
public static loadingStatus: APILoadingStatus = APILoadingStatus.NOT_LOADED;
public static serializedApiParams?: string;

/**
* Loads the Google Maps API with the specified parameters.
* Since the Maps library can only be loaded once per page, this will
Expand All @@ -33,24 +38,35 @@ export class GoogleMapsApiLoader {
*
* 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> {
static async load(
params: ApiParams,
onLoadingStatusChange: (status: APILoadingStatus) => void
): Promise<void> {
const libraries = params.libraries ? params.libraries.split(',') : [];
const serializedParams = this.serializeParams(params);

// note: if google.maps.importLibrary was defined externally, the params
// will be ignored. If it was defined by a previous call to this
// method, we will check that the key and other parameters have not been
// changed in between calls.

if (!window.google?.maps?.importLibrary) {
window.__googleMapsApiParams__ = serializedParams;
this.initImportLibrary(params);
this.serializedApiParams = serializedParams;
this.initImportLibrary(params, onLoadingStatusChange);
} else {
// if serializedApiParams isn't defined the library was loaded externally
// and we can only assume that went alright.
if (!this.serializedApiParams) {
this.loadingStatus = APILoadingStatus.LOADED;
}

onLoadingStatusChange(this.loadingStatus);
}

if (
window.__googleMapsApiParams__ &&
window.__googleMapsApiParams__ !== serializedParams
this.serializedApiParams &&
this.serializedApiParams !== serializedParams
) {
console.warn(
`The maps API has already been loaded with different ` +
Expand All @@ -75,7 +91,10 @@ export class GoogleMapsApiLoader {
].join('/');
}

private static initImportLibrary(params: ApiParams) {
private static initImportLibrary(
params: ApiParams,
onLoadingStatusChange: (status: APILoadingStatus) => void
) {
if (!window.google) window.google = {} as never;
if (!window.google.maps) window.google.maps = {} as never;

Expand Down Expand Up @@ -105,14 +124,29 @@ export class GoogleMapsApiLoader {
urlParams.set('callback', '__googleMapsCallback__');
scriptElement.src = MAPS_API_BASE_URL + `?` + urlParams.toString();

window.__googleMapsCallback__ = resolve;
window.__googleMapsCallback__ = () => {
this.loadingStatus = APILoadingStatus.LOADED;
onLoadingStatusChange(this.loadingStatus);
resolve();
};

scriptElement.onerror = () =>
window.gm_authFailure = () => {
this.loadingStatus = APILoadingStatus.AUTH_FAILURE;
onLoadingStatusChange(this.loadingStatus);
};

scriptElement.onerror = () => {
this.loadingStatus = APILoadingStatus.FAILED;
onLoadingStatusChange(this.loadingStatus);
reject(new Error('The Google Maps JavaScript API could not load.'));
};

scriptElement.nonce =
(document.querySelector('script[nonce]') as HTMLScriptElement)
?.nonce || '';

this.loadingStatus = APILoadingStatus.LOADING;
onLoadingStatusChange(this.loadingStatus);
document.head.append(scriptElement);
});

Expand Down

0 comments on commit 26ccc15

Please sign in to comment.