Skip to content

Commit 682d752

Browse files
committed
fix: prevent memory leaks in all Google Maps sub-components
The previous fix (#649) only handled one race condition — unmounting during `await importLibrary`. The actual leak was caused by orphaned Vue watchers: watchers created after an `await` lose component instance context and are never auto-stopped on unmount, retaining the entire reactive scope. - Create `useGoogleMapsResource` composable encoding lifecycle safety - Move all options watchers to synchronous setup scope (Vue auto-stops) - Add unmount guards to all async resource creation callbacks - Null all resource refs on unmount to release Google Maps objects - Convert `let` variables to `shallowRef` for proper GC - Make parent `onBeforeUnmount` synchronous (Vue doesn't await async hooks) - Clear `libraries` and `queryToLatLngCache` on parent unmount - Extract injection keys to shared `injectionKeys.ts` Closes #646
1 parent 89a9ebe commit 682d752

16 files changed

Lines changed: 1475 additions & 402 deletions

src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/// <reference types="google.maps" />
33
import type { ElementScriptTrigger } from '#nuxt-scripts/types'
44
import type { QueryObject } from 'ufo'
5-
import type { HTMLAttributes, ImgHTMLAttributes, InjectionKey, Ref, ReservedProps, ShallowRef } from 'vue'
5+
import type { HTMLAttributes, ImgHTMLAttributes, Ref, ReservedProps, ShallowRef } from 'vue'
66
import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement'
77
import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps'
88
import { scriptRuntimeConfig } from '#nuxt-scripts/utils'
@@ -13,10 +13,9 @@ import { withQuery } from 'ufo'
1313
import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue'
1414
import ScriptAriaLoadingIndicator from '../ScriptAriaLoadingIndicator.vue'
1515
16-
export const MAP_INJECTION_KEY = Symbol('map') as InjectionKey<{
17-
map: ShallowRef<google.maps.Map | undefined>
18-
mapsApi: Ref<typeof google.maps | undefined>
19-
}>
16+
import { MAP_INJECTION_KEY } from './injectionKeys'
17+
18+
export { MAP_INJECTION_KEY } from './injectionKeys'
2019
</script>
2120

2221
<script lang="ts" setup>
@@ -190,16 +189,12 @@ function isLocationQuery(s: string | any) {
190189
return typeof s === 'string' && (s.split(',').length > 2 || s.includes('+'))
191190
}
192191
193-
function resetMapMarkerMap(_marker: google.maps.marker.AdvancedMarkerElement | Promise<google.maps.marker.AdvancedMarkerElement>) {
194-
// eslint-disable-next-line no-async-promise-executor
195-
return new Promise<void>(async (resolve) => {
196-
const marker = _marker instanceof Promise ? await _marker : _marker
197-
if (marker) {
198-
// @ts-expect-error broken type
199-
marker.setMap(null)
200-
}
201-
resolve()
202-
})
192+
async function resetMapMarkerMap(_marker: google.maps.marker.AdvancedMarkerElement | Promise<google.maps.marker.AdvancedMarkerElement>) {
193+
const marker = _marker instanceof Promise ? await _marker : _marker
194+
if (marker) {
195+
// @ts-expect-error broken type
196+
marker.setMap(null)
197+
}
203198
}
204199
205200
function normalizeAdvancedMapMarkerOptions(_options?: google.maps.marker.AdvancedMarkerElementOptions | `${string},${string}`) {
@@ -228,14 +223,12 @@ async function createAdvancedMapMarker(_options?: google.maps.marker.AdvancedMar
228223
const key = hash({ position: normalizedOptions.position })
229224
if (mapMarkers.value.has(key))
230225
return mapMarkers.value.get(key)
231-
// eslint-disable-next-line no-async-promise-executor
232-
const p = new Promise<google.maps.marker.AdvancedMarkerElement>(async (resolve) => {
233-
const lib = await importLibrary('marker')
226+
const p = importLibrary('marker').then((lib) => {
234227
const mapMarkerOptions = {
235228
...toRaw(normalizedOptions),
236229
map: toRaw(map.value!),
237230
}
238-
resolve(new lib.AdvancedMarkerElement(mapMarkerOptions))
231+
return new lib.AdvancedMarkerElement(mapMarkerOptions)
239232
})
240233
mapMarkers.value.set(key, p)
241234
return p
@@ -536,12 +529,20 @@ const rootAttrs = computed(() => {
536529
}) as HTMLAttributes
537530
})
538531
539-
onBeforeUnmount(async () => {
540-
await Promise.all(Array.from(mapMarkers.value.entries(), ([,marker]) => resetMapMarkerMap(marker)))
541-
mapMarkers.value.clear()
532+
onBeforeUnmount(() => {
533+
// Synchronous cleanup — Vue does not await async lifecycle hooks,
534+
// so anything after an `await` runs as a detached microtask.
535+
// Note: do NOT null mapsApi here — children unmount AFTER onBeforeUnmount
536+
// and need mapsApi.value for clearInstanceListeners in their cleanup.
542537
map.value?.unbindAll()
543538
map.value = undefined
544539
mapEl.value?.firstChild?.remove()
540+
libraries.clear()
541+
queryToLatLngCache.clear()
542+
543+
// Async marker cleanup (fire-and-forget — markers are already detached from the nulled map)
544+
Promise.all(Array.from(mapMarkers.value.entries(), ([, marker]) => resetMapMarkerMap(marker)))
545+
mapMarkers.value.clear()
545546
})
546547
</script>
547548

src/runtime/components/GoogleMaps/ScriptGoogleMapsAdvancedMarkerElement.vue

Lines changed: 32 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
<script lang="ts">
2-
import type { InjectionKey, ShallowRef } from 'vue'
3-
import { whenever } from '@vueuse/core'
4-
import { inject, onUnmounted, provide, ref, shallowRef } from 'vue'
5-
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
2+
import { inject, provide, watch } from 'vue'
3+
import { ADVANCED_MARKER_ELEMENT_INJECTION_KEY } from './injectionKeys'
64
import { MARKER_CLUSTERER_INJECTION_KEY } from './ScriptGoogleMapsMarkerClusterer.vue'
5+
import { useGoogleMapsResource } from './useGoogleMapsResource'
76
8-
export const ADVANCED_MARKER_ELEMENT_INJECTION_KEY = Symbol('marker') as InjectionKey<{
9-
advancedMarkerElement: ShallowRef<google.maps.marker.AdvancedMarkerElement | undefined>
10-
}>
7+
export { ADVANCED_MARKER_ELEMENT_INJECTION_KEY } from './injectionKeys'
118
</script>
129

1310
<script setup lang="ts">
@@ -47,68 +44,46 @@ const eventsWithMapMouseEventPayload = [
4744
'mouseup',
4845
] as const
4946
50-
const mapContext = inject(MAP_INJECTION_KEY, undefined)
5147
const markerClustererContext = inject(MARKER_CLUSTERER_INJECTION_KEY, undefined)
5248
53-
const advancedMarkerElement = shallowRef<google.maps.marker.AdvancedMarkerElement | undefined>(undefined)
54-
const isUnmounted = ref(false)
55-
56-
whenever(() => mapContext?.map.value && mapContext.mapsApi.value, async () => {
57-
await mapContext!.mapsApi.value!.importLibrary('marker')
58-
59-
// component was unmounted while awaiting the library import
60-
if (isUnmounted.value)
61-
return
62-
63-
advancedMarkerElement.value = new mapContext!.mapsApi.value!.marker.AdvancedMarkerElement(props.options)
64-
65-
setupAdvancedMarkerElementEventListeners(advancedMarkerElement.value)
66-
67-
if (markerClustererContext?.markerClusterer.value) {
68-
markerClustererContext.markerClusterer.value.addMarker(advancedMarkerElement.value)
69-
}
70-
else {
71-
advancedMarkerElement.value.map = mapContext!.map.value
72-
}
73-
74-
whenever(() => props.options, (options) => {
75-
if (advancedMarkerElement.value && options) {
76-
Object.assign(advancedMarkerElement.value, options)
49+
const advancedMarkerElement = useGoogleMapsResource<google.maps.marker.AdvancedMarkerElement>({
50+
async create({ mapsApi, map }) {
51+
await mapsApi.importLibrary('marker')
52+
const marker = new mapsApi.marker.AdvancedMarkerElement(props.options)
53+
setupEventListeners(marker)
54+
if (markerClustererContext?.markerClusterer.value) {
55+
markerClustererContext.markerClusterer.value.addMarker(marker)
7756
}
78-
}, {
79-
deep: true,
80-
})
81-
}, {
82-
immediate: true,
83-
once: true,
57+
else {
58+
marker.map = map
59+
}
60+
return marker
61+
},
62+
cleanup(marker, { mapsApi }) {
63+
mapsApi.event.clearInstanceListeners(marker)
64+
if (markerClustererContext) {
65+
markerClustererContext.markerClusterer.value?.removeMarker(marker)
66+
}
67+
else {
68+
marker.map = null
69+
}
70+
},
8471
})
8572
86-
onUnmounted(() => {
87-
isUnmounted.value = true
88-
89-
if (!advancedMarkerElement.value || !mapContext?.mapsApi.value) {
90-
return
73+
watch(() => props.options, (options) => {
74+
if (advancedMarkerElement.value && options) {
75+
Object.assign(advancedMarkerElement.value, options)
9176
}
92-
93-
mapContext.mapsApi.value.event.clearInstanceListeners(advancedMarkerElement.value)
94-
95-
if (markerClustererContext) {
96-
markerClustererContext.markerClusterer.value?.removeMarker(advancedMarkerElement.value)
97-
}
98-
else {
99-
advancedMarkerElement.value.map = null
100-
}
101-
})
77+
}, { deep: true })
10278
10379
provide(ADVANCED_MARKER_ELEMENT_INJECTION_KEY, { advancedMarkerElement })
10480
105-
function setupAdvancedMarkerElementEventListeners(advancedMarkerElement: google.maps.marker.AdvancedMarkerElement) {
81+
function setupEventListeners(marker: google.maps.marker.AdvancedMarkerElement) {
10682
eventsWithoutPayload.forEach((event) => {
107-
advancedMarkerElement.addListener(event, () => emit(event))
83+
marker.addListener(event, () => emit(event))
10884
})
109-
11085
eventsWithMapMouseEventPayload.forEach((event) => {
111-
advancedMarkerElement.addListener(event, (payload: google.maps.MapMouseEvent) => emit(event, payload))
86+
marker.addListener(event, (payload: google.maps.MapMouseEvent) => emit(event, payload))
11287
})
11388
}
11489
</script>

src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script setup lang="ts">
2-
import { whenever } from '@vueuse/core'
3-
import { inject, onUnmounted } from 'vue'
4-
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
2+
import { watch } from 'vue'
3+
import { useGoogleMapsResource } from './useGoogleMapsResource'
54
65
const props = defineProps<{
76
options?: Omit<google.maps.CircleOptions, 'map'>
@@ -31,45 +30,30 @@ const eventsWithMapMouseEventPayload = [
3130
'rightclick',
3231
] as const
3332
34-
const mapContext = inject(MAP_INJECTION_KEY, undefined)
35-
36-
let circle: google.maps.Circle | undefined
37-
38-
whenever(() => mapContext?.map.value && mapContext.mapsApi.value, () => {
39-
circle = new mapContext!.mapsApi.value!.Circle({
40-
map: mapContext!.map.value,
41-
...props.options,
42-
})
43-
44-
setupCircleEventListeners(circle)
45-
46-
whenever(() => props.options, (options) => {
47-
circle?.setOptions(options)
48-
}, {
49-
deep: true,
50-
})
51-
}, {
52-
immediate: true,
53-
once: true,
33+
const circle = useGoogleMapsResource<google.maps.Circle>({
34+
create({ mapsApi, map }) {
35+
const c = new mapsApi.Circle({ map, ...props.options })
36+
setupEventListeners(c)
37+
return c
38+
},
39+
cleanup(c, { mapsApi }) {
40+
mapsApi.event.clearInstanceListeners(c)
41+
c.setMap(null)
42+
},
5443
})
5544
56-
onUnmounted(() => {
57-
if (!circle || !mapContext?.mapsApi.value) {
58-
return
45+
watch(() => props.options, (options) => {
46+
if (circle.value && options) {
47+
circle.value.setOptions(options)
5948
}
49+
}, { deep: true })
6050
61-
mapContext.mapsApi.value.event.clearInstanceListeners(circle)
62-
63-
circle.setMap(null)
64-
})
65-
66-
function setupCircleEventListeners(circle: google.maps.Circle) {
51+
function setupEventListeners(c: google.maps.Circle) {
6752
eventsWithoutPayload.forEach((event) => {
68-
circle.addListener(event, () => emit(event))
53+
c.addListener(event, () => emit(event))
6954
})
70-
7155
eventsWithMapMouseEventPayload.forEach((event) => {
72-
circle.addListener(event, (payload: google.maps.MapMouseEvent) => emit(event, payload))
56+
c.addListener(event, (payload: google.maps.MapMouseEvent) => emit(event, payload))
7357
})
7458
}
7559
</script>

src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,29 @@
11
<script setup lang="ts">
2-
import { whenever } from '@vueuse/core'
3-
import { inject, onUnmounted } from 'vue'
4-
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
2+
import { watch } from 'vue'
3+
import { useGoogleMapsResource } from './useGoogleMapsResource'
54
65
const props = defineProps<{
76
options?: Omit<google.maps.visualization.HeatmapLayerOptions, 'map'>
87
}>()
98
10-
const mapContext = inject(MAP_INJECTION_KEY, undefined)
11-
12-
let heatmapLayer: google.maps.visualization.HeatmapLayer | undefined
13-
14-
whenever(() => mapContext?.map.value && mapContext.mapsApi.value, async () => {
15-
await mapContext!.mapsApi.value!.importLibrary('visualization')
16-
17-
heatmapLayer = new mapContext!.mapsApi.value!.visualization.HeatmapLayer({
18-
map: mapContext!.map.value!,
19-
...props.options,
20-
})
21-
22-
whenever(() => props.options, (options) => {
23-
heatmapLayer?.setOptions(options)
24-
}, {
25-
deep: true,
26-
})
27-
}, {
28-
immediate: true,
29-
once: true,
9+
const heatmapLayer = useGoogleMapsResource<google.maps.visualization.HeatmapLayer>({
10+
async create({ mapsApi, map }) {
11+
await mapsApi.importLibrary('visualization')
12+
return new mapsApi.visualization.HeatmapLayer({
13+
map,
14+
...props.options,
15+
})
16+
},
17+
cleanup(layer) {
18+
layer.setMap(null)
19+
},
3020
})
3121
32-
onUnmounted(() => {
33-
heatmapLayer?.setMap(null)
34-
})
22+
watch(() => props.options, (options) => {
23+
if (heatmapLayer.value && options) {
24+
heatmapLayer.value.setOptions(options)
25+
}
26+
}, { deep: true })
3527
</script>
3628

3729
<template>

0 commit comments

Comments
 (0)