diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue index 29c53585..5223db13 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue @@ -225,11 +225,18 @@ function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: numb // available after the script loads, so this stays a function rather than // a top-level class declaration. function makeOverlayClass(mapsApi: typeof google.maps, map: google.maps.Map) { + // Capture the anchor element at onAdd time so onRemove can detach it even + // after Vue has nulled the template ref during component unmount. Without + // this, `v-if="false"` leaves the reparented element in the Google Maps + // pane because `overlayAnchor.value` is already null when setMap(null) + // triggers onRemove. + let attachedEl: HTMLElement | null = null return class CustomOverlay extends mapsApi.OverlayView { override onAdd() { const panes = this.getPanes() const el = overlayAnchor.value if (panes && el) { + attachedEl = el panes[pane].appendChild(el) if (blockMapInteraction) mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el) @@ -274,8 +281,8 @@ function makeOverlayClass(mapsApi: typeof google.maps, map: google.maps.Map) { } override onRemove() { - const el = overlayAnchor.value - el?.parentNode?.removeChild(el) + attachedEl?.parentNode?.removeChild(attachedEl) + attachedEl = null } } } diff --git a/test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts b/test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts index fb5b6e76..4d0bb60b 100644 --- a/test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts +++ b/test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts @@ -413,4 +413,52 @@ describe('scriptGoogleMapsOverlayView', () => { expect(mocks.mockMap.panBy).not.toHaveBeenCalled() }) }) + + describe('unmount cleanup', () => { + // Regression: https://github.com/nuxt/scripts/issues/735 + // `` did not detach its overlay + // element from the Google Maps pane on unmount, leaving a stale node + // visible on the map. Cause: `onRemove()` read the anchor via + // `useTemplateRef`, which Vue nulls during component unmount before + // `onUnmounted` fires (and thus before `setMap(null)` triggers + // `onRemove`). The fix captures the element at `onAdd` time. + it('detaches the anchor element from its pane when v-if toggles false', async () => { + const mocks = createOverlayMocks() + const Provider = createMapProvider(mocks) + const show = shallowRef(true) + + const wrapper = await mountSuspended(Provider, { + slots: { + default: () => (show.value + ? h( + ScriptGoogleMapsOverlayView, + { position: { lat: 10, lng: 20 } }, + () => h('div', { class: 'overlay-content' }), + ) + : null), + }, + }) + + await nextTick() + await nextTick() + await nextTick() + + const overlayWrapper = wrapper.findComponent(ScriptGoogleMapsOverlayView) + const anchor = (overlayWrapper.vm as any).$refs['overlay-anchor'] as HTMLElement + expect(anchor).toBeTruthy() + // After onAdd, the anchor is reparented into a Google Maps pane, so it + // has a parentNode that is not the component's hidden wrapper. + expect(anchor.parentNode).toBeTruthy() + const paneBeforeUnmount = anchor.parentNode! + + // Unmount the component via v-if. The cleanup must remove the anchor + // from the pane so it does not linger on the map. + show.value = false + await wrapper.setProps({}) + await nextTick() + await nextTick() + + expect(paneBeforeUnmount.contains(anchor)).toBe(false) + }) + }) })