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 80ea71de3..1611b57cb 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 @@ -84,6 +84,22 @@ Switch map styles automatically based on the user's color mode preference. Provi ``` +If you set up a single Map ID in Google Cloud Console with both Light and Dark color schemes, point both keys at the same id and the map will pick up Google's `colorScheme` automatically: + +```vue + +``` + +::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. +:: + This auto-detects `@nuxtjs/color-mode` if installed. You can also control it manually with the `colorMode` prop: ```vue diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index dfdc5bd7b..d6902a707 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -155,7 +155,7 @@ import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps' import { scriptRuntimeConfig, scriptsPrefix } from '#nuxt-scripts/utils' import { defu } from 'defu' import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app' -import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, useAttrs, useTemplateRef, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, useAttrs, useTemplateRef, watch } from 'vue' import ScriptAriaLoadingIndicator from '../ScriptAriaLoadingIndicator.vue' import { defineDeprecatedAlias, MAP_INJECTION_KEY, waitForMapsReady, warnDeprecatedTopLevelMapProps } from './useGoogleMapsResource' @@ -186,6 +186,16 @@ const currentMapId = computed(() => { return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId }) +// `colorScheme` is a Google Maps init-only option that drives Cloud-based +// styling for a single mapId. We always derive it so that toggling color mode +// triggers a re-init even when the resolved mapId is unchanged (e.g. a single +// mapId hosting both Light/Dark cloud themes). +const currentColorScheme = computed(() => { + if (!props.mapIds && !props.colorMode && !nuxtColorMode.value) + return undefined + return currentColorMode.value === 'dark' ? 'DARK' as google.maps.ColorScheme : 'LIGHT' as google.maps.ColorScheme +}) + const mapsApi = shallowRef() if (import.meta.dev) { @@ -229,13 +239,19 @@ const options = computed(() => { // are mounted against a styled (mapId-less) map. const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'DEMO_MAP_ID') return defu( - { center: centerOverride.value, mapId }, + { center: centerOverride.value, mapId, colorScheme: currentColorScheme.value }, props.mapOptions, { center: props.center, zoom: props.zoom }, { zoom: 15 }, ) }) const isMapReady = ref(false) +// Drives default-slot mounting. Starts true so children mount immediately +// (preserving v0/v1 behavior where children wait for map readiness via +// `useGoogleMapsResource`). Toggled false→true when the map is re-initialized +// (mapId or colorScheme change) so child components remount and re-run their +// `whenever({ once: true })` create callbacks against the new map instance. +const slotMounted = ref(true) const map: ShallowRef = shallowRef() @@ -375,9 +391,44 @@ onMounted(() => { return // Exclude center and zoom — they have dedicated watchers that avoid // resetting user interactions (pan/zoom) on unrelated re-renders. - const { center: _, zoom: __, ...rest } = options.value + // Exclude mapId and colorScheme — Google Maps treats these as init-only; + // changes are handled by the dedicated re-init watcher below. + const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options.value map.value.setOptions(rest) }) + // Re-init map when mapId or colorScheme changes (e.g. user toggles color mode + // with `mapIds` set or with cloud-based styling on a single mapId). Both are + // init-only in Google Maps; setOptions is a no-op + dev warning. We tear + // down and recreate the map preserving the user's pan/zoom state, and + // toggle `slotMounted` so child components remount and re-bind to the new + // map instance via their `whenever({ once: true })` create callbacks. + watch([currentMapId, currentColorScheme], async ([newMapId, newScheme], [oldMapId, oldScheme]) => { + if (!map.value || !mapsApi.value || !mapEl.value) + return + if (newMapId === oldMapId && newScheme === oldScheme) + return + const center = map.value.getCenter() + const zoom = map.value.getZoom() + map.value.unbindAll() + map.value = undefined + slotMounted.value = false + // Clear any DOM children left by the previous Map instance — Google Maps + // expects to render into an empty container. + if (mapEl.value) + mapEl.value.innerHTML = '' + await nextTick() + // Component may have unmounted (or refs been torn down) during nextTick; + // bail out so we don't spin up a Map against a detached container. + if (!mapEl.value || !mapsApi.value) + return + const _options: google.maps.MapOptions = { + ...options.value, + center: center ? { lat: center.lat(), lng: center.lng() } : options.value.center, + zoom: zoom ?? options.value.zoom, + } + map.value = new mapsApi.value.Map(mapEl.value, _options) + slotMounted.value = true + }) watch(() => options.value.zoom, (zoom) => { if (map.value && zoom != null) map.value.setZoom(zoom) @@ -497,6 +548,6 @@ onBeforeUnmount(() => { - + diff --git a/test/unit/google-maps-regressions.test.ts b/test/unit/google-maps-regressions.test.ts index 73938a9fe..a93f497c0 100644 --- a/test/unit/google-maps-regressions.test.ts +++ b/test/unit/google-maps-regressions.test.ts @@ -329,9 +329,11 @@ describe('google Maps Regressions', () => { map.setOptions(options) } - // Simulate the fixed watcher: strips center and zoom before calling setOptions + // Simulate the fixed watcher: strips center, zoom, mapId, and colorScheme + // before calling setOptions. mapId/colorScheme are init-only in Google Maps + // and are handled by a dedicated re-init watcher (see #726 regression suite). function applyOptionsFixed(map: ReturnType, options: Record) { - const { center: _, zoom: __, ...rest } = options + const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options map.setOptions(rest) } @@ -348,19 +350,22 @@ describe('google Maps Regressions', () => { ) }) - it('fixed behavior: setOptions excludes zoom and center', () => { + it('fixed behavior: setOptions excludes zoom, center, mapId, and colorScheme', () => { const map = createMockMap() - const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc' } + const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true } applyOptionsFixed(map, options) - expect(map.setOptions).toHaveBeenCalledWith({ mapId: 'abc' }) + expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: true }) expect(map.setOptions).not.toHaveBeenCalledWith( expect.objectContaining({ center: expect.anything() }), ) expect(map.setOptions).not.toHaveBeenCalledWith( expect.objectContaining({ zoom: expect.anything() }), ) + expect(map.setOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ mapId: expect.anything() }), + ) }) it('old behavior: repeated overlay toggles reset zoom/center every time', () => { @@ -502,4 +507,118 @@ describe('google Maps Regressions', () => { expect(iw.close).not.toHaveBeenCalled() }) }) + + describe('color-mode reactivity for cloud-based map IDs (#726)', () => { + // Regression: toggling color mode with `mapIds` set (or with cloud-based + // styling on a single mapId) did not update the map. The old code passed + // the resolved mapId via `setOptions`, which Google Maps refuses + // ("A Map's mapId property cannot be changed after initial Map render"). + // Both `mapId` and `colorScheme` are init-only; the fix excludes them + // from the generic setOptions call and re-initialises the Map instance + // when either changes. + + function resolveMapId(props: { + mapIds?: { light?: string, dark?: string } + mapOptions?: { mapId?: string } + }, colorMode: 'light' | 'dark') { + if (!props.mapIds) + return props.mapOptions?.mapId + return props.mapIds[colorMode] || props.mapIds.light || props.mapOptions?.mapId + } + + function resolveColorScheme(props: { + mapIds?: { light?: string, dark?: string } + colorMode?: 'light' | 'dark' + hasNuxtColorMode?: boolean + }, currentColorMode: 'light' | 'dark') { + if (!props.mapIds && !props.colorMode && !props.hasNuxtColorMode) + return undefined + return currentColorMode === 'dark' ? 'DARK' : 'LIGHT' + } + + function applyOptionsFixed(map: ReturnType, options: Record) { + const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options + map.setOptions(rest) + } + + it('strips mapId and colorScheme from setOptions to avoid the init-only warning', () => { + const map = createMockMap() + const options = { + center: { lat: 40, lng: -74 }, + zoom: 12, + mapId: 'abc', + colorScheme: 'DARK', + disableDefaultUI: true, + } + + applyOptionsFixed(map, options) + + expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: true }) + expect(map.setOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ mapId: expect.anything() }), + ) + expect(map.setOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ colorScheme: expect.anything() }), + ) + }) + + it('resolves a different mapId per color mode when both light and dark are provided', () => { + const props = { mapIds: { light: 'LIGHT_ID', dark: 'DARK_ID' } } + + expect(resolveMapId(props, 'light')).toBe('LIGHT_ID') + expect(resolveMapId(props, 'dark')).toBe('DARK_ID') + }) + + it('emits a colorScheme so a single mapId with cloud-based light/dark styling can re-init', () => { + // User configured one mapId in Cloud Console with both Light and Dark + // schemes. mapIds resolves to the same id in both modes, so the only + // signal that triggers re-init is the colorScheme value. + const props = { mapIds: { light: 'SAME_ID', dark: 'SAME_ID' } } + + expect(resolveMapId(props, 'light')).toBe('SAME_ID') + expect(resolveMapId(props, 'dark')).toBe('SAME_ID') + + expect(resolveColorScheme(props, 'light')).toBe('LIGHT') + expect(resolveColorScheme(props, 'dark')).toBe('DARK') + }) + + it('does not emit a colorScheme when no color-mode props or @nuxtjs/color-mode are present', () => { + // Avoid forcing a LIGHT scheme on existing maps that never opted in to + // color-mode reactivity — would otherwise needlessly re-init on first + // mount or accidentally override mapOptions.colorScheme. + expect(resolveColorScheme({}, 'light')).toBeUndefined() + }) + + it('emits a colorScheme when @nuxtjs/color-mode is detected even without explicit mapIds', () => { + expect(resolveColorScheme({ hasNuxtColorMode: true }, 'dark')).toBe('DARK') + }) + + it('triggers re-init only when the resolved mapId or colorScheme actually changes', () => { + // Mirrors the dedup guard in the recreate watcher. + function shouldReinit( + prev: { mapId: string | undefined, scheme: string | undefined }, + next: { mapId: string | undefined, scheme: string | undefined }, + ) { + return prev.mapId !== next.mapId || prev.scheme !== next.scheme + } + + // Identical → no re-init (covers e.g. unrelated re-renders that re-evaluate the options computed). + expect(shouldReinit( + { mapId: 'abc', scheme: 'LIGHT' }, + { mapId: 'abc', scheme: 'LIGHT' }, + )).toBe(false) + + // mapId changes (two-id light/dark setup). + expect(shouldReinit( + { mapId: 'LIGHT_ID', scheme: 'LIGHT' }, + { mapId: 'DARK_ID', scheme: 'DARK' }, + )).toBe(true) + + // Single mapId, only colorScheme changes (cloud styling on one id). + expect(shouldReinit( + { mapId: 'SAME_ID', scheme: 'LIGHT' }, + { mapId: 'SAME_ID', scheme: 'DARK' }, + )).toBe(true) + }) + }) })