From 7195399f1a0bf9feff309d39caf067183123e292 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 27 Oct 2023 11:49:40 +0200 Subject: [PATCH] feat: useMapsLibrary returns API object instead of boolean BREAKING CHANGE: loading multiple libraries at once is no longer supported, changed the return type of useMapsLibrary. --- .../__tests__/api-provider.test.tsx | 6 +- src/components/__tests__/map.test.tsx | 2 +- src/components/advanced-marker.tsx | 2 +- src/components/api-provider.tsx | 62 ++++-- src/components/map.tsx | 2 +- ...g-status.test.tsx => api-loading.test.tsx} | 6 +- ...map-instance.test.tsx => use-map.test.tsx} | 4 +- src/hooks/api-loading-status.ts | 33 --- src/hooks/autocomplete.ts | 2 +- src/hooks/directions-service.ts | 4 +- src/hooks/street-view-panorama.ts | 4 +- src/hooks/use-api-is-loaded.ts | 10 + src/hooks/use-api-loading-status.ts | 6 + src/hooks/{map-instance.ts => use-map.ts} | 0 src/hooks/use-maps-library.ts | 40 ++++ src/index.ts | 7 +- .../__mocks__/google-maps-api-loader.ts | 8 +- .../__mocks__/lib/import-library-mock.ts | 203 ++++++++++++++++++ 18 files changed, 329 insertions(+), 72 deletions(-) rename src/hooks/__tests__/{api-loading-status.test.tsx => api-loading.test.tsx} (92%) rename src/hooks/__tests__/{map-instance.test.tsx => use-map.test.tsx} (97%) delete mode 100644 src/hooks/api-loading-status.ts create mode 100644 src/hooks/use-api-is-loaded.ts create mode 100644 src/hooks/use-api-loading-status.ts rename src/hooks/{map-instance.ts => use-map.ts} (100%) create mode 100644 src/hooks/use-maps-library.ts create mode 100644 src/libraries/__mocks__/lib/import-library-mock.ts diff --git a/src/components/__tests__/api-provider.test.tsx b/src/components/__tests__/api-provider.test.tsx index 786a508..d0c46fc 100644 --- a/src/components/__tests__/api-provider.test.tsx +++ b/src/components/__tests__/api-provider.test.tsx @@ -3,6 +3,9 @@ import {act, render, screen} from '@testing-library/react'; import {initialize} from '@googlemaps/jest-mocks'; import '@testing-library/jest-dom'; +// FIXME: this should no longer be needed with the next version of @googlemaps/jest-mocks +import {importLibraryMock} from '../../libraries/__mocks__/lib/import-library-mock'; + import { APILoadingStatus, APIProvider, @@ -10,7 +13,7 @@ import { APIProviderContextValue } from '../api-provider'; import {ApiParams} from '../../libraries/google-maps-api-loader'; -import {useApiIsLoaded} from '../../hooks/api-loading-status'; +import {useApiIsLoaded} from '../../hooks/use-api-is-loaded'; const apiLoadSpy = jest.fn(); const apiUnloadSpy = jest.fn(); @@ -29,6 +32,7 @@ jest.mock('../../libraries/google-maps-api-loader', () => { class GoogleMapsApiLoader { static async load(params: ApiParams): Promise { apiLoadSpy(params); + google.maps.importLibrary = importLibraryMock; return new Promise(resolve => (triggerMapsApiLoaded = resolve)); } static unload() { diff --git a/src/components/__tests__/map.test.tsx b/src/components/__tests__/map.test.tsx index ce1f2b6..e2fbb06 100644 --- a/src/components/__tests__/map.test.tsx +++ b/src/components/__tests__/map.test.tsx @@ -24,7 +24,7 @@ beforeEach(() => { mockContextValue = { importLibrary: jest.fn(), - loadedLibraries: new Set(), + loadedLibraries: {}, status: APILoadingStatus.LOADED, mapInstances: {}, addMapInstance: jest.fn(), diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 13ad496..38971c0 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -13,7 +13,7 @@ import {createPortal} from 'react-dom'; import {GoogleMapsContext} from './map'; import type {Ref, PropsWithChildren} from 'react'; -import {useMapsLibrary} from '../hooks/api-loading-status'; +import {useMapsLibrary} from '../hooks/use-maps-library'; export interface AdvancedMarkerContextValue { marker: google.maps.marker.AdvancedMarkerElement; diff --git a/src/components/api-provider.tsx b/src/components/api-provider.tsx index 4bfb988..059f576 100644 --- a/src/components/api-provider.tsx +++ b/src/components/api-provider.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, + useReducer, useState } from 'react'; @@ -18,12 +19,13 @@ export enum APILoadingStatus { const {NOT_LOADED, LOADING, LOADED, FAILED} = APILoadingStatus; -/** - * API Provider context - */ +type ImportLibraryFunction = typeof google.maps.importLibrary; +type GoogleMapsLibrary = Awaited>; +type LoadedLibraries = {[name: string]: GoogleMapsLibrary}; + export interface APIProviderContextValue { status: APILoadingStatus; - loadedLibraries: Set; + loadedLibraries: LoadedLibraries; importLibrary: typeof google.maps.importLibrary; mapInstances: Record; addMapInstance: (map: google.maps.Map, id?: string) => void; @@ -106,8 +108,14 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { const {onLoad, apiKey, libraries = [], ...otherApiParams} = props; const [status, setStatus] = useState(NOT_LOADED); - const [loadedLibraries, setLoadedLibraries] = useState>( - new Set() + const [loadedLibraries, addLoadedLibrary] = useReducer( + ( + loadedLibraries: LoadedLibraries, + action: {name: keyof LoadedLibraries; value: LoadedLibraries[string]} + ) => { + return {...loadedLibraries, [action.name]: action.value}; + }, + {} ); const librariesString = useMemo(() => libraries?.join(','), [libraries]); @@ -116,6 +124,27 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { [otherApiParams] ); + const importLibrary: typeof google.maps.importLibrary = useCallback( + async (name: string) => { + if (loadedLibraries[name]) { + return loadedLibraries[name]; + } + + if (!google?.maps?.importLibrary) { + throw new Error( + '[api-provider-internal] importLibrary was called before ' + + 'google.maps.importLibrary was defined.' + ); + } + + const res = await window.google.maps.importLibrary(name); + addLoadedLibrary({name, value: res}); + + return res; + }, + [] + ); + useEffect( () => { setStatus(LOADING); @@ -129,7 +158,10 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { }); setStatus(LOADED); - setLoadedLibraries(new Set(['maps', ...libraries])); + + for (const name of ['core', 'maps', ...libraries]) { + await importLibrary(name); + } if (onLoad) { onLoad(); @@ -144,22 +176,6 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { [apiKey, librariesString, serializedParams] ); - const importLibrary: typeof google.maps.importLibrary = useCallback( - async (name: string) => { - if (!google?.maps?.importLibrary) { - throw new Error( - 'importLibrary was called before google.maps.importLibrary was defined' - ); - } - - const res = await window.google.maps.importLibrary(name); - setLoadedLibraries(new Set([...loadedLibraries, name])); - - return res; - }, - [] - ); - return { status, loadedLibraries, diff --git a/src/components/map.tsx b/src/components/map.tsx index b056dc1..ba4539b 100644 --- a/src/components/map.tsx +++ b/src/components/map.tsx @@ -12,7 +12,7 @@ import React, { import {APIProviderContext, APIProviderContextValue} from './api-provider'; -import {useApiIsLoaded} from '../hooks/api-loading-status'; +import {useApiIsLoaded} from '../hooks/use-api-is-loaded'; import {logErrorOnce} from '../libraries/errors'; // Google Maps context diff --git a/src/hooks/__tests__/api-loading-status.test.tsx b/src/hooks/__tests__/api-loading.test.tsx similarity index 92% rename from src/hooks/__tests__/api-loading-status.test.tsx rename to src/hooks/__tests__/api-loading.test.tsx index 8dcdb2c..8400856 100644 --- a/src/hooks/__tests__/api-loading-status.test.tsx +++ b/src/hooks/__tests__/api-loading.test.tsx @@ -7,7 +7,9 @@ import { APIProviderContext, APIProviderContextValue } from '../../components/api-provider'; -import {useApiIsLoaded, useApiLoadingStatus} from '../api-loading-status'; + +import {useApiLoadingStatus} from '../use-api-loading-status'; +import {useApiIsLoaded} from '../use-api-is-loaded'; let wrapper: ({children}: {children: React.ReactNode}) => JSX.Element | null; let mockContextValue: jest.MockedObject; @@ -16,7 +18,7 @@ beforeEach(() => { mockContextValue = { importLibrary: jest.fn(), - loadedLibraries: new Set(), + loadedLibraries: {}, status: APILoadingStatus.LOADED, mapInstances: {}, addMapInstance: jest.fn(), diff --git a/src/hooks/__tests__/map-instance.test.tsx b/src/hooks/__tests__/use-map.test.tsx similarity index 97% rename from src/hooks/__tests__/map-instance.test.tsx rename to src/hooks/__tests__/use-map.test.tsx index 7f4cacc..a8c69a3 100644 --- a/src/hooks/__tests__/map-instance.test.tsx +++ b/src/hooks/__tests__/use-map.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom'; import {renderHook} from '@testing-library/react'; import {initialize, mockInstances} from '@googlemaps/jest-mocks'; -import {useMap} from '../map-instance'; +import {useMap} from '../use-map'; import { APILoadingStatus, APIProviderContext, @@ -28,7 +28,7 @@ beforeEach(() => { mockContextValue = { importLibrary: jest.fn(), - loadedLibraries: new Set(), + loadedLibraries: {}, status: APILoadingStatus.LOADED, mapInstances: {}, addMapInstance: jest.fn(), diff --git a/src/hooks/api-loading-status.ts b/src/hooks/api-loading-status.ts deleted file mode 100644 index 9ae6bf1..0000000 --- a/src/hooks/api-loading-status.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {useContext, useEffect} from 'react'; - -import {APILoadingStatus, APIProviderContext} from '../components/api-provider'; - -/** - * Hook to check if the Google Maps API is loaded - */ -export function useApiIsLoaded(): boolean { - const status = useApiLoadingStatus(); - - return status === APILoadingStatus.LOADED; -} - -export function useApiLoadingStatus(): APILoadingStatus { - return useContext(APIProviderContext)?.status || APILoadingStatus.NOT_LOADED; -} - -export function useMapsLibrary(...names: string[]) { - const apiIsLoaded = useApiIsLoaded(); - const ctx = useContext(APIProviderContext); - - useEffect(() => { - if (!apiIsLoaded || !ctx) return; - - // trigger loading the libraries via our proxy-method, which will update the - // loadedLibraries list in the context, triggering a re-render. - for (const name of names) { - if (!ctx.loadedLibraries.has(name)) void ctx.importLibrary(name); - } - }, [apiIsLoaded, ctx?.importLibrary]); - - return names.every(name => ctx?.loadedLibraries.has(name) || false); -} diff --git a/src/hooks/autocomplete.ts b/src/hooks/autocomplete.ts index 6aa349d..c624c12 100644 --- a/src/hooks/autocomplete.ts +++ b/src/hooks/autocomplete.ts @@ -1,6 +1,6 @@ import {useState, useRef, useEffect} from 'react'; -import {useApiIsLoaded} from './api-loading-status'; +import {useApiIsLoaded} from './use-api-is-loaded'; export interface AutocompleteProps { inputField: HTMLInputElement | null; diff --git a/src/hooks/directions-service.ts b/src/hooks/directions-service.ts index 1a29a52..5b91e52 100644 --- a/src/hooks/directions-service.ts +++ b/src/hooks/directions-service.ts @@ -1,7 +1,7 @@ import {useMemo, useEffect, useCallback} from 'react'; -import {useApiIsLoaded} from './api-loading-status'; -import {useMap} from './map-instance'; +import {useApiIsLoaded} from './use-api-is-loaded'; +import {useMap} from './use-map'; import {assertNotNull} from '../libraries/assert-not-null'; export interface DirectionsServiceHookOptions { diff --git a/src/hooks/street-view-panorama.ts b/src/hooks/street-view-panorama.ts index 08872fe..5dea316 100644 --- a/src/hooks/street-view-panorama.ts +++ b/src/hooks/street-view-panorama.ts @@ -1,7 +1,7 @@ /* eslint-disable complexity */ import {useEffect, useState} from 'react'; -import {useApiIsLoaded} from './api-loading-status'; -import {useMap} from './map-instance'; +import {useApiIsLoaded} from './use-api-is-loaded'; +import {useMap} from './use-map'; export interface StreetViewPanoramaProps { mapId?: string; diff --git a/src/hooks/use-api-is-loaded.ts b/src/hooks/use-api-is-loaded.ts new file mode 100644 index 0000000..7268eec --- /dev/null +++ b/src/hooks/use-api-is-loaded.ts @@ -0,0 +1,10 @@ +import {APILoadingStatus} from '../components/api-provider'; +import {useApiLoadingStatus} from './use-api-loading-status'; +/** + * Hook to check if the Google Maps API is loaded + */ +export function useApiIsLoaded(): boolean { + const status = useApiLoadingStatus(); + + return status === APILoadingStatus.LOADED; +} diff --git a/src/hooks/use-api-loading-status.ts b/src/hooks/use-api-loading-status.ts new file mode 100644 index 0000000..a897e64 --- /dev/null +++ b/src/hooks/use-api-loading-status.ts @@ -0,0 +1,6 @@ +import {useContext} from 'react'; +import {APILoadingStatus, APIProviderContext} from '../components/api-provider'; + +export function useApiLoadingStatus(): APILoadingStatus { + return useContext(APIProviderContext)?.status || APILoadingStatus.NOT_LOADED; +} diff --git a/src/hooks/map-instance.ts b/src/hooks/use-map.ts similarity index 100% rename from src/hooks/map-instance.ts rename to src/hooks/use-map.ts diff --git a/src/hooks/use-maps-library.ts b/src/hooks/use-maps-library.ts new file mode 100644 index 0000000..1c51998 --- /dev/null +++ b/src/hooks/use-maps-library.ts @@ -0,0 +1,40 @@ +import {useContext, useEffect} from 'react'; + +import {APIProviderContext} from '../components/api-provider'; +import {useApiIsLoaded} from './use-api-is-loaded'; + +interface ApiLibraries { + core: google.maps.CoreLibrary; + maps: google.maps.MapsLibrary; + places: google.maps.PlacesLibrary; + geocoding: google.maps.GeocodingLibrary; + routes: google.maps.RoutesLibrary; + marker: google.maps.MarkerLibrary; + geometry: google.maps.GeometryLibrary; + elevation: google.maps.ElevationLibrary; + streetView: google.maps.StreetViewLibrary; + journeySharing: google.maps.JourneySharingLibrary; + drawing: google.maps.DrawingLibrary; + visualization: google.maps.VisualizationLibrary; +} + +export function useMapsLibrary< + K extends keyof ApiLibraries, + V extends ApiLibraries[K] +>(name: K): V | null; + +export function useMapsLibrary(name: string) { + const apiIsLoaded = useApiIsLoaded(); + const ctx = useContext(APIProviderContext); + + useEffect(() => { + if (!apiIsLoaded || !ctx) return; + + // Trigger loading the libraries via our proxy-method. + // The returned promise is ignored, since importLibrary will update loadedLibraries + // list in the context, triggering a re-render. + void ctx.importLibrary(name); + }, [apiIsLoaded, ctx?.importLibrary]); + + return ctx?.loadedLibraries[name] || null; +} diff --git a/src/index.ts b/src/index.ts index 9fc9537..9377bb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,12 @@ export * from './components/info-window'; export * from './components/map'; export * from './components/marker'; export * from './components/pin'; -export * from './hooks/api-loading-status'; +export * from './hooks/use-api-loading-status'; +export * from './hooks/use-api-is-loaded'; +export * from './hooks/use-maps-library'; +export * from './hooks/use-map'; export * from './hooks/autocomplete'; export * from './hooks/directions-service'; -export * from './hooks/map-instance'; export * from './hooks/street-view-panorama'; + export {limitTiltRange} from './libraries/limit-tilt-range'; diff --git a/src/libraries/__mocks__/google-maps-api-loader.ts b/src/libraries/__mocks__/google-maps-api-loader.ts index 18c5d19..5c31be5 100644 --- a/src/libraries/__mocks__/google-maps-api-loader.ts +++ b/src/libraries/__mocks__/google-maps-api-loader.ts @@ -1,5 +1,11 @@ import type {GoogleMapsApiLoader as ActualLoader} from '../google-maps-api-loader'; +// FIXME: this should no longer be needed with the next version of @googlemaps/jest-mocks +import {importLibraryMock} from './lib/import-library-mock'; + export class GoogleMapsApiLoader { - static load: typeof ActualLoader.load = jest.fn(() => Promise.resolve()); + static load: typeof ActualLoader.load = jest.fn(() => { + google.maps.importLibrary = importLibraryMock; + return Promise.resolve(); + }); } diff --git a/src/libraries/__mocks__/lib/import-library-mock.ts b/src/libraries/__mocks__/lib/import-library-mock.ts new file mode 100644 index 0000000..98f0ab7 --- /dev/null +++ b/src/libraries/__mocks__/lib/import-library-mock.ts @@ -0,0 +1,203 @@ +// FIXME: remove once we can update to the new @googlemaps/jest-mocks version +export const importLibraryMock = jest.fn(async (name: string) => { + switch (name) { + case 'core': { + const { + ControlPosition, + event, + LatLng, + LatLngAltitude, + LatLngBounds, + MapsNetworkError, + MapsNetworkErrorEndpoint, + MapsRequestError, + MapsServerError, + MVCArray, + MVCObject, + Point, + Settings, + Size, + SymbolPath, + UnitSystem + } = google.maps; + + return { + ControlPosition, + event, + LatLng, + LatLngAltitude, + LatLngBounds, + MapsNetworkError, + MapsNetworkErrorEndpoint, + MapsRequestError, + MapsServerError, + MVCArray, + MVCObject, + Point, + Settings, + Size, + SymbolPath, + UnitSystem + } as google.maps.CoreLibrary; + } + + case 'maps': { + const { + BicyclingLayer, + Circle, + Data, + FeatureType, + GroundOverlay, + ImageMapType, + InfoWindow, + KmlLayer, + KmlLayerStatus, + Map, + MapTypeControlStyle, + MapTypeId, + MapTypeRegistry, + MaxZoomService, + MaxZoomStatus, + OverlayView, + Polygon, + Polyline, + Rectangle, + RenderingType, + StrokePosition, + StyledMapType, + TrafficLayer, + TransitLayer, + WebGLOverlayView + } = google.maps; + + return { + BicyclingLayer, + Circle, + Data, + FeatureType, + GroundOverlay, + ImageMapType, + InfoWindow, + KmlLayer, + KmlLayerStatus, + Map, + MapTypeControlStyle, + MapTypeId, + MapTypeRegistry, + MaxZoomService, + MaxZoomStatus, + OverlayView, + Polygon, + Polyline, + Rectangle, + RenderingType, + StrokePosition, + StyledMapType, + TrafficLayer, + TransitLayer, + WebGLOverlayView + } as google.maps.MapsLibrary; + } + case 'places': + return google.maps.places as google.maps.PlacesLibrary; + case 'geocoding': { + const {Geocoder, GeocoderLocationType, GeocoderStatus} = google.maps; + return { + Geocoder, + GeocoderLocationType, + GeocoderStatus + } as google.maps.GeocodingLibrary; + } + case 'routes': { + const { + DirectionsRenderer, + DirectionsService, + DirectionsStatus, + DistanceMatrixElementStatus, + DistanceMatrixService, + DistanceMatrixStatus, + TrafficModel, + TransitMode, + TransitRoutePreference, + TravelMode, + VehicleType + } = google.maps; + + return { + DirectionsRenderer, + DirectionsService, + DirectionsStatus, + DistanceMatrixElementStatus, + DistanceMatrixService, + DistanceMatrixStatus, + TrafficModel, + TransitMode, + TransitRoutePreference, + TravelMode, + VehicleType + } as google.maps.RoutesLibrary; + } + case 'marker': { + const { + Animation, + CollisionBehavior, + Marker, + marker: {AdvancedMarkerClickEvent, AdvancedMarkerElement, PinElement} + } = google.maps; + + return { + AdvancedMarkerClickEvent, + AdvancedMarkerElement, + Animation, + CollisionBehavior, + Marker, + PinElement + } as google.maps.MarkerLibrary; + } + case 'geometry': { + return google.maps.geometry as google.maps.GeometryLibrary; + } + case 'elevation': { + const {ElevationService, ElevationStatus} = google.maps; + + return { + ElevationService, + ElevationStatus + } as google.maps.ElevationLibrary; + } + case 'streetView': { + const { + InfoWindow, + OverlayView, + StreetViewCoverageLayer, + StreetViewPanorama, + StreetViewPreference, + StreetViewService, + StreetViewSource, + StreetViewStatus + } = google.maps; + + return { + InfoWindow, + OverlayView, + StreetViewCoverageLayer, + StreetViewPanorama, + StreetViewPreference, + StreetViewService, + StreetViewSource, + StreetViewStatus + } as google.maps.StreetViewLibrary; + } + case 'journeySharing': { + return google.maps.journeySharing as google.maps.JourneySharingLibrary; + } + case 'drawing': { + return google.maps.drawing as google.maps.DrawingLibrary; + } + case 'visualization': { + return google.maps.visualization as google.maps.VisualizationLibrary; + } + } + + throw new TypeError(`unknown library name: ${name}`); +});