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
returnundefined
275275
}
276276
277+
const controlledCenterKey =computed(() => {
278+
returngetCenterWatchKey(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+
returnrest
290+
}
291+
292+
function isPlainObject(value:unknown):valueisRecord<string, unknown> {
293+
if (!value||typeofvalue!=='object')
294+
returnfalse
295+
const proto =Object.getPrototypeOf(value)
296+
returnproto===Object.prototype||proto===null
297+
}
298+
299+
function isSameOptionValue(a:unknown, b:unknown):boolean {
300+
if (Object.is(a, b))
301+
returntrue
302+
if (Array.isArray(a) &&Array.isArray(b)) {
303+
returna.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+
returnaKeys.length===bKeys.length
309+
&&aKeys.every(key=>Object.hasOwn(b, key) &&isSameOptionValue(a[key], b[key]))
310+
}
311+
returnfalse
312+
}
313+
314+
function isSameMapOptions(a:google.maps.MapOptions, b:google.maps.MapOptions):boolean {
315+
returnisSameOptionValue(toRaw(a), toRaw(b))
316+
}
317+
277318
const queryToLatLngCache =newMap<string, google.maps.LatLng|google.maps.LatLngLiteral>()
278319
279320
asyncfunction 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+
functionisPlainObject(value: unknown): value is Record<string,unknown>{
341+
if(!value||typeofvalue!=='object')
342+
returnfalse
343+
constproto=Object.getPrototypeOf(value)
344+
returnproto===Object.prototype||proto===null
345+
}
346+
347+
functionisSameOptionValue(a: unknown,b: unknown): boolean{
348+
if(Object.is(a,b))
349+
returntrue
350+
if(Array.isArray(a)&&Array.isArray(b))
351+
returna.length===b.length&&a.every((value,index)=>isSameOptionValue(value,b[index]))
352+
if(isPlainObject(a)&&isPlainObject(b)){
353+
constaKeys=Object.keys(a)
354+
constbKeys=Object.keys(b)
355+
returnaKeys.length===bKeys.length
356+
&&aKeys.every(key=>Object.hasOwn(b,key)&&isSameOptionValue(a[key],b[key]))
357+
}
358+
returnfalse
359+
}
360+
361+
functionapplyOptionsWithStableGuard(
362+
map: ReturnType<typeofcreateMockMap>,
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
constmap=createMockMap()
342375
constoptions={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+
constmap=createMockMap()
441+
constbaseOptions={
442+
center: {lat: 40,lng: -74},
443+
zoom: 12,
444+
mapId: 'abc',
445+
disableDefaultUI: true,
446+
restriction: {strictBounds: false},
447+
}
448+
449+
for(leti=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+
constmap=createMockMap()
460+
constpreviousOptions={center: {lat: 40,lng: -74},zoom: 12,mapId: 'abc',disableDefaultUI: true}
461+
constnextOptions={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+
functiongetCenterWatchKey(center: any){
543+
if(!center)
544+
returnundefined
545+
constlat=typeofcenter.lat==='function' ? center.lat() : center.lat
546+
constlng=typeofcenter.lng==='function' ? center.lng() : center.lng
547+
return`latlng:${lat},${lng}`
548+
}
549+
550+
functiongetControlledCenterKey(props: {center?: any,mapOptions?: {center?: any}},centerOverride?: any){
551+
returngetCenterWatchKey(centerOverride)
552+
||getCenterWatchKey(props.mapOptions?.center)
553+
||getCenterWatchKey(props.center)
554+
}
555+
556+
constfirstRender={
557+
mapOptions: {
558+
center: {lat: -34.397,lng: 150.644},
559+
zoom: 8,
560+
},
561+
}
562+
constsecondRenderAfterRectangleToggle={
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
functiongetCenterWatchKey(center: any){
481577
constlat=typeofcenter.lat==='function' ? center.lat() : center.lat

0 commit comments

Comments
 (0)