Skip to content

Commit 65c3b56

Browse files
authored
fix(google-maps): avoid recentering on inline options (#747)
1 parent b0ca869 commit 65c3b56

3 files changed

Lines changed: 145 additions & 10 deletions

File tree

packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,47 @@ function getCenterWatchKey(center: ScriptGoogleMapsCenter): string | undefined {
274274
return undefined
275275
}
276276
277+
const controlledCenterKey = computed(() => {
278+
return getCenterWatchKey(centerOverride.value)
279+
|| getCenterWatchKey(props.mapOptions?.center)
280+
|| getCenterWatchKey(props.center)
281+
})
282+
283+
function getReactiveMapOptions(options: google.maps.MapOptions): google.maps.MapOptions {
284+
// Exclude center and zoom — they have dedicated watchers that avoid
285+
// resetting user interactions (pan/zoom) on unrelated re-renders.
286+
// Exclude mapId and colorScheme — Google Maps treats these as init-only;
287+
// changes are handled by the dedicated re-init watcher below.
288+
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options
289+
return rest
290+
}
291+
292+
function isPlainObject(value: unknown): value is Record<string, unknown> {
293+
if (!value || typeof value !== 'object')
294+
return false
295+
const proto = Object.getPrototypeOf(value)
296+
return proto === Object.prototype || proto === null
297+
}
298+
299+
function isSameOptionValue(a: unknown, b: unknown): boolean {
300+
if (Object.is(a, b))
301+
return true
302+
if (Array.isArray(a) && Array.isArray(b)) {
303+
return a.length === b.length && a.every((value, index) => isSameOptionValue(value, b[index]))
304+
}
305+
if (isPlainObject(a) && isPlainObject(b)) {
306+
const aKeys = Object.keys(a)
307+
const bKeys = Object.keys(b)
308+
return aKeys.length === bKeys.length
309+
&& aKeys.every(key => Object.hasOwn(b, key) && isSameOptionValue(a[key], b[key]))
310+
}
311+
return false
312+
}
313+
314+
function isSameMapOptions(a: google.maps.MapOptions, b: google.maps.MapOptions): boolean {
315+
return isSameOptionValue(toRaw(a), toRaw(b))
316+
}
317+
277318
const queryToLatLngCache = new Map<string, google.maps.LatLng | google.maps.LatLngLiteral>()
278319
279320
async function resolveQueryToLatLng(query: string) {
@@ -401,15 +442,12 @@ onMounted(() => {
401442
emits('error')
402443
}
403444
})
404-
watch(options, () => {
445+
watch(() => getReactiveMapOptions(options.value), (nextOptions, previousOptions) => {
405446
if (!map.value)
406447
return
407-
// Exclude center and zoom — they have dedicated watchers that avoid
408-
// resetting user interactions (pan/zoom) on unrelated re-renders.
409-
// Exclude mapId and colorScheme — Google Maps treats these as init-only;
410-
// changes are handled by the dedicated re-init watcher below.
411-
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options.value
412-
map.value.setOptions(rest)
448+
if (isSameMapOptions(nextOptions, previousOptions))
449+
return
450+
map.value.setOptions(nextOptions)
413451
})
414452
// Re-init map when mapId or colorScheme changes (e.g. user toggles color mode
415453
// with `mapIds` set or with cloud-based styling on a single mapId). Both are
@@ -458,16 +496,17 @@ onMounted(() => {
458496
emits('ready', exposed)
459497
})
460498
watch(() => options.value.zoom, (zoom) => {
461-
if (map.value && zoom != null)
499+
if (map.value && zoom != null) {
462500
map.value.setZoom(zoom)
501+
}
463502
})
464503
// Clear centerOverride when the controlled center prop changes so external
465504
// updates take effect (otherwise centerOverride, written from the user's
466505
// pan during re-init, would permanently win over future prop updates).
467506
watch([() => getCenterWatchKey(props.center), () => getCenterWatchKey(props.mapOptions?.center)], () => {
468507
centerOverride.value = undefined
469508
})
470-
watch([() => getCenterWatchKey(options.value.center), isMapReady, map], async () => {
509+
watch([controlledCenterKey, isMapReady, map], async () => {
471510
if (!map.value) {
472511
return
473512
}

playground/pages/third-parties/google-maps/sfcs.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ whenever(() => googleMapsRef.value?.googleMaps, (googleMaps) => {
9292
api-key="AIzaSyAOEIQ_xOdLx2dNwnFMzyJoswwvPCTcGzU"
9393
:width="1280"
9494
:height="720"
95-
:zoom="zoom"
9695
:map-options="{
9796
center: { lat: -34.397, lng: 150.644 },
97+
zoom,
9898
mapId: 'DEMO_MAP_ID',
9999
}"
100100
>

test/unit/google-maps-regressions.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,39 @@ describe('google Maps Regressions', () => {
337337
map.setOptions(rest)
338338
}
339339

340+
function isPlainObject(value: unknown): value is Record<string, unknown> {
341+
if (!value || typeof value !== 'object')
342+
return false
343+
const proto = Object.getPrototypeOf(value)
344+
return proto === Object.prototype || proto === null
345+
}
346+
347+
function isSameOptionValue(a: unknown, b: unknown): boolean {
348+
if (Object.is(a, b))
349+
return true
350+
if (Array.isArray(a) && Array.isArray(b))
351+
return a.length === b.length && a.every((value, index) => isSameOptionValue(value, b[index]))
352+
if (isPlainObject(a) && isPlainObject(b)) {
353+
const aKeys = Object.keys(a)
354+
const bKeys = Object.keys(b)
355+
return aKeys.length === bKeys.length
356+
&& aKeys.every(key => Object.hasOwn(b, key) && isSameOptionValue(a[key], b[key]))
357+
}
358+
return false
359+
}
360+
361+
function applyOptionsWithStableGuard(
362+
map: ReturnType<typeof createMockMap>,
363+
options: Record<string, any>,
364+
previousOptions: Record<string, any>,
365+
) {
366+
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...next } = options
367+
const { center: _previousCenter, zoom: _previousZoom, mapId: _previousMapId, colorScheme: _previousColorScheme, ...previous } = previousOptions
368+
if (isSameOptionValue(next, previous))
369+
return
370+
map.setOptions(next)
371+
}
372+
340373
it('old behavior: setOptions resets zoom and center on unrelated re-render', () => {
341374
const map = createMockMap()
342375
const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc' }
@@ -402,6 +435,35 @@ describe('google Maps Regressions', () => {
402435
expect(map.setCenter).not.toHaveBeenCalled()
403436
expect(map.setZoom).not.toHaveBeenCalled()
404437
})
438+
439+
it('fixed behavior: identical inline options do not call setOptions on unrelated re-renders', () => {
440+
const map = createMockMap()
441+
const baseOptions = {
442+
center: { lat: 40, lng: -74 },
443+
zoom: 12,
444+
mapId: 'abc',
445+
disableDefaultUI: true,
446+
restriction: { strictBounds: false },
447+
}
448+
449+
for (let i = 0; i < 3; i++) {
450+
applyOptionsWithStableGuard(map, { ...baseOptions, restriction: { strictBounds: false } }, baseOptions)
451+
}
452+
453+
expect(map.setOptions).not.toHaveBeenCalled()
454+
expect(map.setCenter).not.toHaveBeenCalled()
455+
expect(map.setZoom).not.toHaveBeenCalled()
456+
})
457+
458+
it('fixed behavior: real non-position option changes still call setOptions', () => {
459+
const map = createMockMap()
460+
const previousOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true }
461+
const nextOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: false }
462+
463+
applyOptionsWithStableGuard(map, nextOptions, previousOptions)
464+
465+
expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: false })
466+
})
405467
})
406468

407469
describe('center watcher should skip setCenter when lat/lng unchanged', () => {
@@ -476,6 +538,40 @@ describe('google Maps Regressions', () => {
476538
expect(getCenterWatchKey(firstRender.center)).toBe(getCenterWatchKey(secondRender.center))
477539
})
478540

541+
it('keeps the controlled center watch key stable across unrelated inline option re-renders', () => {
542+
function getCenterWatchKey(center: any) {
543+
if (!center)
544+
return undefined
545+
const lat = typeof center.lat === 'function' ? center.lat() : center.lat
546+
const lng = typeof center.lng === 'function' ? center.lng() : center.lng
547+
return `latlng:${lat},${lng}`
548+
}
549+
550+
function getControlledCenterKey(props: { center?: any, mapOptions?: { center?: any } }, centerOverride?: any) {
551+
return getCenterWatchKey(centerOverride)
552+
|| getCenterWatchKey(props.mapOptions?.center)
553+
|| getCenterWatchKey(props.center)
554+
}
555+
556+
const firstRender = {
557+
mapOptions: {
558+
center: { lat: -34.397, lng: 150.644 },
559+
zoom: 8,
560+
},
561+
}
562+
const secondRenderAfterRectangleToggle = {
563+
mapOptions: {
564+
center: { lat: -34.397, lng: 150.644 },
565+
zoom: 8,
566+
},
567+
}
568+
569+
expect(firstRender.mapOptions).not.toBe(secondRenderAfterRectangleToggle.mapOptions)
570+
expect(firstRender.mapOptions.center).not.toBe(secondRenderAfterRectangleToggle.mapOptions.center)
571+
expect(getControlledCenterKey(firstRender))
572+
.toBe(getControlledCenterKey(secondRenderAfterRectangleToggle))
573+
})
574+
479575
it('changes the center watch key when coordinates actually change', () => {
480576
function getCenterWatchKey(center: any) {
481577
const lat = typeof center.lat === 'function' ? center.lat() : center.lat

0 commit comments

Comments
 (0)