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