Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,47 @@ function getCenterWatchKey(center: ScriptGoogleMapsCenter): string | undefined {
return undefined
}

const controlledCenterKey = computed(() => {
return getCenterWatchKey(centerOverride.value)
|| getCenterWatchKey(props.mapOptions?.center)
|| getCenterWatchKey(props.center)
})

function getReactiveMapOptions(options: google.maps.MapOptions): google.maps.MapOptions {
// Exclude center and zoom — they have dedicated watchers that avoid
// resetting user interactions (pan/zoom) on unrelated re-renders.
// Exclude mapId and colorScheme — Google Maps treats these as init-only;
// changes are handled by the dedicated re-init watcher below.
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options
return rest
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object')
return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}

function isSameOptionValue(a: unknown, b: unknown): boolean {
if (Object.is(a, b))
return true
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((value, index) => isSameOptionValue(value, b[index]))
}
if (isPlainObject(a) && isPlainObject(b)) {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
return aKeys.length === bKeys.length
&& aKeys.every(key => Object.hasOwn(b, key) && isSameOptionValue(a[key], b[key]))
}
return false
}

function isSameMapOptions(a: google.maps.MapOptions, b: google.maps.MapOptions): boolean {
return isSameOptionValue(toRaw(a), toRaw(b))
}

const queryToLatLngCache = new Map<string, google.maps.LatLng | google.maps.LatLngLiteral>()

async function resolveQueryToLatLng(query: string) {
Expand Down Expand Up @@ -401,15 +442,12 @@ onMounted(() => {
emits('error')
}
})
watch(options, () => {
watch(() => getReactiveMapOptions(options.value), (nextOptions, previousOptions) => {
if (!map.value)
return
// Exclude center and zoom — they have dedicated watchers that avoid
// resetting user interactions (pan/zoom) on unrelated re-renders.
// Exclude mapId and colorScheme — Google Maps treats these as init-only;
// changes are handled by the dedicated re-init watcher below.
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...rest } = options.value
map.value.setOptions(rest)
if (isSameMapOptions(nextOptions, previousOptions))
return
map.value.setOptions(nextOptions)
})
// Re-init map when mapId or colorScheme changes (e.g. user toggles color mode
// with `mapIds` set or with cloud-based styling on a single mapId). Both are
Expand Down Expand Up @@ -458,16 +496,17 @@ onMounted(() => {
emits('ready', exposed)
})
watch(() => options.value.zoom, (zoom) => {
if (map.value && zoom != null)
if (map.value && zoom != null) {
map.value.setZoom(zoom)
}
})
// Clear centerOverride when the controlled center prop changes so external
// updates take effect (otherwise centerOverride, written from the user's
// pan during re-init, would permanently win over future prop updates).
watch([() => getCenterWatchKey(props.center), () => getCenterWatchKey(props.mapOptions?.center)], () => {
centerOverride.value = undefined
})
watch([() => getCenterWatchKey(options.value.center), isMapReady, map], async () => {
watch([controlledCenterKey, isMapReady, map], async () => {
if (!map.value) {
return
}
Expand Down
2 changes: 1 addition & 1 deletion playground/pages/third-parties/google-maps/sfcs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ whenever(() => googleMapsRef.value?.googleMaps, (googleMaps) => {
api-key="AIzaSyAOEIQ_xOdLx2dNwnFMzyJoswwvPCTcGzU"
:width="1280"
:height="720"
:zoom="zoom"
:map-options="{
center: { lat: -34.397, lng: 150.644 },
zoom,
mapId: 'DEMO_MAP_ID',
}"
>
Expand Down
96 changes: 96 additions & 0 deletions test/unit/google-maps-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,39 @@ describe('google Maps Regressions', () => {
map.setOptions(rest)
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object')
return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}

function isSameOptionValue(a: unknown, b: unknown): boolean {
if (Object.is(a, b))
return true
if (Array.isArray(a) && Array.isArray(b))
return a.length === b.length && a.every((value, index) => isSameOptionValue(value, b[index]))
if (isPlainObject(a) && isPlainObject(b)) {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
return aKeys.length === bKeys.length
&& aKeys.every(key => Object.hasOwn(b, key) && isSameOptionValue(a[key], b[key]))
}
return false
}

function applyOptionsWithStableGuard(
map: ReturnType<typeof createMockMap>,
options: Record<string, any>,
previousOptions: Record<string, any>,
) {
const { center: _, zoom: __, mapId: ___, colorScheme: ____, ...next } = options
const { center: _previousCenter, zoom: _previousZoom, mapId: _previousMapId, colorScheme: _previousColorScheme, ...previous } = previousOptions
if (isSameOptionValue(next, previous))
return
map.setOptions(next)
}

it('old behavior: setOptions resets zoom and center on unrelated re-render', () => {
const map = createMockMap()
const options = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc' }
Expand Down Expand Up @@ -402,6 +435,35 @@ describe('google Maps Regressions', () => {
expect(map.setCenter).not.toHaveBeenCalled()
expect(map.setZoom).not.toHaveBeenCalled()
})

it('fixed behavior: identical inline options do not call setOptions on unrelated re-renders', () => {
const map = createMockMap()
const baseOptions = {
center: { lat: 40, lng: -74 },
zoom: 12,
mapId: 'abc',
disableDefaultUI: true,
restriction: { strictBounds: false },
}

for (let i = 0; i < 3; i++) {
applyOptionsWithStableGuard(map, { ...baseOptions, restriction: { strictBounds: false } }, baseOptions)
}

expect(map.setOptions).not.toHaveBeenCalled()
expect(map.setCenter).not.toHaveBeenCalled()
expect(map.setZoom).not.toHaveBeenCalled()
})

it('fixed behavior: real non-position option changes still call setOptions', () => {
const map = createMockMap()
const previousOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: true }
const nextOptions = { center: { lat: 40, lng: -74 }, zoom: 12, mapId: 'abc', disableDefaultUI: false }

applyOptionsWithStableGuard(map, nextOptions, previousOptions)

expect(map.setOptions).toHaveBeenCalledWith({ disableDefaultUI: false })
})
})

describe('center watcher should skip setCenter when lat/lng unchanged', () => {
Expand Down Expand Up @@ -476,6 +538,40 @@ describe('google Maps Regressions', () => {
expect(getCenterWatchKey(firstRender.center)).toBe(getCenterWatchKey(secondRender.center))
})

it('keeps the controlled center watch key stable across unrelated inline option re-renders', () => {
function getCenterWatchKey(center: any) {
if (!center)
return undefined
const lat = typeof center.lat === 'function' ? center.lat() : center.lat
const lng = typeof center.lng === 'function' ? center.lng() : center.lng
return `latlng:${lat},${lng}`
}

function getControlledCenterKey(props: { center?: any, mapOptions?: { center?: any } }, centerOverride?: any) {
return getCenterWatchKey(centerOverride)
|| getCenterWatchKey(props.mapOptions?.center)
|| getCenterWatchKey(props.center)
}

const firstRender = {
mapOptions: {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
},
}
const secondRenderAfterRectangleToggle = {
mapOptions: {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
},
}

expect(firstRender.mapOptions).not.toBe(secondRenderAfterRectangleToggle.mapOptions)
expect(firstRender.mapOptions.center).not.toBe(secondRenderAfterRectangleToggle.mapOptions.center)
expect(getControlledCenterKey(firstRender))
.toBe(getControlledCenterKey(secondRenderAfterRectangleToggle))
})

it('changes the center watch key when coordinates actually change', () => {
function getCenterWatchKey(center: any) {
const lat = typeof center.lat === 'function' ? center.lat() : center.lat
Expand Down
Loading