From 14e74843acbf2ccafbca1bc41922d87dbc280067 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 31 Mar 2026 00:53:36 +1100 Subject: [PATCH] fix(google-maps): prevent zoom/pan reset when overlay toggles The options watcher called setOptions with initial zoom and center values on every re-evaluation, resetting user interactions when parent components re-rendered (e.g. overlay open/close toggling state). Split zoom and center into dedicated watchers that only fire on actual prop value changes. --- .../GoogleMaps/ScriptGoogleMaps.vue | 11 ++- test/unit/__mocks__/google-maps-api.ts | 13 +++ test/unit/google-maps-regressions.test.ts | 84 ++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 9e89d418..d7e67fd4 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -288,7 +288,16 @@ onMounted(() => { } }) watch(options, () => { - map.value?.setOptions(options.value) + if (!map.value) + return + // Exclude center and zoom — they have dedicated watchers that avoid + // resetting user interactions (pan/zoom) on unrelated re-renders. + const { center: _, zoom: __, ...rest } = options.value + map.value.setOptions(rest) + }) + watch(() => options.value.zoom, (zoom) => { + if (map.value && zoom != null) + map.value.setZoom(zoom) }) watch([() => options.value.center, ready, map], async (next) => { if (!map.value) { diff --git a/test/unit/__mocks__/google-maps-api.ts b/test/unit/__mocks__/google-maps-api.ts index 63aade89..70f5e622 100644 --- a/test/unit/__mocks__/google-maps-api.ts +++ b/test/unit/__mocks__/google-maps-api.ts @@ -1,5 +1,18 @@ import { vi } from 'vitest' +export function createMockMap() { + return { + setOptions: vi.fn(), + setCenter: vi.fn(), + setZoom: vi.fn(), + panBy: vi.fn(), + getDiv: vi.fn(() => document.createElement('div')), + getZoom: vi.fn(() => 15), + getCenter: vi.fn(() => ({ lat: () => 0, lng: () => 0 })), + unbindAll: vi.fn(), + } +} + export function createMockMarker() { return { setOptions: vi.fn(), diff --git a/test/unit/google-maps-regressions.test.ts b/test/unit/google-maps-regressions.test.ts index 03ed4db2..74287c5f 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { bindGoogleMapsEvents } from '../../packages/script/src/runtime/components/GoogleMaps/useGoogleMapsResource' -import { createMockAdvancedMarkerElement, createMockGoogleMapsAPIWithInstances, createMockInfoWindow } from './__mocks__/google-maps-api' +import { createMockAdvancedMarkerElement, createMockGoogleMapsAPIWithInstances, createMockInfoWindow, createMockMap } from './__mocks__/google-maps-api' describe('google Maps Regressions', () => { describe('bindGoogleMapsEvents emit forwarding', () => { @@ -317,6 +317,88 @@ describe('google Maps Regressions', () => { }) }) + describe('map setOptions should not reset zoom or center', () => { + // Regression: when parent component re-renders (e.g. overlay open/close toggling + // state in parent), the options computed re-evaluates (defu returns a new object), + // triggering watch(options) which called setOptions with the initial zoom and center, + // resetting any user pan/zoom interactions. + // Fix: exclude center and zoom from the generic setOptions call; use dedicated watchers. + + // Simulate the old (broken) watcher: passed full options including center/zoom + function applyOptionsOld(map: ReturnType, options: Record) { + map.setOptions(options) + } + + // Simulate the fixed watcher: strips center and zoom before calling setOptions + function applyOptionsFixed(map: ReturnType, options: Record) { + const { center: _, zoom: __, ...rest } = options + map.setOptions(rest) + } + + it('old behavior: setOptions resets zoom and center on unrelated re-render', () => { + const map = createMockMap() + const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc' } + + // User has panned/zoomed the map, but parent re-renders and the watcher fires. + // Old code passed full options, resetting zoom and center to initial values. + applyOptionsOld(map, options) + + expect(map.setOptions).toHaveBeenCalledWith( + expect.objectContaining({ zoom: 12, center: { lat: 40, lng: -74 } }), + ) + }) + + it('fixed behavior: setOptions excludes zoom and center', () => { + const map = createMockMap() + const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc' } + + applyOptionsFixed(map, options) + + expect(map.setOptions).toHaveBeenCalledWith({ mapId: 'abc' }) + expect(map.setOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ center: expect.anything() }), + ) + expect(map.setOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ zoom: expect.anything() }), + ) + }) + + it('old behavior: repeated overlay toggles reset zoom/center every time', () => { + const map = createMockMap() + const baseOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true } + + // Simulate 3 re-renders from overlay open/close/open + for (let i = 0; i < 3; i++) { + applyOptionsOld(map, { ...baseOptions }) + } + + // Every call leaked center and zoom, resetting user interactions each time + expect(map.setOptions).toHaveBeenCalledTimes(3) + for (const call of map.setOptions.mock.calls) { + expect(call[0]).toHaveProperty('center') + expect(call[0]).toHaveProperty('zoom') + } + }) + + it('fixed behavior: repeated overlay toggles never reset zoom/center', () => { + const map = createMockMap() + const baseOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true } + + // Simulate 3 re-renders from overlay open/close/open + for (let i = 0; i < 3; i++) { + applyOptionsFixed(map, { ...baseOptions }) + } + + expect(map.setOptions).toHaveBeenCalledTimes(3) + for (const call of map.setOptions.mock.calls) { + expect(call[0]).not.toHaveProperty('center') + expect(call[0]).not.toHaveProperty('zoom') + } + expect(map.setCenter).not.toHaveBeenCalled() + expect(map.setZoom).not.toHaveBeenCalled() + }) + }) + describe('infoWindow group close', () => { // Regression: opening a new InfoWindow didn't close the previous one. // Fix: shared activateInfoWindow on MAP_INJECTION_KEY closes the previous.