From f804d19b470c5b0a5fbddc47e58675970d6f05de Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:47:52 +1000 Subject: [PATCH 1/2] fix(google-maps): persist user pan and re-emit ready on color-mode re-init Two regressions surfaced after the cloud-styled mapId fix (#727): 1. Toggling color mode discarded the user's panned position. The recreate watcher captured the live center before teardown, but the standalone center watcher then re-fired against the new map instance and called `setCenter(propsInitialCenter)`. Persist the captured center into `centerOverride` so the recomputed `options.value.center` matches the new map's actual center; the watcher's lat/lng comparison guard then short-circuits. 2. Imperative resources created off the exposed `map` ref (e.g. pins added outside declarative children) silently disappeared after re-init with no signal to re-attach. Re-emit `ready` after constructing the new Map instance so consumers can re-bind, and document the contract in the map-styling guide. --- .../google-maps/1.guides/2.map-styling.md | 4 +- .../GoogleMaps/ScriptGoogleMaps.vue | 13 ++++ test/unit/google-maps-regressions.test.ts | 70 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) 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..3ce15b0b3 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -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 → 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 +437,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..0d16f3f93 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -620,5 +620,75 @@ 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) + }) }) }) From 131de29bd38bde2f08e3c4f1c3581fa371126ff2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:59:39 +1000 Subject: [PATCH 2/2] fix(google-maps): clear centerOverride when caller changes requested center Persisting the user-panned center into `centerOverride` made the override sticky in `defu` precedence: any later `props.center` or `props.mapOptions.center` updates would silently fail to propagate because `options.value.center` kept returning the stored override. Add a watcher on the requested center that clears the override whenever the caller changes it. Strengthen the regression test to assert the watcher's setCenter call is actually skipped on re-init, and add a new test covering the post-reinit external center update path. --- .../GoogleMaps/ScriptGoogleMaps.vue | 10 +++ test/unit/google-maps-regressions.test.ts | 89 ++++++++++++++----- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 3ce15b0b3..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({ diff --git a/test/unit/google-maps-regressions.test.ts b/test/unit/google-maps-regressions.test.ts index 0d16f3f93..ee5822070 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -621,38 +621,85 @@ describe('google Maps Regressions', () => { )).toBe(true) }) - it('persists the user-panned center via centerOverride before tearing down', () => { + 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 then called + // 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() - // User panned to (50, 100) + // Map is currently at the user's panned position 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: 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 - // 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 + // options.value.center recomputes via defu({ center: centerOverride }, ..., { center: propsCenter }) 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) + // 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', () => {