diff --git a/docs/content/scripts/google-maps.md b/docs/content/scripts/google-maps.md index 93cf292c5..96cf9f14c 100644 --- a/docs/content/scripts/google-maps.md +++ b/docs/content/scripts/google-maps.md @@ -521,160 +521,35 @@ onMounted(() => { ``` -**See the [SFC Playground Example](https://nuxt-scripts-playground.stackblitz.io/third-parties/google-maps/sfcs) for a complete demonstration.** +### Component Hierarchy -### Component Details - -#### ScriptGoogleMapsMarker - -Classic Google Maps marker with icon support. - -**Props:** -- `options` - `google.maps.MarkerOptions` (excluding `map`) - -**Events:** -- Standard marker events: `click`, `mousedown`, `mouseover`, etc. - -#### ScriptGoogleMapsAdvancedMarkerElement - -Modern advanced markers that support HTML content and better customization. - -**Props:** -- `options` - `google.maps.marker.AdvancedMarkerElementOptions` (excluding `map`) - -**Events:** -- Standard marker events: `click`, `drag`, `position_changed`, etc. - -#### ScriptGoogleMapsInfoWindow - -Information windows that display content when triggered. - -**Props:** -- `options` - `google.maps.InfoWindowOptions` - -**Behavior:** -- Automatically opens on parent marker click -- You can use it standalone with an explicit position -- Supports custom HTML content via default slot - -#### ScriptGoogleMapsMarkerClusterer - -Groups nearby markers into clusters for better performance and UX. - -**Props:** -- `options` - `MarkerClustererOptions` (excluding `map`) - -**Dependencies:** -- Requires `@googlemaps/markerclusterer` peer dependency - -#### Other Components - -- **ScriptGoogleMapsPinElement**: Use within AdvancedMarkerElement for customizable pins -- **ScriptGoogleMapsCircle**: Circular overlays with radius and styling -- **ScriptGoogleMapsPolygon/Polyline**: Shape and line overlays -- **ScriptGoogleMapsRectangle**: Rectangular overlays -- **ScriptGoogleMapsHeatmapLayer**: Data visualization with heatmaps - -All components support: -- Reactive `options` prop that updates the basic Google Maps object -- Automatic cleanup on component unmount -- TypeScript support with Google Maps types - -### Best Practices - -#### Performance Considerations - -**Use MarkerClusterer for Many Markers** -```vue - - - - - - - -``` - -**Prefer AdvancedMarkerElement for Modern Apps** -```vue - - - - - - - -``` - -#### Component Hierarchy - -Follow this nesting structure for components: - -``` +```text ScriptGoogleMaps (root) ├── ScriptGoogleMapsMarkerClusterer (optional) -│ └── ScriptGoogleMapsMarker/AdvancedMarkerElement +│ └── ScriptGoogleMapsMarker / ScriptGoogleMapsAdvancedMarkerElement │ └── ScriptGoogleMapsInfoWindow (optional) ├── ScriptGoogleMapsAdvancedMarkerElement │ ├── ScriptGoogleMapsPinElement (optional) │ └── ScriptGoogleMapsInfoWindow (optional) -└── Other overlays (Circle, Polygon, etc.) +└── ScriptGoogleMapsCircle / Polygon / Polyline / Rectangle / HeatmapLayer ``` -#### Reactive Data Patterns - -**Reactive Marker Updates** -```vue - - - - - - - -``` - -#### Error Handling - -Always provide error fallbacks and loading states: - -```vue - - - - - - - Failed to load Google Maps - - - - - - -``` +All SFC components accept an `options` prop matching their Google Maps API options type (excluding `map`, which is injected automatically). Options are reactive - changes update the basic Google Maps object. Components clean up automatically on unmount. + +### Component Reference + +| Component | Options Type | Notes | +|---|---|---| +| `ScriptGoogleMapsMarker` | `google.maps.MarkerOptions` | Classic marker | +| `ScriptGoogleMapsAdvancedMarkerElement` | `google.maps.marker.AdvancedMarkerElementOptions` | Recommended | +| `ScriptGoogleMapsPinElement` | `google.maps.marker.PinElementOptions` | Child of AdvancedMarkerElement | +| `ScriptGoogleMapsInfoWindow` | `google.maps.InfoWindowOptions` | Auto-opens on parent marker click | +| `ScriptGoogleMapsMarkerClusterer` | `MarkerClustererOptions` | Requires `@googlemaps/markerclusterer` | +| `ScriptGoogleMapsCircle` | `google.maps.CircleOptions` | | +| `ScriptGoogleMapsPolygon` | `google.maps.PolygonOptions` | | +| `ScriptGoogleMapsPolyline` | `google.maps.PolylineOptions` | | +| `ScriptGoogleMapsRectangle` | `google.maps.RectangleOptions` | | +| `ScriptGoogleMapsHeatmapLayer` | `google.maps.visualization.HeatmapLayerOptions` | | ## [`useScriptGoogleMaps()`{lang="ts"}](/scripts/google-maps){lang="ts"} diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 1e865c6ff..e0d6cd902 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -2,7 +2,7 @@ /// import type { ElementScriptTrigger } from '#nuxt-scripts/types' import type { QueryObject } from 'ufo' -import type { HTMLAttributes, ImgHTMLAttributes, InjectionKey, Ref, ReservedProps, ShallowRef } from 'vue' +import type { HTMLAttributes, ImgHTMLAttributes, Ref, ReservedProps, ShallowRef } from 'vue' import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement' import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps' import { scriptRuntimeConfig } from '#nuxt-scripts/utils' @@ -13,10 +13,9 @@ import { withQuery } from 'ufo' import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue' import ScriptAriaLoadingIndicator from '../ScriptAriaLoadingIndicator.vue' -export const MAP_INJECTION_KEY = Symbol('map') as InjectionKey<{ - map: ShallowRef - mapsApi: Ref -}> +import { MAP_INJECTION_KEY } from './injectionKeys' + +export { MAP_INJECTION_KEY } from './injectionKeys' diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsAdvancedMarkerElement.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsAdvancedMarkerElement.vue index 2aaa65c0d..499a0c2cc 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsAdvancedMarkerElement.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsAdvancedMarkerElement.vue @@ -1,13 +1,10 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue index 63aa2e9af..163687d8d 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue @@ -1,7 +1,6 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue index 58c35fb14..5903da59c 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue @@ -1,37 +1,29 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue index e84cca125..f7ae27cd7 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue @@ -1,9 +1,7 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue index 7bb49bb7e..a7c41c808 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue @@ -1,13 +1,10 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue index b4d69194f..0cec5764b 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue @@ -1,8 +1,8 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue index 521f21c63..99a79bc9f 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue @@ -1,7 +1,6 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue index 633a80e69..8a8e1b62e 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue @@ -1,7 +1,6 @@ diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue index 345f72624..42b563319 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue @@ -1,7 +1,6 @@ diff --git a/src/runtime/components/GoogleMaps/injectionKeys.ts b/src/runtime/components/GoogleMaps/injectionKeys.ts new file mode 100644 index 000000000..65c3c16ae --- /dev/null +++ b/src/runtime/components/GoogleMaps/injectionKeys.ts @@ -0,0 +1,14 @@ +import type { InjectionKey, Ref, ShallowRef } from 'vue' + +export const MAP_INJECTION_KEY = Symbol('map') as InjectionKey<{ + map: ShallowRef + mapsApi: Ref +}> + +export const ADVANCED_MARKER_ELEMENT_INJECTION_KEY = Symbol('marker') as InjectionKey<{ + advancedMarkerElement: ShallowRef +}> + +export const MARKER_INJECTION_KEY = Symbol('marker') as InjectionKey<{ + marker: ShallowRef +}> diff --git a/src/runtime/components/GoogleMaps/useGoogleMapsResource.ts b/src/runtime/components/GoogleMaps/useGoogleMapsResource.ts new file mode 100644 index 000000000..929585c0b --- /dev/null +++ b/src/runtime/components/GoogleMaps/useGoogleMapsResource.ts @@ -0,0 +1,66 @@ +import type { ShallowRef } from 'vue' +import { whenever } from '@vueuse/core' +import { inject, onUnmounted, ref, shallowRef } from 'vue' +import { MAP_INJECTION_KEY } from './injectionKeys' + +export interface GoogleMapsResourceContext { + map: google.maps.Map + mapsApi: typeof google.maps +} + +/** + * Composable for safely managing Google Maps resource lifecycle. + * + * Handles the common pattern: wait for map readiness → async create → cleanup on unmount. + * + * Safety guarantees: + * - No watchers created after `await` (prevents orphaned watchers that leak memory) + * - Unmount guard prevents resource creation after component unmount + * - Resources created during the async gap are immediately cleaned up + * - Resource ref is always nulled on unmount to allow GC + */ +export function useGoogleMapsResource({ + ready, + create, + cleanup, +}: { + /** Additional readiness condition beyond map + mapsApi being available */ + ready?: () => boolean + /** Create the Google Maps resource. Receives map context snapshot. May be async. */ + create: (ctx: GoogleMapsResourceContext) => Promise | T + /** Clean up the resource. Called on unmount, or immediately if resource was created after unmount. */ + cleanup?: (resource: T, ctx: { mapsApi: typeof google.maps }) => void +}): ShallowRef { + const mapContext = inject(MAP_INJECTION_KEY, undefined) + const resource = shallowRef(undefined) as ShallowRef + const isUnmounted = ref(false) + + whenever( + () => mapContext?.map.value && mapContext.mapsApi.value && (!ready || ready()), + async () => { + const result = await create({ + map: mapContext!.map.value!, + mapsApi: mapContext!.mapsApi.value!, + }) + if (isUnmounted.value) { + // Resource was created during the async gap after unmount — clean it up immediately + if (cleanup && mapContext?.mapsApi.value) { + cleanup(result, { mapsApi: mapContext.mapsApi.value }) + } + return + } + resource.value = result + }, + { immediate: true, once: true }, + ) + + onUnmounted(() => { + isUnmounted.value = true + if (resource.value && cleanup && mapContext?.mapsApi.value) { + cleanup(resource.value, { mapsApi: mapContext.mapsApi.value }) + } + resource.value = undefined + }) + + return resource +} diff --git a/test/nuxt-runtime/google-maps-lifecycle.nuxt.test.ts b/test/nuxt-runtime/google-maps-lifecycle.nuxt.test.ts new file mode 100644 index 000000000..4cc4fa44e --- /dev/null +++ b/test/nuxt-runtime/google-maps-lifecycle.nuxt.test.ts @@ -0,0 +1,241 @@ +/// +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, provide, ref, shallowRef } from 'vue' +import { MAP_INJECTION_KEY } from '../../src/runtime/components/GoogleMaps/injectionKeys' +import { useGoogleMapsResource } from '../../src/runtime/components/GoogleMaps/useGoogleMapsResource' +import { createMockGoogleMapsAPI } from '../unit/__mocks__/google-maps-api' + +type MockAPI = ReturnType + +function createMapProvider(mocks: MockAPI, opts?: { immediate?: boolean }) { + const map = shallowRef(opts?.immediate !== false ? {} : undefined) + const mapsApi = ref(opts?.immediate !== false ? mocks.mockMapsApi : undefined) + + return { + Provider: defineComponent({ + setup(_, { slots }) { + provide(MAP_INJECTION_KEY, { map, mapsApi } as any) + return () => h('div', slots.default?.()) + }, + }), + map, + mapsApi, + } +} + +async function flushAsync(ticks = 4) { + for (let i = 0; i < ticks; i++) { + await nextTick() + } +} + +describe('useGoogleMapsResource in Nuxt environment', () => { + let mocks: MockAPI + + beforeEach(() => { + mocks = createMockGoogleMapsAPI() + vi.clearAllMocks() + }) + + it('should create and cleanup resource through full mount/unmount cycle', async () => { + const { Provider } = createMapProvider(mocks) + const cleanupFn = vi.fn() + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: () => ({ id: 'test-marker' }), + cleanup: cleanupFn, + }) + return { resource } + }, + render() { + return this.resource ? h('div', 'marker-ready') : h('div', 'loading') + }, + }) + + const wrapper = await mountSuspended(Provider, { + slots: { default: () => h(Child) }, + }) + + await flushAsync() + + expect(wrapper.text()).toContain('marker-ready') + + wrapper.unmount() + + expect(cleanupFn).toHaveBeenCalledOnce() + expect(cleanupFn).toHaveBeenCalledWith( + { id: 'test-marker' }, + { mapsApi: mocks.mockMapsApi }, + ) + }) + + it('should handle async resource creation with unmount race condition', async () => { + const { Provider } = createMapProvider(mocks) + const cleanupFn = vi.fn() + + let resolveCreate: (v: any) => void + const createPromise = new Promise((resolve) => { + resolveCreate = resolve + }) + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: () => createPromise, + cleanup: cleanupFn, + }) + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = await mountSuspended(Provider, { + slots: { default: () => h(Child) }, + }) + + await flushAsync() + + // Unmount before creation completes + wrapper.unmount() + + // Complete creation after unmount + resolveCreate!({ id: 'late-resource' }) + await flushAsync() + + // Should cleanup the late-created resource + expect(cleanupFn).toHaveBeenCalledWith( + { id: 'late-resource' }, + { mapsApi: mocks.mockMapsApi }, + ) + }) + + it('should handle deferred map readiness', async () => { + const { Provider, map, mapsApi } = createMapProvider(mocks, { immediate: false }) + const createFn = vi.fn(() => ({ id: 'deferred' })) + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: createFn, + }) + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = await mountSuspended(Provider, { + slots: { default: () => h(Child) }, + }) + + await flushAsync() + + // Not yet created (map not ready) + expect(createFn).not.toHaveBeenCalled() + + // Make map ready + map.value = {} + mapsApi.value = mocks.mockMapsApi + await flushAsync() + + expect(createFn).toHaveBeenCalledOnce() + + wrapper.unmount() + }) + + it('should handle multiple child components independently', async () => { + const { Provider } = createMapProvider(mocks) + const cleanups: string[] = [] + + function createChild(id: string) { + return defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: () => ({ id }), + cleanup: () => { cleanups.push(id) }, + }) + return { resource } + }, + render() { + return h('div', this.resource?.id) + }, + }) + } + + const Child1 = createChild('marker-1') + const Child2 = createChild('marker-2') + const Child3 = createChild('marker-3') + + const wrapper = await mountSuspended(Provider, { + slots: { + default: () => [h(Child1), h(Child2), h(Child3)], + }, + }) + + await flushAsync() + + expect(wrapper.text()).toContain('marker-1') + expect(wrapper.text()).toContain('marker-2') + expect(wrapper.text()).toContain('marker-3') + + wrapper.unmount() + + // All 3 should be cleaned up independently + expect(cleanups).toHaveLength(3) + expect(cleanups).toContain('marker-1') + expect(cleanups).toContain('marker-2') + expect(cleanups).toContain('marker-3') + }) + + it('should cleanup resources created during async gap after unmount', async () => { + const cleanupFn = vi.fn() + const { Provider } = createMapProvider(mocks) + + let resolveImport: () => void + const importPromise = new Promise((resolve) => { + resolveImport = resolve + }) + + const Child = defineComponent({ + setup() { + useGoogleMapsResource({ + async create({ mapsApi }) { + await importPromise + return { id: 'async-resource', mapsApi } + }, + cleanup: cleanupFn, + }) + return {} + }, + render() { + return h('div') + }, + }) + + const wrapper = await mountSuspended(Provider, { + slots: { default: () => h(Child) }, + }) + + await flushAsync() + + // Unmount while import is pending + wrapper.unmount() + + // Resolve the import — this creates the resource after unmount + resolveImport!() + await flushAsync() + + // The composable should have cleaned up the post-unmount resource + expect(cleanupFn).toHaveBeenCalledOnce() + expect(cleanupFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 'async-resource' }), + expect.objectContaining({ mapsApi: mocks.mockMapsApi }), + ) + }) +}) diff --git a/test/unit/__mocks__/google-maps-api.ts b/test/unit/__mocks__/google-maps-api.ts index dcf574bb6..604499682 100644 --- a/test/unit/__mocks__/google-maps-api.ts +++ b/test/unit/__mocks__/google-maps-api.ts @@ -52,6 +52,19 @@ function createMockClass(instance: T) { return MockClass } +// Class-based mocks that return a fresh instance per call (for multi-instance tests) +function createMockClassFactory(factory: () => T) { + const instances: T[] = [] + // eslint-disable-next-line prefer-arrow-callback + const MockClass = vi.fn(function () { + const instance = factory() + instances.push(instance) + return instance + }) as unknown as (new (...args: any[]) => T) & ReturnType & { instances: T[] } + MockClass.instances = instances + return MockClass +} + export function createMockGoogleMapsAPI() { const mockMarker = createMockMarker() const mockAdvancedMarkerElement = createMockAdvancedMarkerElement() @@ -95,6 +108,69 @@ export function createMockGoogleMapsAPI() { } } +/** + * Creates a mock Google Maps API where each constructor returns a unique instance. + * Use this for tests that mount multiple components of the same type. + */ +export function createMockGoogleMapsAPIWithInstances() { + const MockMarker = createMockClassFactory(createMockMarker) + const MockAdvancedMarkerElement = createMockClassFactory(createMockAdvancedMarkerElement) + const MockPinElement = createMockClassFactory(createMockPinElement) + const MockInfoWindow = createMockClassFactory(createMockInfoWindow) + const MockCircle = createMockClassFactory(() => ({ setOptions: vi.fn(), setMap: vi.fn(), addListener: vi.fn() })) + const MockPolygon = createMockClassFactory(() => ({ setOptions: vi.fn(), setMap: vi.fn(), addListener: vi.fn() })) + const MockPolyline = createMockClassFactory(() => ({ setOptions: vi.fn(), setMap: vi.fn(), addListener: vi.fn() })) + const MockRectangle = createMockClassFactory(() => ({ setOptions: vi.fn(), setMap: vi.fn(), addListener: vi.fn() })) + const MockHeatmapLayer = createMockClassFactory(() => ({ setOptions: vi.fn(), setMap: vi.fn() })) + + const mockMapsApi = { + Marker: MockMarker, + marker: { + AdvancedMarkerElement: MockAdvancedMarkerElement, + PinElement: MockPinElement, + }, + InfoWindow: MockInfoWindow, + Circle: MockCircle, + Polygon: MockPolygon, + Polyline: MockPolyline, + Rectangle: MockRectangle, + visualization: { + HeatmapLayer: MockHeatmapLayer, + }, + event: { + clearInstanceListeners: vi.fn(), + }, + importLibrary: vi.fn().mockImplementation((key: string) => { + if (key === 'marker') { + return Promise.resolve({ + AdvancedMarkerElement: MockAdvancedMarkerElement, + PinElement: MockPinElement, + }) + } + if (key === 'visualization') { + return Promise.resolve({ + HeatmapLayer: MockHeatmapLayer, + }) + } + return Promise.resolve({}) + }), + LatLng: vi.fn((lat: number, lng: number) => ({ lat, lng })), + } + + return { + mockMapsApi, + MockMarker, + MockAdvancedMarkerElement, + MockPinElement, + MockInfoWindow, + MockCircle, + MockPolygon, + MockPolyline, + MockRectangle, + MockHeatmapLayer, + } +} + // Standard event types used across components export const MARKER_EVENTS_WITHOUT_PAYLOAD = [ 'animation_changed', diff --git a/test/unit/google-maps-lifecycle.test.ts b/test/unit/google-maps-lifecycle.test.ts new file mode 100644 index 000000000..bfc77caf9 --- /dev/null +++ b/test/unit/google-maps-lifecycle.test.ts @@ -0,0 +1,370 @@ +/** + * @vitest-environment happy-dom + */ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, onUnmounted, provide, ref, shallowRef } from 'vue' +import { MAP_INJECTION_KEY } from '../../src/runtime/components/GoogleMaps/injectionKeys' +import { useGoogleMapsResource } from '../../src/runtime/components/GoogleMaps/useGoogleMapsResource' +import { createMockGoogleMapsAPI } from './__mocks__/google-maps-api' + +// Helper to create a wrapper component that provides mock map context +function createMapProvider(mocks: ReturnType, opts?: { immediate?: boolean }) { + const map = shallowRef(opts?.immediate !== false ? {} : undefined) + const mapsApi = ref(opts?.immediate !== false ? mocks.mockMapsApi : undefined) + + return defineComponent({ + setup(_, { slots }) { + provide(MAP_INJECTION_KEY, { map, mapsApi } as any) + return () => h('div', slots.default?.()) + }, + // Expose refs for test manipulation + data: () => ({ map, mapsApi }), + }) +} + +describe('useGoogleMapsResource', () => { + let mocks: ReturnType + + beforeEach(() => { + mocks = createMockGoogleMapsAPI() + vi.clearAllMocks() + }) + + it('should create resource when map context is ready', async () => { + const createFn = vi.fn(() => ({ id: 'test-resource' })) + const Provider = createMapProvider(mocks) + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: createFn, + }) + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + await nextTick() + + expect(createFn).toHaveBeenCalledOnce() + expect(createFn).toHaveBeenCalledWith( + expect.objectContaining({ map: expect.anything(), mapsApi: expect.anything() }), + ) + + wrapper.unmount() + }) + + it('should call cleanup on unmount and null the resource ref', async () => { + const resource = { id: 'test-resource' } + const cleanupFn = vi.fn() + const Provider = createMapProvider(mocks) + + const Child = defineComponent({ + setup() { + const ref = useGoogleMapsResource({ + create: () => resource, + cleanup: cleanupFn, + }) + return { ref } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + await nextTick() + + // Verify resource was created + const child = wrapper.findComponent(Child) + expect(child.vm.ref).toBe(resource) + + // Unmount + wrapper.unmount() + + expect(cleanupFn).toHaveBeenCalledWith(resource, { mapsApi: mocks.mockMapsApi }) + }) + + it('should not create resource if unmounted during async creation', async () => { + let resolveCreate: (value: any) => void + const createPromise = new Promise((resolve) => { + resolveCreate = resolve + }) + const cleanupFn = vi.fn() + const Provider = createMapProvider(mocks) + let resourceRef: any + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: () => createPromise, + cleanup: cleanupFn, + }) + resourceRef = resource + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + + // Unmount before create resolves + wrapper.unmount() + + // Now resolve the create + const resource = { id: 'created-after-unmount' } + resolveCreate!(resource) + await nextTick() + + // Resource should NOT be assigned (ref was nulled on unmount) + expect(resourceRef.value).toBeUndefined() + + // Cleanup should be called on the newly created resource to prevent leak + expect(cleanupFn).toHaveBeenCalledWith(resource, { mapsApi: mocks.mockMapsApi }) + }) + + it('should not create resource if map context is not ready', async () => { + const createFn = vi.fn(() => ({ id: 'test' })) + const Provider = createMapProvider(mocks, { immediate: false }) + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: createFn, + }) + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + await nextTick() + + expect(createFn).not.toHaveBeenCalled() + + wrapper.unmount() + }) + + it('should respect additional ready condition', async () => { + const createFn = vi.fn(() => ({ id: 'test' })) + const isReady = ref(false) + const Provider = createMapProvider(mocks) + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + ready: () => isReady.value, + create: createFn, + }) + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + await nextTick() + + expect(createFn).not.toHaveBeenCalled() + + isReady.value = true + await nextTick() + await nextTick() + + expect(createFn).toHaveBeenCalledOnce() + + wrapper.unmount() + }) + + it('should only call create once (once: true semantics)', async () => { + const createFn = vi.fn(() => ({ id: 'test' })) + const Provider = createMapProvider(mocks) + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create: createFn, + }) + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + await nextTick() + await nextTick() + + expect(createFn).toHaveBeenCalledOnce() + + wrapper.unmount() + }) +}) + +describe('google Maps component lifecycle - memory leak prevention', () => { + let mocks: ReturnType + + beforeEach(() => { + mocks = createMockGoogleMapsAPI() + vi.clearAllMocks() + }) + + it('should not leave orphaned watchers after unmount', async () => { + // This test verifies the core fix: options watchers are created in sync setup scope + // and auto-stopped by Vue on unmount, rather than being orphaned after an await + const Provider = createMapProvider(mocks) + const unmountedCallbacks: (() => void)[] = [] + + const Child = defineComponent({ + setup() { + const resource = useGoogleMapsResource({ + create() { + return { id: 'test', setOptions: vi.fn() } + }, + cleanup(r) { + // Resource should be cleaned up + expect(r.id).toBe('test') + }, + }) + + onUnmounted(() => { + unmountedCallbacks.push(() => { + // After unmount, the resource ref should be undefined + expect(resource.value).toBeUndefined() + }) + }) + + return { resource } + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + await nextTick() + + wrapper.unmount() + + // Run post-unmount checks + unmountedCallbacks.forEach(cb => cb()) + }) + + it('should handle rapid mount/unmount cycles without leaking', async () => { + const Provider = createMapProvider(mocks) + const cleanupCount = ref(0) + + const Child = defineComponent({ + setup() { + useGoogleMapsResource({ + create: () => ({ id: Math.random() }), + cleanup: () => { cleanupCount.value++ }, + }) + return {} + }, + render() { + return h('div') + }, + }) + + // Rapid mount/unmount cycle + for (let i = 0; i < 5; i++) { + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + await nextTick() + await nextTick() + wrapper.unmount() + } + + // Each mount should have resulted in a cleanup + expect(cleanupCount.value).toBe(5) + }) + + it('should cleanup resources created during async gap after unmount', async () => { + // Simulates: component mounts → starts async importLibrary → unmounts → import resolves → resource created + // The composable should clean up the resource even though the component is already unmounted + const cleanupFn = vi.fn() + const Provider = createMapProvider(mocks) + + let resolveImport: () => void + const importPromise = new Promise((resolve) => { + resolveImport = resolve + }) + + const Child = defineComponent({ + setup() { + useGoogleMapsResource({ + async create({ mapsApi }) { + await importPromise + return { id: 'async-resource', mapsApi } + }, + cleanup: cleanupFn, + }) + return {} + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(Provider, { + slots: { default: () => h(Child) }, + }) + + await nextTick() + + // Unmount while import is pending + wrapper.unmount() + + // Resolve the import — this creates the resource after unmount + resolveImport!() + await nextTick() + await nextTick() + + // The composable should have cleaned up the post-unmount resource + expect(cleanupFn).toHaveBeenCalledOnce() + expect(cleanupFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 'async-resource' }), + expect.objectContaining({ mapsApi: mocks.mockMapsApi }), + ) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 381c51975..8cc5492d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "test/e2e", "test/fixtures", "test/unit", + "test/nuxt-runtime", "test/benchmark.skip.ts", "examples" ]