From d9477defdcd0665b37e485ba4db461c9c1f1b93e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:48:50 +1000 Subject: [PATCH 1/2] fix(google-maps): preserve user pan and re-emit ready on color-mode re-init When `mapId` or `colorScheme` change, the map is torn down and re-created. The standalone center watcher then re-fired against the new map instance, calling setCenter with the prop's initial center and discarding the user's pan. Persist the captured center into `centerOverride` before teardown so that `options.value.center` reflects the user's actual position; the watcher's lat/lng comparison guard then short-circuits. Also re-emit `ready` after the new Map instance is constructed so consumers that attach imperative state via the exposed `map` ref (outside of declarative children) can re-bind to the new instance. --- .../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..4f0e0b3c4 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; 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..0285a1f3f 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: the 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..d2f6009b6 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 3f4c112dcd0f30d1bb3a802c6bc26b8adc56613b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:57:43 +1000 Subject: [PATCH 2/2] fix(google-maps): clear centerOverride on controlled center prop change Address CodeRabbit review on #731. Writing centerOverride from the user's pan would block subsequent prop-driven center updates because centerOverride wins over props in the defu merge. Clear it on prop change so external controlled updates take effect again. Add tests for the ready re-emission and the centerOverride clear. --- .../GoogleMaps/ScriptGoogleMaps.vue | 6 ++++ test/unit/google-maps-regressions.test.ts | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 0285a1f3f..b5eb17fe3 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -446,6 +446,12 @@ onMounted(() => { 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 diff --git a/test/unit/google-maps-regressions.test.ts b/test/unit/google-maps-regressions.test.ts index d2f6009b6..ecf0025ac 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -690,5 +690,39 @@ describe('google Maps Regressions', () => { 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() + }) }) })