Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/content/scripts/google-maps/1.guides/2.map-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ If you set up a single Map ID in Google Cloud Console with both Light and Dark c
```

::callout{color="amber"}
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.
Google Maps treats both `mapId` and `colorScheme` as init-only options. Toggling color mode tears down and re-creates the basic `Map` instance, since 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.

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.
::

This auto-detects `@nuxtjs/color-mode` if installed. You can also control it manually with the `colorMode` prop:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,17 @@ if (import.meta.dev) {
const rootEl = useTemplateRef<HTMLElement>('rootEl')
const mapEl = useTemplateRef<HTMLElement>('mapEl')

// Holds a runtime-derived center that wins over props in `defu`. Set when:
// 1. A location query (e.g. "lat,lng" string) is asynchronously resolved to LatLng
// 2. The map is re-initialised on color-mode change (preserves user pan)
// Cleared whenever the caller updates `props.center` or `props.mapOptions.center`,
// so external center updates always propagate after the first re-init.
const centerOverride = ref()
// Track requested center so we can detect external changes and clear the override.
const requestedCenter = computed(() => props.mapOptions?.center ?? props.center)
watch(requestedCenter, () => {
centerOverride.value = undefined
})

const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })
const { load, status, onLoaded } = useScriptGoogleMaps({
Expand Down Expand Up @@ -409,6 +419,15 @@ onMounted(() => {
return
const center = map.value.getCenter()
const zoom = map.value.getZoom()
// Persist the user's panned position into `centerOverride` *before* tearing
// down. Without this, `options.value.center` recomputes (defu returns a new
// object even when values are unchanged) and the center watcher fires when
// `map.value` is reassigned β€” calling `setCenter(propsInitialCenter)` and
// discarding the user's pan. centerOverride wins over props in `defu`, so
// the recomputed center matches the new map's actual center β†’ comparison
// guard skips the redundant setCenter.
if (center)
centerOverride.value = { lat: center.lat(), lng: center.lng() }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
map.value.unbindAll()
map.value = undefined
slotMounted.value = false
Expand All @@ -428,6 +447,10 @@ onMounted(() => {
}
map.value = new mapsApi.value.Map(mapEl.value, _options)
slotMounted.value = true
// Re-emit `ready` so consumers can re-attach imperative state (e.g. pins
// created via `map` ref outside of declarative children, which don't
// automatically remount).
emits('ready', exposed)
})
watch(() => options.value.zoom, (zoom) => {
if (map.value && zoom != null)
Expand Down
117 changes: 117 additions & 0 deletions test/unit/google-maps-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,5 +620,122 @@ describe('google Maps Regressions', () => {
{ mapId: 'SAME_ID', scheme: 'DARK' },
)).toBe(true)
})

it('persists the user-panned center via centerOverride and the watcher guard skips setCenter', () => {
// Regression: after the re-init watcher captured zoom/center, it created
// the new Map with the captured center, but the standalone center watcher
// (which depends on `options.value.center` and `map`) re-fired when
// `map.value` was reassigned. Because `options.value.center` still pointed
// at the prop-defined initial center, the watcher called
// setCenter(initialCenter), discarding the user's pan.
// Fix: write the captured center to `centerOverride` before teardown so
// that `options.value.center` reflects the user's pan; the watcher's
// lat/lng comparison guard then short-circuits.

// Simulate the production center watcher's runtime path.
function applyCenterWatcher(
map: ReturnType<typeof createMockMap>,
center: { lat: number, lng: number } | { lat: () => number, lng: () => number },
) {
if (!map)
return
const current = map.getCenter()
if (current) {
const newLat = typeof (center as any).lat === 'function' ? (center as any).lat() : (center as any).lat
const newLng = typeof (center as any).lng === 'function' ? (center as any).lng() : (center as any).lng
if (current.lat() === newLat && current.lng() === newLng)
return
}
map.setCenter(center)
}

const newMap = createMockMap()
// User panned to (50, 100); the new map instance is built with the captured center.
newMap.getCenter.mockReturnValue({ lat: () => 50, lng: () => 100 })

const propsCenter = { lat: 0, lng: 0 }
const centerOverride = { lat: 50, lng: 100 } // captured before teardown
// options.value.center after re-init: defu hands centerOverride first.
const optionsCenter = centerOverride || propsCenter

// The watcher fires when `map` is reassigned; guard must short-circuit.
applyCenterWatcher(newMap, optionsCenter)
expect(newMap.setCenter).not.toHaveBeenCalled()
})

it('clears centerOverride when props.center changes so external updates still propagate', () => {
// Without the clear watcher, `centerOverride` would stay at the captured
// pan and `props.center` updates after the first re-init would never reach
// the map (centerOverride wins in defu precedence, so options.value.center
// never changes, so the center watcher never fires).
const map = createMockMap()
// Map is currently at the user's panned position
map.getCenter.mockReturnValue({ lat: () => 50, lng: () => 100 })

// Simulate: caller updates props.center β†’ watcher fires and clears centerOverride
let centerOverride: { lat: number, lng: number } | undefined = { lat: 50, lng: 100 }
const newPropCenter = { lat: 10, lng: 20 }
let propsCenter = { lat: 0, lng: 0 }

// Watcher on requestedCenter fires before options recomputes:
propsCenter = newPropCenter
centerOverride = undefined // requestedCenter watcher clears the override

// options.value.center recomputes via defu({ center: centerOverride }, ..., { center: propsCenter })
const optionsCenter = centerOverride || propsCenter

// options.value.center now reflects the new prop, not the stale override.
expect(optionsCenter).toEqual(newPropCenter)
expect(centerOverride).toBeUndefined()

// Center watcher sees the new value differs from map.getCenter() and applies it.
function applyCenterWatcher(
m: ReturnType<typeof createMockMap>,
c: { lat: number, lng: number },
) {
const current = m.getCenter()
if (current && current.lat() === c.lat && current.lng() === c.lng)
return
m.setCenter(c)
}
applyCenterWatcher(map, optionsCenter)
expect(map.setCenter).toHaveBeenCalledWith(newPropCenter)
})

it('passes captured zoom and center to the new Map instance', () => {
// The re-init watcher reads the live map state before teardown and uses
// the captured values when constructing the new Map. Verifies that the
// _options object spread does not let an undefined captured zoom fall
// back to a stale options value, and that the literal coordinate object
// is the right shape for Google Maps.
const map = createMockMap()
map.getCenter.mockReturnValue({ lat: () => 50, lng: () => 100 })
map.getZoom.mockReturnValue(10)

const optionsValue = { zoom: 5, center: { lat: 0, lng: 0 }, mapId: 'a', colorScheme: 'DARK' }

const center = map.getCenter()
const zoom = map.getZoom()
const _options = {
...optionsValue,
center: center ? { lat: center.lat(), lng: center.lng() } : optionsValue.center,
zoom: zoom ?? optionsValue.zoom,
}

expect(_options.zoom).toBe(10)
expect(_options.center).toEqual({ lat: 50, lng: 100 })
// mapId/colorScheme from the new options pass through (init-only, but the
// new instance can accept them).
expect(_options.mapId).toBe('a')
expect(_options.colorScheme).toBe('DARK')
})

it('preserves zoom of 0 (a valid Google Maps zoom level)', () => {
// `zoom ?? options.value.zoom` correctly handles 0 vs undefined.
const map = createMockMap()
map.getZoom.mockReturnValue(0)
const zoom = map.getZoom()
expect(zoom ?? 15).toBe(0)
})
})
})
Loading