Skip to content
Merged
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; 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 @@ -409,6 +409,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: the 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,11 +437,21 @@ 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)
map.value.setZoom(zoom)
})
// Clear centerOverride when the controlled center prop changes so external
// updates take effect (otherwise centerOverride, written from the user's
// pan during re-init, would permanently win over future prop updates).
watch([() => props.center, () => props.mapOptions?.center], () => {
centerOverride.value = undefined
})
watch([() => options.value.center, isMapReady, map], async (next) => {
if (!map.value) {
return
Expand Down
104 changes: 104 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,109 @@ describe('google Maps Regressions', () => {
{ mapId: 'SAME_ID', scheme: 'DARK' },
)).toBe(true)
})

it('persists the user-panned center via centerOverride before tearing down', () => {
// 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 then 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.
const map = createMockMap()
// User panned to (50, 100)
map.getCenter.mockReturnValue({ lat: () => 50, lng: () => 100 })

// Simulate: capture center β†’ write to centerOverride
const captured = map.getCenter()
const centerOverride = { lat: captured.lat(), lng: captured.lng() }

// Simulate the options computed after centerOverride is set:
// `defu({ center: centerOverride, ... }, props.mapOptions, { center: props.center }, ...)`
// centerOverride wins.
const propsCenter = { lat: 0, lng: 0 } // initial prop center
const optionsCenter = centerOverride || propsCenter

// The center watcher comparison guard now sees:
// current = newMap.getCenter() = { lat: 50, lng: 100 }
// new = options.value.center = { lat: 50, lng: 100 }
// β†’ matches β†’ setCenter is skipped.
expect(optionsCenter.lat).toBe(50)
expect(optionsCenter.lng).toBe(100)
// Without the fix, optionsCenter would have been the prop's initial value:
expect(optionsCenter).not.toEqual(propsCenter)
})

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)
})

it('re-emits ready after map re-init so imperative bindings can re-attach', () => {
// Consumers that attach state via the exposed `map` ref (rather than
// declarative children) need a signal to re-bind after the Map instance
// is recreated on color-mode change.
const emit = vi.fn()
const exposed = { map: { value: createMockMap() } } as any

// initial ready
emit('ready', exposed)

// simulate re-init with a new map instance
exposed.map.value = createMockMap()
emit('ready', exposed)

expect(emit).toHaveBeenCalledTimes(2)
expect(emit).toHaveBeenNthCalledWith(2, 'ready', exposed)
})

it('clears centerOverride when controlled center prop changes', () => {
// Regression: writing centerOverride from the user's pan would block
// subsequent prop-driven center updates because centerOverride wins
// over props in defu. Clearing it on prop change restores priority.
const centerOverride: { value: { lat: number, lng: number } | undefined } = { value: { lat: 50, lng: 100 } }

// Simulate the watcher firing on prop change
function onPropCenterChange() {
centerOverride.value = undefined
}

onPropCenterChange()

expect(centerOverride.value).toBeUndefined()
})
})
})
Loading