From 55f9af1fa79fc7294e9903dd0eb9701607e3d878 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Wed, 8 Jul 2026 12:56:32 +0800 Subject: [PATCH 1/2] fix: ignore invalid camera events --- src/components/__tests__/map.test.tsx | 132 +++++++++++++++++- src/components/map/use-map-events.ts | 18 ++- .../map/use-tracked-camera-state-ref.ts | 7 +- 3 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/components/__tests__/map.test.tsx b/src/components/__tests__/map.test.tsx index b621f7ed..49c19cf6 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,135 @@ 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(); + }); }); 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..830f9a02 100644 --- a/src/components/map/use-tracked-camera-state-ref.ts +++ b/src/components/map/use-tracked-camera-state-ref.ts @@ -23,12 +23,13 @@ function handleBoundsChange(map: google.maps.Map, ref: CameraStateRef) { '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 }); From b17d71c8131fc3df8b2da5e14f936da24f05d8f6 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Thu, 9 Jul 2026 04:47:46 +0800 Subject: [PATCH 2/2] fix: keep valid camera state without bounds --- src/components/__tests__/map.test.tsx | 33 +++++++++++++++++++ .../map/use-tracked-camera-state-ref.ts | 3 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/__tests__/map.test.tsx b/src/components/__tests__/map.test.tsx index 49c19cf6..ee82e435 100644 --- a/src/components/__tests__/map.test.tsx +++ b/src/components/__tests__/map.test.tsx @@ -350,6 +350,39 @@ describe('camera configuration', () => { 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', () => { diff --git a/src/components/map/use-tracked-camera-state-ref.ts b/src/components/map/use-tracked-camera-state-ref.ts index 830f9a02..78e786a2 100644 --- a/src/components/map/use-tracked-camera-state-ref.ts +++ b/src/components/map/use-tracked-camera-state-ref.ts @@ -15,9 +15,8 @@ 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 ' +