Skip to content

Commit 33ebf61

Browse files
authored
fix(google-maps): close races in resolveQueryToLatLng (#693)
1 parent b59cd35 commit 33ebf61

File tree

3 files changed

+178
-14
lines changed

3 files changed

+178
-14
lines changed

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

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ import { defu } from 'defu'
138138
import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app'
139139
import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, useAttrs, useTemplateRef, watch } from 'vue'
140140
import ScriptAriaLoadingIndicator from '../ScriptAriaLoadingIndicator.vue'
141-
import { MAP_INJECTION_KEY } from './useGoogleMapsResource'
141+
import { MAP_INJECTION_KEY, waitForMapsReady } from './useGoogleMapsResource'
142142
143143
const props = withDefaults(defineProps<ScriptGoogleMapsProps>(), {
144144
// @ts-expect-error untyped
@@ -241,17 +241,11 @@ async function resolveQueryToLatLng(query: string) {
241241
throw new Error(`No location found for ${query}`)
242242
}
243243
244-
// Fallback: use Places API client-side
245-
if (!mapsApi.value) {
246-
await load()
247-
// await new promise, watch until mapsApi is set
248-
await new Promise<void>((resolve) => {
249-
const _ = watch(mapsApi, () => {
250-
_()
251-
resolve()
252-
})
253-
})
254-
}
244+
// Fallback: use Places API client-side. Wait for both the maps API and a
245+
// Map instance: resolveQueryToLatLng is publicly exposed and may be called
246+
// before onLoaded has populated map.value, so constructing PlacesService
247+
// without map would throw.
248+
await waitForMapsReady({ mapsApi, map, status, load })
255249
256250
const placesService = new mapsApi.value!.places.PlacesService(map.value!)
257251
const result = await new Promise<google.maps.LatLng>((resolve, reject) => {

packages/script/src/runtime/components/GoogleMaps/useGoogleMapsResource.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { InjectionKey, Ref, ShallowRef } from 'vue'
22
import { whenever } from '@vueuse/core'
3-
import { inject, onUnmounted, ref, shallowRef } from 'vue'
3+
import { effectScope, inject, onUnmounted, ref, shallowRef, watch } from 'vue'
44

55
export const MAP_INJECTION_KEY = Symbol('map') as InjectionKey<{
66
map: ShallowRef<google.maps.Map | undefined>
@@ -41,6 +41,67 @@ export interface GoogleMapsResourceContext {
4141
mapsApi: typeof google.maps
4242
}
4343

44+
/**
45+
* Wait until the Google Maps API and a Map instance are both available.
46+
*
47+
* Triggers script loading via `load()` if not already loaded. Uses an
48+
* immediate watcher (matching `importLibrary`'s pattern) to avoid the race
49+
* where `load()` resolves synchronously: a non-immediate watcher would miss
50+
* the change and the promise would hang forever.
51+
*
52+
* Rejects if `status` enters an `'error'` state before both refs are populated.
53+
* Runs the watcher inside a detached effect scope so it is safe to call from
54+
* any context (component setup, exposed methods, tests).
55+
*/
56+
export async function waitForMapsReady({
57+
mapsApi,
58+
map,
59+
status,
60+
load,
61+
}: {
62+
mapsApi: ShallowRef<typeof google.maps | undefined>
63+
map: ShallowRef<google.maps.Map | undefined>
64+
status: Ref<string>
65+
load: () => Promise<unknown> | unknown
66+
}): Promise<void> {
67+
if (mapsApi.value && map.value)
68+
return
69+
if (status.value === 'error')
70+
throw new Error('Google Maps script failed to load')
71+
72+
await load()
73+
74+
// load() may have populated both refs synchronously — re-check before
75+
// installing a watcher to avoid the race that hangs the promise forever.
76+
if (mapsApi.value && map.value)
77+
return
78+
if (status.value === 'error')
79+
throw new Error('Google Maps script failed to load')
80+
81+
const scope = effectScope(true)
82+
try {
83+
await new Promise<void>((resolve, reject) => {
84+
scope.run(() => {
85+
watch(
86+
[mapsApi, map, status],
87+
([api, m, s]) => {
88+
if (api && m) {
89+
resolve()
90+
return
91+
}
92+
if (s === 'error')
93+
reject(new Error('Google Maps script failed to load'))
94+
},
95+
{ immediate: true },
96+
)
97+
})
98+
})
99+
}
100+
finally {
101+
scope.stop()
102+
}
103+
}
104+
44105
/**
45106
* Composable for safely managing Google Maps resource lifecycle.
46107
*

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

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { mount } from '@vue/test-utils'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import { defineComponent, h, nextTick, onUnmounted, provide, ref, shallowRef } from 'vue'
7-
import { MAP_INJECTION_KEY, useGoogleMapsResource } from '../../packages/script/src/runtime/components/GoogleMaps/useGoogleMapsResource'
7+
import { MAP_INJECTION_KEY, useGoogleMapsResource, waitForMapsReady } from '../../packages/script/src/runtime/components/GoogleMaps/useGoogleMapsResource'
88
import { createMockGoogleMapsAPI } from './__mocks__/google-maps-api'
99

1010
// Helper to create a wrapper component that provides mock map context
@@ -367,3 +367,112 @@ describe('google Maps component lifecycle - memory leak prevention', () => {
367367
)
368368
})
369369
})
370+
371+
describe('waitForMapsReady', () => {
372+
// Regression: the previous resolveQueryToLatLng wait pattern used a
373+
// non-immediate watch after `await load()`. If load() populated mapsApi
374+
// synchronously, the watcher missed the change and the promise hung
375+
// forever. The fix re-checks state after load() and uses an immediate
376+
// watcher (matching importLibrary's pattern). It also waits for `map`
377+
// to be set, since PlacesService construction requires it.
378+
379+
it('returns immediately when both refs are already populated', async () => {
380+
const mapsApi = shallowRef<any>({ places: {} })
381+
const map = shallowRef<any>({})
382+
const status = ref<string>('loaded')
383+
const load = vi.fn(() => Promise.resolve())
384+
385+
await waitForMapsReady({ mapsApi, map, status, load })
386+
387+
// Should not have called load() at all
388+
expect(load).not.toHaveBeenCalled()
389+
})
390+
391+
it('does not hang when load() resolves synchronously', async () => {
392+
// Reproduces the original race: load() populates mapsApi synchronously,
393+
// so a non-immediate watcher would never fire.
394+
const mapsApi = shallowRef<any>(undefined)
395+
const map = shallowRef<any>(undefined)
396+
const status = ref<string>('loading')
397+
const load = vi.fn(() => {
398+
// Synchronously populate both refs before returning the resolved promise
399+
mapsApi.value = { places: {} }
400+
map.value = {}
401+
return Promise.resolve()
402+
})
403+
404+
// The original race would hang forever — vitest's per-test timeout
405+
// is the deterministic backstop, no wall-clock setTimeout needed.
406+
await expect(waitForMapsReady({ mapsApi, map, status, load })).resolves.toBeUndefined()
407+
408+
expect(load).toHaveBeenCalledOnce()
409+
})
410+
411+
it('resolves when refs are populated asynchronously after load()', async () => {
412+
const mapsApi = shallowRef<any>(undefined)
413+
const map = shallowRef<any>(undefined)
414+
const status = ref<string>('loading')
415+
const load = vi.fn(() => Promise.resolve())
416+
417+
const promise = waitForMapsReady({ mapsApi, map, status, load })
418+
419+
// Populate refs after a tick — simulates the normal onLoaded flow
420+
await nextTick()
421+
mapsApi.value = { places: {} }
422+
await nextTick()
423+
map.value = {}
424+
425+
await expect(promise).resolves.toBeUndefined()
426+
})
427+
428+
it('rejects synchronously when status is already error', async () => {
429+
const mapsApi = shallowRef<any>(undefined)
430+
const map = shallowRef<any>(undefined)
431+
const status = ref<string>('error')
432+
const load = vi.fn(() => Promise.resolve())
433+
434+
await expect(
435+
waitForMapsReady({ mapsApi, map, status, load }),
436+
).rejects.toThrow('Google Maps script failed to load')
437+
438+
// Should bail before calling load()
439+
expect(load).not.toHaveBeenCalled()
440+
})
441+
442+
it('rejects when status transitions to error during the wait', async () => {
443+
const mapsApi = shallowRef<any>(undefined)
444+
const map = shallowRef<any>(undefined)
445+
const status = ref<string>('loading')
446+
const load = vi.fn(() => Promise.resolve())
447+
448+
const promise = waitForMapsReady({ mapsApi, map, status, load })
449+
450+
await nextTick()
451+
status.value = 'error'
452+
453+
await expect(promise).rejects.toThrow('Google Maps script failed to load')
454+
})
455+
456+
it('waits for map even when mapsApi is set first', async () => {
457+
// Guards against the second bug: PlacesService construction needs
458+
// map.value, not just mapsApi.value.
459+
const mapsApi = shallowRef<any>({ places: {} })
460+
const map = shallowRef<any>(undefined)
461+
const status = ref<string>('loading')
462+
const load = vi.fn(() => Promise.resolve())
463+
464+
let settled = false
465+
const promise = waitForMapsReady({ mapsApi, map, status, load }).then(() => {
466+
settled = true
467+
})
468+
469+
// Flush microtasks: settlement should not happen while map is undefined
470+
await nextTick()
471+
await nextTick()
472+
expect(settled).toBe(false)
473+
474+
map.value = {}
475+
await expect(promise).resolves.toBeUndefined()
476+
expect(settled).toBe(true)
477+
})
478+
})

0 commit comments

Comments
 (0)