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
25 changes: 25 additions & 0 deletions docs/content/docs/4.migration-guide/1.v0-to-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

Third-party scripts expose data that enables fingerprinting users across sites. Every request shares the user's IP address, and scripts can set third-party cookies for cross-site tracking.

First-party mode acts as a reverse proxy: scripts are bundled at build time and served from your domain, while runtime collection requests are forwarded through Nitro server routes with automatic anonymisation. It is **auto-enabled** for scripts that support it, zero-config:

Check warning on line 14 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "are bundled". Consider rewriting in active voice

Check warning on line 14 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "are bundled". Consider rewriting in active voice

```ts
export default defineNuxtConfig({
Expand Down Expand Up @@ -220,7 +220,7 @@
})
```

If you only need infrastructure without loading the script on the page, set `trigger: false` explicitly. This registers proxy routes, TypeScript types, and bundling config, but no `<script>`{lang="html"} tag is injected. Useful when you load the script yourself via a component or composable.

Check warning on line 223 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is injected". Consider rewriting in active voice

Check warning on line 223 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is injected". Consider rewriting in active voice

```ts [nuxt.config.ts]
export default defineNuxtConfig({
Expand Down Expand Up @@ -414,6 +414,31 @@
})
```

#### `<ScriptGoogleMapsOverlayView>`{lang="html"} DOM Structure

The overlay view now renders an extra wrapper element. The structure is `anchor div β†’ content div β†’ slot`, where the anchor handles positioning (Google Maps reparents it into a pane on mount) and the content div carries `data-state` plus any forwarded `class`/attrs.

CSS that targets `[data-state="open"]` on the slot's root element should move to a class on the `<ScriptGoogleMapsOverlayView>`{lang="html"} itself, since the data-state attribute now lives on the internal content div rather than propagating into the slot's first child.

```diff
<template>
- <ScriptGoogleMapsOverlayView v-model:open="open">
- <div class="overlay-popup">
- ...
- </div>
- </ScriptGoogleMapsOverlayView>
+ <ScriptGoogleMapsOverlayView class="overlay-popup" v-model:open="open">
+ ...
+ </ScriptGoogleMapsOverlayView>
</template>

<style scoped>
-.overlay-popup[data-state="open"] { animation: ... }
+/* Use :deep() in scoped styles since the class lands on a child component's element */
+:deep(.overlay-popup[data-state="open"]) { animation: ... }
</style>
```

### Google Maps Static Placeholder ([#673](https://github.com/nuxt/scripts/pull/673))

v1 extracts the built-in static map placeholder into a standalone [`<ScriptGoogleMapsStaticMap>`{lang="html"}](/scripts/google-maps/api/static-map) component. This removes the following props from `<ScriptGoogleMaps>`{lang="html"}:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export interface ScriptGoogleMapsOverlayViewExpose {
</script>

<script setup lang="ts">
import { computed, inject, ref, useTemplateRef, watch } from 'vue'
import { computed, inject, shallowRef, useTemplateRef, watch } from 'vue'
import { MARKER_CLUSTERER_INJECTION_KEY } from './types'
import { defineDeprecatedAlias, MARKER_INJECTION_KEY, normalizeLatLng, useGoogleMapsResource } from './useGoogleMapsResource'

Expand Down Expand Up @@ -159,30 +159,45 @@ const ANCHOR_TRANSFORMS: Record<ScriptGoogleMapsOverlayAnchor, string> = {
'right-center': 'translate(-100%, -50%)',
}

const overlayContent = useTemplateRef('overlay-content')
const overlayAnchor = useTemplateRef('overlay-anchor')

// Reactive open/closed state for CSS animations via data-state attribute.
// Tracks whether the overlay content is positioned and should be visually open.
const isPositioned = ref(false)
const dataState = computed(() => isPositioned.value ? 'open' : 'closed')
// Reactive pixel position written by `draw()`. The template style binding
// on the anchor element reads it, so position updates flow through Vue's
// reactivity instead of imperative `el.style.left/top` writes.
const overlayPosition = shallowRef<{ x: number, y: number } | undefined>(undefined)

// Track all event listeners for clean teardown
const listeners: google.maps.MapsEventListener[] = []
// `dataState` reflects the visible/hidden state of the overlay. It is bound
// directly on the content element so CSS animations targeting `[data-state]`
// react without any imperative DOM writes.
const dataState = computed<'open' | 'closed'>(() =>
open.value !== false && overlayPosition.value !== undefined ? 'open' : 'closed',
)

function setDataState(el: HTMLElement, state: 'open' | 'closed') {
el.dataset.state = state
// Propagate to the slot's root element imperatively (Vue template bindings
// don't reliably patch elements that have been moved to Google Maps panes)
const child = el.firstElementChild as HTMLElement | null
if (child)
child.dataset.state = state
}
// Computed style for the anchor element. Vue patches the moved DOM node via
// this binding even after Google Maps has reparented it into a pane.
const overlayStyle = computed<Record<string, string | undefined>>(() => {
const visible = open.value !== false && overlayPosition.value !== undefined
if (!visible) {
return {
position: 'absolute',
visibility: 'hidden',
pointerEvents: 'none',
}
}
const { x, y } = overlayPosition.value!
return {
position: 'absolute',
left: `${x + (offset?.x ?? 0)}px`,
top: `${y + (offset?.y ?? 0)}px`,
transform: ANCHOR_TRANSFORMS[anchor],
zIndex: zIndex !== undefined ? String(zIndex) : undefined,
visibility: 'visible',
pointerEvents: 'auto',
}
})

function hideElement(el: HTMLElement) {
el.style.visibility = 'hidden'
el.style.pointerEvents = 'none'
setDataState(el, 'closed')
}
// Track all event listeners for clean teardown
const listeners: google.maps.MapsEventListener[] = []

function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: number) {
const child = el.firstElementChild
Expand All @@ -204,87 +219,76 @@ function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: numb
map.panBy(panX, panY)
}

const overlay = useGoogleMapsResource<google.maps.OverlayView>({
// ready condition accesses .value on ShallowRefs β€” tracked by whenever() in useGoogleMapsResource
ready: () => !!overlayContent.value
&& !!(position || markerContext?.advancedMarkerElement.value),
create({ mapsApi, map }) {
const el = overlayContent.value!

class CustomOverlay extends mapsApi.OverlayView {
override onAdd() {
const panes = this.getPanes()
if (panes) {
panes[pane].appendChild(el)
if (blockMapInteraction)
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
}
if (panOnOpen) {
// Wait for draw() to position the element, then pan
const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
requestAnimationFrame(() => {
panMapToFitOverlay(el, map, padding)
})
}
// Factory that builds the OverlayView subclass. Lifted out of `create()`
// so the create callback stays focused on wiring (instantiation, listeners).
// The class still has to extend `mapsApi.OverlayView`, which is only
// 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) {
return class CustomOverlay extends mapsApi.OverlayView {
override onAdd() {
const panes = this.getPanes()
const el = overlayAnchor.value
if (panes && el) {
panes[pane].appendChild(el)
if (blockMapInteraction)
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
}

override draw() {
// v-model:open support: hide when explicitly closed
if (open.value === false) {
isPositioned.value = false
hideElement(el)
return
}

const resolvedPosition = getResolvedPosition()
if (!resolvedPosition) {
isPositioned.value = false
hideElement(el)
return
}
const projection = this.getProjection()
if (!projection) {
isPositioned.value = false
hideElement(el)
return
}
const pos = projection.fromLatLngToDivPixel(
new mapsApi.LatLng(resolvedPosition.lat, resolvedPosition.lng),
)
if (!pos) {
isPositioned.value = false
hideElement(el)
return
}

el.style.position = 'absolute'
el.style.left = `${pos.x + (offset?.x ?? 0)}px`
el.style.top = `${pos.y + (offset?.y ?? 0)}px`
el.style.transform = ANCHOR_TRANSFORMS[anchor]
if (zIndex !== undefined)
el.style.zIndex = String(zIndex)
el.style.visibility = 'visible'
el.style.pointerEvents = 'auto'
setDataState(el, 'open')
isPositioned.value = true
if (panOnOpen) {
// Wait for draw() to position the element, then pan
const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
requestAnimationFrame(() => {
if (overlayAnchor.value)
panMapToFitOverlay(overlayAnchor.value, map, padding)
})
}
Comment on lines +237 to 244
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard pan-on-open so closed/unpositioned overlays do not pan the map.

On Line 237, panMapToFitOverlay is scheduled whenever panOnOpen is enabled, even if open is false or draw() cannot resolve a position. That can cause unexpected map panning on initial mount/remount while the overlay is closed.

πŸ”§ Suggested fix
-      if (panOnOpen) {
+      if (panOnOpen && open.value !== false) {
         // Wait for draw() to position the element, then pan
         const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
         requestAnimationFrame(() => {
-          if (overlayAnchor.value)
+          if (overlayAnchor.value && overlayPosition.value)
             panMapToFitOverlay(overlayAnchor.value, map, padding)
         })
       }
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue`
around lines 237 - 244, The pan logic triggers even when the overlay is closed
or unpositioned; change the block using panOnOpen so it first checks the
overlay's open state and that overlayAnchor.value is positioned before
scheduling panMapToFitOverlay. Specifically, update the conditional around
panOnOpen to also verify the component's open flag (open) and that
overlayAnchor.value exists and has a valid position (e.g.,
overlayAnchor.value.getPosition() or similar) before calling
requestAnimationFrame and panMapToFitOverlay; keep the existing padding
calculation and only call panMapToFitOverlay when those guards pass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch β€” verified this is pre-existing on main: the if (panOnOpen) { requestAnimationFrame(...) } block had no open/position guard before this PR either, the refactor just moved it into the new factory. Keeping PR E focused on the class extraction + reactive position rendering and tracking the pan-on-open guard as a separate follow-up so the fix lands with its own regression test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harlan-zw, thanks for verifying the pre-existing behaviour β€” makes sense to keep the fix out of scope here and ship it alongside a dedicated regression test. Would you like me to open a GitHub issue to track the pan-on-open guard fix (adding the open.value !== false and overlayPosition.value guards with a regression test)?

}

override onRemove() {
el.parentNode?.removeChild(el)
override draw() {
if (open.value === false) {
overlayPosition.value = undefined
return
}
const resolvedPosition = getResolvedPosition()
if (!resolvedPosition) {
overlayPosition.value = undefined
return
}
const projection = this.getProjection()
if (!projection) {
overlayPosition.value = undefined
return
}
const pos = projection.fromLatLngToDivPixel(
new mapsApi.LatLng(resolvedPosition.lat, resolvedPosition.lng),
)
if (!pos) {
overlayPosition.value = undefined
return
}
overlayPosition.value = { x: pos.x, y: pos.y }
}

// Prevent flash: hide until first draw() positions content
el.style.visibility = 'hidden'
el.style.pointerEvents = 'none'
override onRemove() {
const el = overlayAnchor.value
el?.parentNode?.removeChild(el)
}
}
}

const overlay = useGoogleMapsResource<google.maps.OverlayView>({
// ready condition accesses .value on ShallowRefs β€” tracked by whenever() in useGoogleMapsResource
ready: () => !!overlayAnchor.value
&& !!(position || markerContext?.advancedMarkerElement.value),
create({ mapsApi, map }) {
const CustomOverlay = makeOverlayClass(mapsApi, map)
const ov = new CustomOverlay()
ov.setMap(map)

// Follow parent marker position changes
// Follow parent marker position changes. AdvancedMarkerElement fires
// `drag` continuously during drag, so the overlay tracks live.
if (markerContext?.advancedMarkerElement.value) {
const ame = markerContext.advancedMarkerElement.value
// AdvancedMarkerElement fires drag continuously during drag
listeners.push(
ame.addListener('drag', () => ov.draw()),
ame.addListener('dragend', () => ov.draw()),
Expand Down Expand Up @@ -386,8 +390,20 @@ defineExpose<ScriptGoogleMapsOverlayViewExpose>(exposed)

<template>
<div style="display: none;">
<div ref="overlay-content" :data-state="dataState" v-bind="$attrs">
<slot />
<!--
Two-element structure:
- `overlay-anchor` is moved into a Google Maps pane on `onAdd()`. Its
inline style is reactively bound to `overlayStyle`, so position
updates from `draw()` flow through Vue's patcher even after the node
has been reparented out of the component tree.
- `overlay-content` carries `data-state`, attribute-based animations,
and forwards parent attrs (e.g. `class`) so consumers can target it
directly with `[data-state]` selectors.
-->
<div ref="overlay-anchor" :style="overlayStyle">
<div :data-state="dataState" v-bind="$attrs">
<slot />
</div>
</div>
</div>
</template>
55 changes: 30 additions & 25 deletions playground/pages/third-parties/google-maps/overlay-animated.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,53 +45,58 @@ function close(id: number) {
@click="toggle(place.id)"
>
<ScriptGoogleMapsOverlayView
class="overlay-popup"
:open="isOpen(place.id)"
anchor="bottom-center"
:offset="{ x: 0, y: -50 }"
@update:open="(v: boolean) => { if (!v) close(place.id) }"
>
<div class="overlay-popup">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-gray-900">
{{ place.name }}
</h3>
<button
class="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click.stop="close(place.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="mt-1 flex items-center gap-1 text-xs text-gray-500">
<span class="font-medium text-yellow-500">β˜… {{ place.rating }}</span>
<span>({{ place.reviews }} reviews)</span>
</div>
<p class="mt-2 text-xs leading-relaxed text-gray-600">
{{ place.desc }}
</p>
</div>
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-gray-900">
{{ place.name }}
</h3>
<button
class="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click.stop="close(place.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="mt-1 flex items-center gap-1 text-xs text-gray-500">
<span class="font-medium text-yellow-500">β˜… {{ place.rating }}</span>
<span>({{ place.reviews }} reviews)</span>
</div>
<p class="mt-2 text-xs leading-relaxed text-gray-600">
{{ place.desc }}
</p>
</ScriptGoogleMapsOverlayView>
</ScriptGoogleMapsMarker>
</ScriptGoogleMaps>
</div>
</template>

<style scoped>
.overlay-popup {
/*
* `.overlay-popup` is forwarded onto the OverlayView's internal content div
* (via `v-bind="$attrs"`). Scoped styles need `:deep()` to reach across the
* component boundary, since the rendered element belongs to the
* `<ScriptGoogleMapsOverlayView>` component, not this page.
*/
:deep(.overlay-popup) {
width: 16rem;
border-radius: 0.75rem;
background: white;
padding: 1rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}

.overlay-popup[data-state="open"] {
:deep(.overlay-popup[data-state="open"]) {
animation: overlayIn 200ms ease-out forwards;
}

.overlay-popup[data-state="closed"] {
:deep(.overlay-popup[data-state="closed"]) {
animation: overlayOut 150ms ease-in forwards;
}

Expand Down
Loading
Loading