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
importScriptAriaLoadingIndicatorfrom'../ScriptAriaLoadingIndicator.vue'
160160
import { defineDeprecatedAlias, MAP_INJECTION_KEY, waitForMapsReady, warnDeprecatedTopLevelMapProps } from'./useGoogleMapsResource'
161161
@@ -186,6 +186,16 @@ const currentMapId = computed(() => {
186186
returnprops.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+
returnundefined
196+
returncurrentColorMode.value==='dark'?'DARK'asgoogle.maps.ColorScheme:'LIGHT'asgoogle.maps.ColorScheme
197+
})
198+
189199
const mapsApi =shallowRef<typeofgoogle.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
returndefu(
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+
awaitnextTick()
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=newmapsApi.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
<slotv-if="status === 'awaitingLoad'"name="awaitingLoad" />
499550
<slotv-else-if="status === 'error'"name="error" />
500-
<slot />
551+
<slotv-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
functionapplyOptionsFixed(map: ReturnType<typeofcreateMockMap>,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 zoomand center',()=>{
353+
it('fixed behavior: setOptions excludes zoom, center, mapId, and colorScheme',()=>{
352354
constmap=createMockMap()
353-
constoptions={center: {lat: 40,lng: -74},zoom: 12,mapId: 'abc'}
355+
constoptions={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+
functionresolveMapId(props: {
521+
mapIds?: {light?: string,dark?: string}
522+
mapOptions?: {mapId?: string}
523+
},colorMode: 'light'|'dark'){
524+
if(!props.mapIds)
525+
returnprops.mapOptions?.mapId
526+
returnprops.mapIds[colorMode]||props.mapIds.light||props.mapOptions?.mapId
527+
}
528+
529+
functionresolveColorScheme(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+
returnundefined
536+
returncurrentColorMode==='dark' ? 'DARK' : 'LIGHT'
537+
}
538+
539+
functionapplyOptionsFixed(map: ReturnType<typeofcreateMockMap>,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+
constmap=createMockMap()
546+
constoptions={
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+
constprops={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+
constprops={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+
functionshouldReinit(
599+
prev: {mapId: string|undefined,scheme: string|undefined},
600+
next: {mapId: string|undefined,scheme: string|undefined},
601+
){
602+
returnprev.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)