diff --git a/docs/content/scripts/google-maps/1.guides/2.map-styling.md b/docs/content/scripts/google-maps/1.guides/2.map-styling.md index 1611b57cb..4e58cde34 100644 --- a/docs/content/scripts/google-maps/1.guides/2.map-styling.md +++ b/docs/content/scripts/google-maps/1.guides/2.map-styling.md @@ -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: diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index d6902a707..e70be719a 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -219,7 +219,17 @@ if (import.meta.dev) { const rootEl = useTemplateRef('rootEl') const mapEl = useTemplateRef('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({ @@ -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() } map.value.unbindAll() map.value = undefined slotMounted.value = false @@ -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) diff --git a/test/unit/google-maps-regressions.test.ts b/test/unit/google-maps-regressions.test.ts index a93f497c0..ee5822070 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -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, + 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, + 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) + }) }) })