Skip to content

Commit 94b7d11

Browse files
authored
fix(google-maps): re-init on color-mode change for cloud-styled mapIds (#727)
1 parent 377b125 commit 94b7d11

3 files changed

Lines changed: 195 additions & 9 deletions

File tree

docs/content/scripts/google-maps/1.guides/2.map-styling.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ Switch map styles automatically based on the user's color mode preference. Provi
8484
</template>
8585
```
8686

87+
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:
88+
89+
```vue
90+
<template>
91+
<ScriptGoogleMaps
92+
:map-ids="{ light: 'YOUR_MAP_ID', dark: 'YOUR_MAP_ID' }"
93+
:center="{ lat: -33.8688, lng: 151.2093 }"
94+
:zoom="12"
95+
/>
96+
</template>
97+
```
98+
99+
::callout{color="amber"}
100+
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.
101+
::
102+
87103
This auto-detects `@nuxtjs/color-mode` if installed. You can also control it manually with the `colorMode` prop:
88104

89105
```vue

packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps'
155155
import { scriptRuntimeConfig, scriptsPrefix } from '#nuxt-scripts/utils'
156156
import { defu } from 'defu'
157157
import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app'
158-
import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, useAttrs, useTemplateRef, watch } from 'vue'
158+
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, useAttrs, useTemplateRef, watch } from 'vue'
159159
import ScriptAriaLoadingIndicator from '../ScriptAriaLoadingIndicator.vue'
160160
import { defineDeprecatedAlias, MAP_INJECTION_KEY, waitForMapsReady, warnDeprecatedTopLevelMapProps } from './useGoogleMapsResource'
161161
@@ -186,6 +186,16 @@ const currentMapId = computed(() => {
186186
return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId
187187
})
188188
189+
// `colorScheme` is a Google Maps init-only option that drives Cloud-based
190+
// styling for a single mapId. We always derive it so that toggling color mode
191+
// triggers a re-init even when the resolved mapId is unchanged (e.g. a single
192+
// mapId hosting both Light/Dark cloud themes).
193+
const currentColorScheme = computed<google.maps.ColorScheme | undefined>(() => {
194+
if (!props.mapIds && !props.colorMode && !nuxtColorMode.value)
195+
return undefined
196+
return currentColorMode.value === 'dark' ? 'DARK' as google.maps.ColorScheme : 'LIGHT' as google.maps.ColorScheme
197+
})
198+
189199
const mapsApi = shallowRef<typeof google.maps | undefined>()
190200
191201
if (import.meta.dev) {
@@ -229,13 +239,19 @@ const options = computed(() => {
229239
// are mounted against a styled (mapId-less) map.
230240
const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'DEMO_MAP_ID')
231241
return defu(
232-
{ center: centerOverride.value, mapId },
242+
{ center: centerOverride.value, mapId, colorScheme: currentColorScheme.value },
233243
props.mapOptions,
234244
{ center: props.center, zoom: props.zoom },
235245
{ zoom: 15 },
236246
)
237247
})
238248
const isMapReady = ref(false)
249+
// Drives default-slot mounting. Starts true so children mount immediately
250+
// (preserving v0/v1 behavior where children wait for map readiness via
251+
// `useGoogleMapsResource`). Toggled false→true when the map is re-initialized
252+
// (mapId or colorScheme change) so child components remount and re-run their
253+
// `whenever({ once: true })` create callbacks against the new map instance.
254+
const slotMounted = ref(true)
239255
240256
const map: ShallowRef<google.maps.Map | undefined> = shallowRef()
241257
@@ -375,9 +391,44 @@ onMounted(() => {
375391
return
376392
// Exclude center and zoom — they have dedicated watchers that avoid
377393
// resetting user interactions (pan/zoom) on unrelated re-renders.
378-
const { center: _, zoom: __, ...rest } = options.value
394+
// Exclude mapId and colorScheme — Google Maps treats these as init-only;
395+
// changes are handled by the dedicated re-init watcher below.
396+
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options.value
379397
map.value.setOptions(rest)
380398
})
399+
// Re-init map when mapId or colorScheme changes (e.g. user toggles color mode
400+
// with `mapIds` set or with cloud-based styling on a single mapId). Both are
401+
// init-only in Google Maps; setOptions is a no-op + dev warning. We tear
402+
// down and recreate the map preserving the user's pan/zoom state, and
403+
// toggle `slotMounted` so child components remount and re-bind to the new
404+
// map instance via their `whenever({ once: true })` create callbacks.
405+
watch([currentMapId, currentColorScheme], async ([newMapId, newScheme], [oldMapId, oldScheme]) => {
406+
if (!map.value || !mapsApi.value || !mapEl.value)
407+
return
408+
if (newMapId === oldMapId && newScheme === oldScheme)
409+
return
410+
const center = map.value.getCenter()
411+
const zoom = map.value.getZoom()
412+
map.value.unbindAll()
413+
map.value = undefined
414+
slotMounted.value = false
415+
// Clear any DOM children left by the previous Map instance — Google Maps
416+
// expects to render into an empty container.
417+
if (mapEl.value)
418+
mapEl.value.innerHTML = ''
419+
await nextTick()
420+
// Component may have unmounted (or refs been torn down) during nextTick;
421+
// bail out so we don't spin up a Map against a detached container.
422+
if (!mapEl.value || !mapsApi.value)
423+
return
424+
const _options: google.maps.MapOptions = {
425+
...options.value,
426+
center: center ? { lat: center.lat(), lng: center.lng() } : options.value.center,
427+
zoom: zoom ?? options.value.zoom,
428+
}
429+
map.value = new mapsApi.value.Map(mapEl.value, _options)
430+
slotMounted.value = true
431+
})
381432
watch(() => options.value.zoom, (zoom) => {
382433
if (map.value && zoom != null)
383434
map.value.setZoom(zoom)
@@ -497,6 +548,6 @@ onBeforeUnmount(() => {
497548
</slot>
498549
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
499550
<slot v-else-if="status === 'error'" name="error" />
500-
<slot />
551+
<slot v-if="slotMounted" />
501552
</div>
502553
</template>

test/unit/google-maps-regressions.test.ts

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,11 @@ describe('google Maps Regressions', () => {
329329
map.setOptions(options)
330330
}
331331

332-
// Simulate the fixed watcher: strips center and zoom before calling setOptions
332+
// Simulate the fixed watcher: strips center, zoom, mapId, and colorScheme
333+
// before calling setOptions. mapId/colorScheme are init-only in Google Maps
334+
// and are handled by a dedicated re-init watcher (see #726 regression suite).
333335
function applyOptionsFixed(map: ReturnType<typeof createMockMap>, options: Record<string, any>) {
334-
const { center: _, zoom: __, ...rest } = options
336+
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options
335337
map.setOptions(rest)
336338
}
337339

@@ -348,19 +350,22 @@ describe('google Maps Regressions', () => {
348350
)
349351
})
350352

351-
it('fixed behavior: setOptions excludes zoom and center', () => {
353+
it('fixed behavior: setOptions excludes zoom, center, mapId, and colorScheme', () => {
352354
const map = createMockMap()
353-
const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc' }
355+
const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true }
354356

355357
applyOptionsFixed(map, options)
356358

357-
expect(map.setOptions).toHaveBeenCalledWith({ mapId: 'abc' })
359+
expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: true })
358360
expect(map.setOptions).not.toHaveBeenCalledWith(
359361
expect.objectContaining({ center: expect.anything() }),
360362
)
361363
expect(map.setOptions).not.toHaveBeenCalledWith(
362364
expect.objectContaining({ zoom: expect.anything() }),
363365
)
366+
expect(map.setOptions).not.toHaveBeenCalledWith(
367+
expect.objectContaining({ mapId: expect.anything() }),
368+
)
364369
})
365370

366371
it('old behavior: repeated overlay toggles reset zoom/center every time', () => {
@@ -502,4 +507,118 @@ describe('google Maps Regressions', () => {
502507
expect(iw.close).not.toHaveBeenCalled()
503508
})
504509
})
510+
511+
describe('color-mode reactivity for cloud-based map IDs (#726)', () => {
512+
// Regression: toggling color mode with `mapIds` set (or with cloud-based
513+
// styling on a single mapId) did not update the map. The old code passed
514+
// the resolved mapId via `setOptions`, which Google Maps refuses
515+
// ("A Map's mapId property cannot be changed after initial Map render").
516+
// Both `mapId` and `colorScheme` are init-only; the fix excludes them
517+
// from the generic setOptions call and re-initialises the Map instance
518+
// when either changes.
519+
520+
function resolveMapId(props: {
521+
mapIds?: { light?: string, dark?: string }
522+
mapOptions?: { mapId?: string }
523+
}, colorMode: 'light' | 'dark') {
524+
if (!props.mapIds)
525+
return props.mapOptions?.mapId
526+
return props.mapIds[colorMode] || props.mapIds.light || props.mapOptions?.mapId
527+
}
528+
529+
function resolveColorScheme(props: {
530+
mapIds?: { light?: string, dark?: string }
531+
colorMode?: 'light' | 'dark'
532+
hasNuxtColorMode?: boolean
533+
}, currentColorMode: 'light' | 'dark') {
534+
if (!props.mapIds && !props.colorMode && !props.hasNuxtColorMode)
535+
return undefined
536+
return currentColorMode === 'dark' ? 'DARK' : 'LIGHT'
537+
}
538+
539+
function applyOptionsFixed(map: ReturnType<typeof createMockMap>, options: Record<string, any>) {
540+
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options
541+
map.setOptions(rest)
542+
}
543+
544+
it('strips mapId and colorScheme from setOptions to avoid the init-only warning', () => {
545+
const map = createMockMap()
546+
const options = {
547+
center: { lat: 40, lng: -74 },
548+
zoom: 12,
549+
mapId: 'abc',
550+
colorScheme: 'DARK',
551+
disableDefaultUI: true,
552+
}
553+
554+
applyOptionsFixed(map, options)
555+
556+
expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: true })
557+
expect(map.setOptions).not.toHaveBeenCalledWith(
558+
expect.objectContaining({ mapId: expect.anything() }),
559+
)
560+
expect(map.setOptions).not.toHaveBeenCalledWith(
561+
expect.objectContaining({ colorScheme: expect.anything() }),
562+
)
563+
})
564+
565+
it('resolves a different mapId per color mode when both light and dark are provided', () => {
566+
const props = { mapIds: { light: 'LIGHT_ID', dark: 'DARK_ID' } }
567+
568+
expect(resolveMapId(props, 'light')).toBe('LIGHT_ID')
569+
expect(resolveMapId(props, 'dark')).toBe('DARK_ID')
570+
})
571+
572+
it('emits a colorScheme so a single mapId with cloud-based light/dark styling can re-init', () => {
573+
// User configured one mapId in Cloud Console with both Light and Dark
574+
// schemes. mapIds resolves to the same id in both modes, so the only
575+
// signal that triggers re-init is the colorScheme value.
576+
const props = { mapIds: { light: 'SAME_ID', dark: 'SAME_ID' } }
577+
578+
expect(resolveMapId(props, 'light')).toBe('SAME_ID')
579+
expect(resolveMapId(props, 'dark')).toBe('SAME_ID')
580+
581+
expect(resolveColorScheme(props, 'light')).toBe('LIGHT')
582+
expect(resolveColorScheme(props, 'dark')).toBe('DARK')
583+
})
584+
585+
it('does not emit a colorScheme when no color-mode props or @nuxtjs/color-mode are present', () => {
586+
// Avoid forcing a LIGHT scheme on existing maps that never opted in to
587+
// color-mode reactivity — would otherwise needlessly re-init on first
588+
// mount or accidentally override mapOptions.colorScheme.
589+
expect(resolveColorScheme({}, 'light')).toBeUndefined()
590+
})
591+
592+
it('emits a colorScheme when @nuxtjs/color-mode is detected even without explicit mapIds', () => {
593+
expect(resolveColorScheme({ hasNuxtColorMode: true }, 'dark')).toBe('DARK')
594+
})
595+
596+
it('triggers re-init only when the resolved mapId or colorScheme actually changes', () => {
597+
// Mirrors the dedup guard in the recreate watcher.
598+
function shouldReinit(
599+
prev: { mapId: string | undefined, scheme: string | undefined },
600+
next: { mapId: string | undefined, scheme: string | undefined },
601+
) {
602+
return prev.mapId !== next.mapId || prev.scheme !== next.scheme
603+
}
604+
605+
// Identical → no re-init (covers e.g. unrelated re-renders that re-evaluate the options computed).
606+
expect(shouldReinit(
607+
{ mapId: 'abc', scheme: 'LIGHT' },
608+
{ mapId: 'abc', scheme: 'LIGHT' },
609+
)).toBe(false)
610+
611+
// mapId changes (two-id light/dark setup).
612+
expect(shouldReinit(
613+
{ mapId: 'LIGHT_ID', scheme: 'LIGHT' },
614+
{ mapId: 'DARK_ID', scheme: 'DARK' },
615+
)).toBe(true)
616+
617+
// Single mapId, only colorScheme changes (cloud styling on one id).
618+
expect(shouldReinit(
619+
{ mapId: 'SAME_ID', scheme: 'LIGHT' },
620+
{ mapId: 'SAME_ID', scheme: 'DARK' },
621+
)).toBe(true)
622+
})
623+
})
505624
})

0 commit comments

Comments
 (0)