Skip to content

Commit ceecf57

Browse files
authored
fix(google-maps): preserve user pan and re-emit ready on color-mode re-init (#731)
1 parent 2cebce3 commit ceecf57

3 files changed

Lines changed: 126 additions & 1 deletion

File tree

‎docs/content/scripts/google-maps/1.guides/2.map-styling.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ If you set up a single Map ID in Google Cloud Console with both Light and Dark c
9797
```
9898

9999
::callout{color="amber"}
100-
Google Maps treats both `mapId` and `colorScheme` as init-only options. Toggling color mode tears down and re-creates the basic `Map` instance (preserving the user's pan/zoom). Child components (markers, info windows, overlays) are remounted against the new map automatically.
100+
Google Maps treats both `mapId` and `colorScheme` as init-only options. Toggling color mode tears down and re-creates the basic `Map` instance; Google does not support changing these without re-rendering. The component preserves the user's pan/zoom and remounts child components (markers, info windows, overlays) against the new map automatically.
101+
102+
If you create resources imperatively from the exposed `map` ref (rather than via child components), listen for the `@ready` event; it re-fires after every re-init so you can re-attach them to the new map instance.
101103
::
102104

103105
This auto-detects `@nuxtjs/color-mode` if installed. You can also control it manually with the `colorMode` prop:

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,15 @@ onMounted(() => {
409409
return
410410
const center =map.value.getCenter()
411411
const zoom =map.value.getZoom()
412+
// Persist the user's panned position into `centerOverride` *before* tearing
413+
// down. Without this, `options.value.center` recomputes (defu returns a new
414+
// object even when values are unchanged) and the center watcher fires when
415+
// `map.value` is reassigned, calling `setCenter(propsInitialCenter)` and
416+
// discarding the user's pan. centerOverride wins over props in `defu`, so
417+
// the recomputed center matches the new map's actual center: the comparison
418+
// guard skips the redundant setCenter.
419+
if (center)
420+
centerOverride.value= { lat: center.lat(), lng: center.lng() }
412421
map.value.unbindAll()
413422
map.value=undefined
414423
slotMounted.value=false
@@ -428,11 +437,21 @@ onMounted(() => {
428437
}
429438
map.value=newmapsApi.value.Map(mapEl.value, _options)
430439
slotMounted.value=true
440+
// Re-emit `ready` so consumers can re-attach imperative state (e.g. pins
441+
// created via `map` ref outside of declarative children, which don't
442+
// automatically remount).
443+
emits('ready', exposed)
431444
})
432445
watch(() =>options.value.zoom, (zoom) => {
433446
if (map.value&&zoom!=null)
434447
map.value.setZoom(zoom)
435448
})
449+
// Clear centerOverride when the controlled center prop changes so external
450+
// updates take effect (otherwise centerOverride, written from the user's
451+
// pan during re-init, would permanently win over future prop updates).
452+
watch([() =>props.center, () =>props.mapOptions?.center], () => {
453+
centerOverride.value=undefined
454+
})
436455
watch([() =>options.value.center, isMapReady, map], async (next) => {
437456
if (!map.value) {
438457
return

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,5 +620,109 @@ describe('google Maps Regressions', () => {
620620
{mapId: 'SAME_ID',scheme: 'DARK'},
621621
)).toBe(true)
622622
})
623+
624+
it('persists the user-panned center via centerOverride before tearing down',()=>{
625+
// Regression: after the re-init watcher captured zoom/center, it created
626+
// the new Map with the captured center, but the standalone center
627+
// watcher (which depends on `options.value.center` and `map`) re-fired
628+
// when `map.value` was reassigned. Because `options.value.center` still
629+
// pointed at the *prop-defined* initial center, the watcher then called
630+
// setCenter(initialCenter), discarding the user's pan.
631+
// Fix: write the captured center to `centerOverride` before teardown so
632+
// that `options.value.center` reflects the user's pan; the watcher's
633+
// lat/lng comparison guard then short-circuits.
634+
constmap=createMockMap()
635+
// User panned to (50, 100)
636+
map.getCenter.mockReturnValue({lat: ()=>50,lng: ()=>100})
637+
638+
// Simulate: capture center → write to centerOverride
639+
constcaptured=map.getCenter()
640+
constcenterOverride={lat: captured.lat(),lng: captured.lng()}
641+
642+
// Simulate the options computed after centerOverride is set:
643+
// `defu({ center: centerOverride, ... }, props.mapOptions, { center: props.center }, ...)`
644+
// centerOverride wins.
645+
constpropsCenter={lat: 0,lng: 0}// initial prop center
646+
constoptionsCenter=centerOverride||propsCenter
647+
648+
// The center watcher comparison guard now sees:
649+
// current = newMap.getCenter() = { lat: 50, lng: 100 }
650+
// new = options.value.center = { lat: 50, lng: 100 }
651+
// → matches → setCenter is skipped.
652+
expect(optionsCenter.lat).toBe(50)
653+
expect(optionsCenter.lng).toBe(100)
654+
// Without the fix, optionsCenter would have been the prop's initial value:
655+
expect(optionsCenter).not.toEqual(propsCenter)
656+
})
657+
658+
it('passes captured zoom and center to the new Map instance',()=>{
659+
// The re-init watcher reads the live map state before teardown and uses
660+
// the captured values when constructing the new Map. Verifies that the
661+
// _options object spread does not let an undefined captured zoom fall
662+
// back to a stale options value, and that the literal coordinate object
663+
// is the right shape for Google Maps.
664+
constmap=createMockMap()
665+
map.getCenter.mockReturnValue({lat: ()=>50,lng: ()=>100})
666+
map.getZoom.mockReturnValue(10)
667+
668+
constoptionsValue={zoom: 5,center: {lat: 0,lng: 0},mapId: 'a',colorScheme: 'DARK'}
669+
670+
constcenter=map.getCenter()
671+
constzoom=map.getZoom()
672+
const_options={
673+
...optionsValue,
674+
center: center ? {lat: center.lat(),lng: center.lng()} : optionsValue.center,
675+
zoom: zoom??optionsValue.zoom,
676+
}
677+
678+
expect(_options.zoom).toBe(10)
679+
expect(_options.center).toEqual({lat: 50,lng: 100})
680+
// mapId/colorScheme from the new options pass through (init-only, but the
681+
// new instance can accept them).
682+
expect(_options.mapId).toBe('a')
683+
expect(_options.colorScheme).toBe('DARK')
684+
})
685+
686+
it('preserves zoom of 0 (a valid Google Maps zoom level)',()=>{
687+
// `zoom ?? options.value.zoom` correctly handles 0 vs undefined.
688+
constmap=createMockMap()
689+
map.getZoom.mockReturnValue(0)
690+
constzoom=map.getZoom()
691+
expect(zoom??15).toBe(0)
692+
})
693+
694+
it('re-emits ready after map re-init so imperative bindings can re-attach',()=>{
695+
// Consumers that attach state via the exposed `map` ref (rather than
696+
// declarative children) need a signal to re-bind after the Map instance
697+
// is recreated on color-mode change.
698+
constemit=vi.fn()
699+
constexposed={map: {value: createMockMap()}}asany
700+
701+
// initial ready
702+
emit('ready',exposed)
703+
704+
// simulate re-init with a new map instance
705+
exposed.map.value=createMockMap()
706+
emit('ready',exposed)
707+
708+
expect(emit).toHaveBeenCalledTimes(2)
709+
expect(emit).toHaveBeenNthCalledWith(2,'ready',exposed)
710+
})
711+
712+
it('clears centerOverride when controlled center prop changes',()=>{
713+
// Regression: writing centerOverride from the user's pan would block
714+
// subsequent prop-driven center updates because centerOverride wins
715+
// over props in defu. Clearing it on prop change restores priority.
716+
constcenterOverride: {value: {lat: number,lng: number}|undefined}={value: {lat: 50,lng: 100}}
717+
718+
// Simulate the watcher firing on prop change
719+
functiononPropCenterChange(){
720+
centerOverride.value=undefined
721+
}
722+
723+
onPropCenterChange()
724+
725+
expect(centerOverride.value).toBeUndefined()
726+
})
623727
})
624728
})

0 commit comments

Comments
 (0)