diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index d82afc18..f4b155b2 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -274,6 +274,47 @@ function getCenterWatchKey(center: ScriptGoogleMapsCenter): string | undefined { return undefined } +const controlledCenterKey = computed(() => { + return getCenterWatchKey(centerOverride.value) + || getCenterWatchKey(props.mapOptions?.center) + || getCenterWatchKey(props.center) +}) + +function getReactiveMapOptions(options: google.maps.MapOptions): google.maps.MapOptions { + // Exclude center and zoom — they have dedicated watchers that avoid + // resetting user interactions (pan/zoom) on unrelated re-renders. + // Exclude mapId and colorScheme — Google Maps treats these as init-only; + // changes are handled by the dedicated re-init watcher below. + const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options + return rest +} + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') + return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function isSameOptionValue(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) + return true + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => isSameOptionValue(value, b[index])) + } + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + return aKeys.length === bKeys.length + && aKeys.every(key => Object.hasOwn(b, key) && isSameOptionValue(a[key], b[key])) + } + return false +} + +function isSameMapOptions(a: google.maps.MapOptions, b: google.maps.MapOptions): boolean { + return isSameOptionValue(toRaw(a), toRaw(b)) +} + const queryToLatLngCache = new Map() async function resolveQueryToLatLng(query: string) { @@ -401,15 +442,12 @@ onMounted(() => { emits('error') } }) - watch(options, () => { + watch(() => getReactiveMapOptions(options.value), (nextOptions, previousOptions) => { if (!map.value) return - // Exclude center and zoom — they have dedicated watchers that avoid - // resetting user interactions (pan/zoom) on unrelated re-renders. - // Exclude mapId and colorScheme — Google Maps treats these as init-only; - // changes are handled by the dedicated re-init watcher below. - const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options.value - map.value.setOptions(rest) + if (isSameMapOptions(nextOptions, previousOptions)) + return + map.value.setOptions(nextOptions) }) // Re-init map when mapId or colorScheme changes (e.g. user toggles color mode // with `mapIds` set or with cloud-based styling on a single mapId). Both are @@ -458,8 +496,9 @@ onMounted(() => { emits('ready', exposed) }) watch(() => options.value.zoom, (zoom) => { - if (map.value && zoom != null) + if (map.value && zoom != null) { map.value.setZoom(zoom) + } }) // Clear centerOverride when the controlled center prop changes so external // updates take effect (otherwise centerOverride, written from the user's @@ -467,7 +506,7 @@ onMounted(() => { watch([() => getCenterWatchKey(props.center), () => getCenterWatchKey(props.mapOptions?.center)], () => { centerOverride.value = undefined }) - watch([() => getCenterWatchKey(options.value.center), isMapReady, map], async () => { + watch([controlledCenterKey, isMapReady, map], async () => { if (!map.value) { return } diff --git a/playground/pages/third-parties/google-maps/sfcs.vue b/playground/pages/third-parties/google-maps/sfcs.vue index c3617dac..22628779 100644 --- a/playground/pages/third-parties/google-maps/sfcs.vue +++ b/playground/pages/third-parties/google-maps/sfcs.vue @@ -92,9 +92,9 @@ whenever(() => googleMapsRef.value?.googleMaps, (googleMaps) => { api-key="AIzaSyAOEIQ_xOdLx2dNwnFMzyJoswwvPCTcGzU" :width="1280" :height="720" - :zoom="zoom" :map-options="{ center: { lat: -34.397, lng: 150.644 }, + zoom, mapId: 'DEMO_MAP_ID', }" > diff --git a/test/unit/google-maps-regressions.test.ts b/test/unit/google-maps-regressions.test.ts index b0af3b00..cb479f7d 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -337,6 +337,39 @@ describe('google Maps Regressions', () => { map.setOptions(rest) } + function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') + return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null + } + + function isSameOptionValue(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) + return true + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((value, index) => isSameOptionValue(value, b[index])) + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + return aKeys.length === bKeys.length + && aKeys.every(key => Object.hasOwn(b, key) && isSameOptionValue(a[key], b[key])) + } + return false + } + + function applyOptionsWithStableGuard( + map: ReturnType, + options: Record, + previousOptions: Record, + ) { + const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...next } = options + const { center: _previousCenter, zoom: _previousZoom, mapId: _previousMapId, colorScheme: _previousColorScheme, ...previous } = previousOptions + if (isSameOptionValue(next, previous)) + return + map.setOptions(next) + } + 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' } @@ -402,6 +435,35 @@ describe('google Maps Regressions', () => { expect(map.setCenter).not.toHaveBeenCalled() expect(map.setZoom).not.toHaveBeenCalled() }) + + it('fixed behavior: identical inline options do not call setOptions on unrelated re-renders', () => { + const map = createMockMap() + const baseOptions = { + center: { lat: 40, lng: -74 }, + zoom: 12, + mapId: 'abc', + disableDefaultUI: true, + restriction: { strictBounds: false }, + } + + for (let i = 0; i < 3; i++) { + applyOptionsWithStableGuard(map, { ...baseOptions, restriction: { strictBounds: false } }, baseOptions) + } + + expect(map.setOptions).not.toHaveBeenCalled() + expect(map.setCenter).not.toHaveBeenCalled() + expect(map.setZoom).not.toHaveBeenCalled() + }) + + it('fixed behavior: real non-position option changes still call setOptions', () => { + const map = createMockMap() + const previousOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true } + const nextOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: false } + + applyOptionsWithStableGuard(map, nextOptions, previousOptions) + + expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: false }) + }) }) describe('center watcher should skip setCenter when lat/lng unchanged', () => { @@ -476,6 +538,40 @@ describe('google Maps Regressions', () => { expect(getCenterWatchKey(firstRender.center)).toBe(getCenterWatchKey(secondRender.center)) }) + it('keeps the controlled center watch key stable across unrelated inline option re-renders', () => { + function getCenterWatchKey(center: any) { + if (!center) + return undefined + const lat = typeof center.lat === 'function' ? center.lat() : center.lat + const lng = typeof center.lng === 'function' ? center.lng() : center.lng + return `latlng:${lat},${lng}` + } + + function getControlledCenterKey(props: { center?: any, mapOptions?: { center?: any } }, centerOverride?: any) { + return getCenterWatchKey(centerOverride) + || getCenterWatchKey(props.mapOptions?.center) + || getCenterWatchKey(props.center) + } + + const firstRender = { + mapOptions: { + center: { lat: -34.397, lng: 150.644 }, + zoom: 8, + }, + } + const secondRenderAfterRectangleToggle = { + mapOptions: { + center: { lat: -34.397, lng: 150.644 }, + zoom: 8, + }, + } + + expect(firstRender.mapOptions).not.toBe(secondRenderAfterRectangleToggle.mapOptions) + expect(firstRender.mapOptions.center).not.toBe(secondRenderAfterRectangleToggle.mapOptions.center) + expect(getControlledCenterKey(firstRender)) + .toBe(getControlledCenterKey(secondRenderAfterRectangleToggle)) + }) + it('changes the center watch key when coordinates actually change', () => { function getCenterWatchKey(center: any) { const lat = typeof center.lat === 'function' ? center.lat() : center.lat