Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/content/scripts/google-maps/1.guides/2.map-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ Switch map styles automatically based on the user's color mode preference. Provi
</template>
```

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
<template>
<ScriptGoogleMaps
:map-ids="{ light: 'YOUR_MAP_ID', dark: 'YOUR_MAP_ID' }"
:center="{ lat: -33.8688, lng: 151.2093 }"
:zoom="12"
/>
</template>
```

::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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<google.maps.ColorScheme | undefined>(() => {
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<typeof google.maps | undefined>()

if (import.meta.dev) {
Expand Down Expand Up @@ -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<google.maps.Map | undefined> = shallowRef()

Expand Down Expand Up @@ -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
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
watch(() => options.value.zoom, (zoom) => {
if (map.value && zoom != null)
map.value.setZoom(zoom)
Expand Down Expand Up @@ -497,6 +548,6 @@ onBeforeUnmount(() => {
</slot>
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
<slot v-else-if="status === 'error'" name="error" />
<slot />
<slot v-if="slotMounted" />
</div>
</template>
129 changes: 124 additions & 5 deletions test/unit/google-maps-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMockMap>, options: Record<string, any>) {
const { center: _, zoom: __, ...rest } = options
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options
map.setOptions(rest)
}

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<typeof createMockMap>, options: Record<string, any>) {
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() }),
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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