diff --git a/src/components/__tests__/map.test.tsx b/src/components/__tests__/map.test.tsx index b621f7ed..ee82e435 100644 --- a/src/components/__tests__/map.test.tsx +++ b/src/components/__tests__/map.test.tsx @@ -1,5 +1,5 @@ import React, {FunctionComponent, PropsWithChildren} from 'react'; -import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {act, cleanup, render, screen, waitFor} from '@testing-library/react'; import {initialize, mockInstances} from '@googlemaps/jest-mocks'; import '@testing-library/jest-dom'; @@ -309,13 +309,168 @@ describe('camera configuration', () => { test.todo('initial camera state is passed via mapOptions, not moveCamera'); test.todo('updated camera state is passed to moveCamera'); test.todo("re-renders with unchanged camera state don't trigger moveCamera"); - test.todo( - "re-renders with props received via events don't trigger moveCamera" - ); + + test("invalid camera events don't overwrite the last known camera state", async () => { + const listeners: Record void>> = {}; + google.maps.event.addListener = jest.fn((_, eventName, handler) => { + listeners[eventName] ??= []; + listeners[eventName].push(handler as () => void); + return {remove: jest.fn()}; + }); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const view = render( + , + {wrapper} + ); + + await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument()); + + const mapInstance = jest.mocked(mockInstances.get(google.maps.Map).at(-1)!); + jest.mocked(mapInstance.getCenter).mockReturnValue({ + toJSON: () => ({lat: 53.55, lng: 10.05}) + } as google.maps.LatLng); + jest.mocked(mapInstance.getBounds).mockReturnValue({ + toJSON: () => ({north: 54, east: 11, south: 53, west: 10}) + } as google.maps.LatLngBounds); + jest.mocked(mapInstance.getZoom).mockReturnValue(8); + + act(() => { + listeners.bounds_changed.forEach(listener => listener()); + }); + + jest.mocked(mapInstance.moveCamera).mockClear(); + jest.mocked(mapInstance.getZoom).mockReturnValue(NaN); + + act(() => { + listeners.bounds_changed.forEach(listener => listener()); + }); + + view.rerender(); + + expect(mapInstance.moveCamera).not.toHaveBeenCalled(); + }); + + test('tracked camera state accepts valid camera values while bounds are unavailable', async () => { + const listeners: Record void>> = {}; + google.maps.event.addListener = jest.fn((_, eventName, handler) => { + listeners[eventName] ??= []; + listeners[eventName].push(handler as () => void); + return {remove: jest.fn()}; + }); + + const view = render( + , + {wrapper} + ); + + await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument()); + + const mapInstance = jest.mocked(mockInstances.get(google.maps.Map).at(-1)!); + jest.mocked(mapInstance.getCenter).mockReturnValue({ + toJSON: () => ({lat: 53.55, lng: 10.05}) + } as google.maps.LatLng); + jest.mocked(mapInstance.getBounds).mockReturnValue(undefined); + jest.mocked(mapInstance.getZoom).mockReturnValue(8); + + act(() => { + listeners.bounds_changed.forEach(listener => listener()); + }); + + jest.mocked(mapInstance.moveCamera).mockClear(); + + view.rerender(); + + expect(mapInstance.moveCamera).not.toHaveBeenCalled(); + }); }); describe('map events and event-props', () => { - test.todo('events dispatched by the map are received via event-props'); + test('events dispatched by the map are received via event-props', async () => { + const listeners: Record void>> = {}; + google.maps.event.addListener = jest.fn((_, eventName, handler) => { + listeners[eventName] ??= []; + listeners[eventName].push(handler as () => void); + return {remove: jest.fn()}; + }); + + const handleCameraChanged = jest.fn(); + + render( + , + {wrapper} + ); + + await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument()); + + const mapInstance = jest.mocked(mockInstances.get(google.maps.Map).at(-1)!); + jest.mocked(mapInstance.getCenter).mockReturnValue({ + toJSON: () => ({lat: 53.55, lng: 10.05}) + } as google.maps.LatLng); + jest.mocked(mapInstance.getBounds).mockReturnValue({ + toJSON: () => ({north: 54, east: 11, south: 53, west: 10}) + } as google.maps.LatLngBounds); + jest.mocked(mapInstance.getZoom).mockReturnValue(8); + + act(() => { + listeners.bounds_changed.forEach(listener => listener()); + }); + + expect(handleCameraChanged).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'bounds_changed', + detail: { + center: {lat: 53.55, lng: 10.05}, + zoom: 8, + heading: 0, + tilt: 0, + bounds: {north: 54, east: 11, south: 53, west: 10} + } + }) + ); + }); + + test('does not emit camera events with invalid map camera values', async () => { + const listeners: Record void>> = {}; + google.maps.event.addListener = jest.fn((_, eventName, handler) => { + listeners[eventName] ??= []; + listeners[eventName].push(handler as () => void); + return {remove: jest.fn()}; + }); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const handleCameraChanged = jest.fn(); + + render( + , + {wrapper} + ); + + await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument()); + + const mapInstance = jest.mocked(mockInstances.get(google.maps.Map).at(-1)!); + jest.mocked(mapInstance.getCenter).mockReturnValue({ + toJSON: () => ({lat: 53.55, lng: 10.05}) + } as google.maps.LatLng); + jest.mocked(mapInstance.getBounds).mockReturnValue({ + toJSON: () => ({north: 54, east: 11, south: 53, west: 10}) + } as google.maps.LatLngBounds); + jest.mocked(mapInstance.getZoom).mockReturnValue(NaN); + + act(() => { + listeners.bounds_changed.forEach(listener => listener()); + }); + + expect(handleCameraChanged).not.toHaveBeenCalled(); + }); }); describe('internalUsageAttributionIds', () => { diff --git a/src/components/map/use-map-events.ts b/src/components/map/use-map-events.ts index 16550f7e..c43eb59a 100644 --- a/src/components/map/use-map-events.ts +++ b/src/components/map/use-map-events.ts @@ -63,7 +63,8 @@ export function useMapEvents( map, eventType, (ev?: google.maps.MapMouseEvent | google.maps.IconMouseEvent) => { - handler(createMapEvent(eventType, map, ev)); + const mapEvent = createMapEvent(eventType, map, ev); + if (mapEvent) handler(mapEvent); } ); @@ -82,7 +83,7 @@ function createMapEvent( type: string, map: google.maps.Map, srcEvent?: google.maps.MapMouseEvent | google.maps.IconMouseEvent -): MapEvent { +): MapEvent | null { const ev: MapEvent = { type, map, @@ -106,19 +107,16 @@ function createMapEvent( 'returned undefined. This is not expected to happen. Please ' + 'report an issue at https://github.com/visgl/react-google-maps/issues/new' ); + + return null; } camEvent.detail = { - center: center?.toJSON() || {lat: 0, lng: 0}, - zoom: (zoom as number) || 0, + center: center.toJSON(), + zoom: zoom as number, heading: heading as number, tilt: tilt as number, - bounds: bounds?.toJSON() || { - north: 90, - east: 180, - south: -90, - west: -180 - } + bounds: bounds.toJSON() }; return camEvent; diff --git a/src/components/map/use-tracked-camera-state-ref.ts b/src/components/map/use-tracked-camera-state-ref.ts index e97c83bb..78e786a2 100644 --- a/src/components/map/use-tracked-camera-state-ref.ts +++ b/src/components/map/use-tracked-camera-state-ref.ts @@ -15,20 +15,20 @@ function handleBoundsChange(map: google.maps.Map, ref: CameraStateRef) { const zoom = map.getZoom(); const heading = map.getHeading() || 0; const tilt = map.getTilt() || 0; - const bounds = map.getBounds(); - if (!center || !bounds || !Number.isFinite(zoom)) { + if (!center || !Number.isFinite(zoom)) { console.warn( '[useTrackedCameraState] at least one of the values from the map ' + 'returned undefined. This is not expected to happen. Please ' + 'report an issue at https://github.com/visgl/react-google-maps/issues/new' ); + + return; } - // fixme: do we need the `undefined` cases for the camera-params? When are they used in the maps API? Object.assign(ref.current, { - center: center?.toJSON() || {lat: 0, lng: 0}, - zoom: (zoom as number) || 0, + center: center.toJSON(), + zoom: zoom as number, heading: heading as number, tilt: tilt as number });