From 9d99eea655bd12f0f8d83bdd64c2f224413f43e7 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 1 May 2026 11:46:39 +1000 Subject: [PATCH] fix(google-maps): detach overlay element on unmount 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, toggling v-if=false leaves the reparented element in the Google Maps pane because overlayAnchor.value is already null when setMap(null) triggers onRemove. Resolves #735 --- .../ScriptGoogleMapsOverlayView.vue | 11 ++++- .../google-maps-overlay-view.nuxt.test.ts | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) 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) + }) + }) })