From 9918a8646022beedec5ced3c5d57823ac448884f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 01:11:26 +1000 Subject: [PATCH] fix(google-maps): guard pan-on-open for closed/unpositioned overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` previously scheduled `panMapToFitOverlay` from `onAdd()` whenever `panOnOpen` was enabled, even when the overlay started closed or `draw()` had not yet resolved a position. That caused unexpected map panning on initial mount and remount when the overlay was hidden. Add two guards: 1. Before scheduling the rAF, check `open !== false`. This skips the rAF entirely when the overlay is mounted closed. 2. Inside the rAF callback, re-check `open` and `overlayPosition` (and the anchor element). The state may have changed during the frame: the controlled `open` could flip to false, the position may not have resolved, or the component may have unmounted. Tests cover the four pan-on-open paths: - defaultOpen=false → no rAF scheduled - :open=false (controlled) → no rAF scheduled - panOnOpen=false → no rAF scheduled - happy path (open + positioned) → rAF still scheduled --- .../ScriptGoogleMapsOverlayView.vue | 10 ++- .../google-maps-overlay-view.nuxt.test.ts | 61 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue index 113f8420..29c53585 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue @@ -234,11 +234,15 @@ function makeOverlayClass(mapsApi: typeof google.maps, map: google.maps.Map) { if (blockMapInteraction) mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el) } - if (panOnOpen) { - // Wait for draw() to position the element, then pan + if (panOnOpen && open.value !== false) { + // Wait for draw() to position the element, then pan. Re-check inside + // the rAF callback so we don't pan when: + // - the controlled `open` prop flipped to false during the frame + // - draw() never resolved a position (closed/missing position) + // - the anchor element is gone (component unmounted mid-frame) const padding = typeof panOnOpen === 'number' ? panOnOpen : 40 requestAnimationFrame(() => { - if (overlayAnchor.value) + if (open.value !== false && overlayAnchor.value && overlayPosition.value) panMapToFitOverlay(overlayAnchor.value, map, padding) }) } 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 e8212436..b94e960b 100644 --- a/test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts +++ b/test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts @@ -1,6 +1,6 @@ /// import { mountSuspended } from '@nuxt/test-utils/runtime' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, nextTick, provide, shallowRef } from 'vue' import ScriptGoogleMapsOverlayView from '../../packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue' import { MAP_INJECTION_KEY, normalizeLatLng } from '../../packages/script/src/runtime/components/GoogleMaps/useGoogleMapsResource' @@ -347,4 +347,63 @@ describe('scriptGoogleMapsOverlayView', () => { expect(content.dataset.state).toBe('open') }) }) + + describe('panOnOpen guard for closed/unpositioned overlays', () => { + // Regression: `onAdd()` previously scheduled `panMapToFitOverlay` whenever + // `panOnOpen` was enabled, even if the overlay started closed or never + // resolved a position. That caused unexpected map panning on initial mount + // and remount. The fix gates the rAF scheduling on `open !== false` and + // re-checks `open` + `overlayPosition` inside the callback before panning. + + let rafSpy: ReturnType + + beforeEach(() => { + rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame') + }) + + afterEach(() => { + rafSpy.mockRestore() + }) + + it('does not schedule pan-on-open when overlay starts closed (defaultOpen=false)', async () => { + const mocks = createOverlayMocks() + await mountOverlay( + { position: { lat: 10, lng: 20 }, defaultOpen: false }, + mocks, + ) + + expect(rafSpy).not.toHaveBeenCalled() + expect(mocks.mockMap.panBy).not.toHaveBeenCalled() + }) + + it('does not schedule pan-on-open when controlled :open is false on mount', async () => { + const mocks = createOverlayMocks() + await mountOverlay( + { position: { lat: 10, lng: 20 }, open: false }, + mocks, + ) + + expect(rafSpy).not.toHaveBeenCalled() + expect(mocks.mockMap.panBy).not.toHaveBeenCalled() + }) + + it('schedules pan-on-open when overlay starts open with a position', async () => { + const mocks = createOverlayMocks() + await mountOverlay({ position: { lat: 10, lng: 20 } }, mocks) + + // The guard allows the rAF to be scheduled when the happy path applies + expect(rafSpy).toHaveBeenCalled() + }) + + it('respects panOnOpen=false even when overlay is open and positioned', async () => { + const mocks = createOverlayMocks() + await mountOverlay( + { position: { lat: 10, lng: 20 }, panOnOpen: false }, + mocks, + ) + + expect(rafSpy).not.toHaveBeenCalled() + expect(mocks.mockMap.panBy).not.toHaveBeenCalled() + }) + }) })