From 46e7ce3e40e7ac46e085c258c6a71c1ff256b054 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 19:40:13 +1000 Subject: [PATCH 1/3] feat(maplibre): add scale, geolocate, fullscreen, attribution controls Only NavigationControl had a component, so every other standard control went through `@ready` with a manual addControl and removeControl (PC-15). Each new component follows ScriptMapLibreNavigationControl: `position` and `options` props, add on map ready, remove on unmount, options read once. ScaleControl also forwards `unit` through `setUnit()`. GeolocateControl re-emits every control event and emits `unavailable` when permission was denied before load, which MapLibre only logs. AttributionControl replaces the map's default control and restores it on unmount, so attribution shows once and required credits never disappear. --- .../maplibre/2.api/10.attribution-control.md | 44 +++ .../scripts/maplibre/2.api/7.scale-control.md | 18 + .../maplibre/2.api/8.geolocate-control.md | 58 ++++ .../maplibre/2.api/9.fullscreen-control.md | 24 ++ docs/content/scripts/maplibre/index.md | 4 + packages/script/src/module.ts | 12 + packages/script/src/registry-types.json | 144 +++++++- .../ScriptMapLibreAttributionControl.vue | 56 ++++ .../ScriptMapLibreFullscreenControl.vue | 34 ++ .../ScriptMapLibreGeolocateControl.vue | 65 ++++ .../MapLibre/ScriptMapLibreScaleControl.vue | 31 ++ .../src/runtime/components/MapLibre/types.ts | 67 ++++ packages/script/src/runtime/types.ts | 6 + .../pages/third-parties/maplibre-controls.vue | 84 +++++ scripts/generate-registry-types.ts | 4 + .../maplibre-controls.nuxt.test.ts | 311 ++++++++++++++++++ test/types/maplibre-controls.test-d.ts | 46 +++ 17 files changed, 1005 insertions(+), 3 deletions(-) create mode 100644 docs/content/scripts/maplibre/2.api/10.attribution-control.md create mode 100644 docs/content/scripts/maplibre/2.api/7.scale-control.md create mode 100644 docs/content/scripts/maplibre/2.api/8.geolocate-control.md create mode 100644 docs/content/scripts/maplibre/2.api/9.fullscreen-control.md create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue create mode 100644 playground/pages/third-parties/maplibre-controls.vue create mode 100644 test/nuxt-runtime/maplibre-controls.nuxt.test.ts create mode 100644 test/types/maplibre-controls.test-d.ts diff --git a/docs/content/scripts/maplibre/2.api/10.attribution-control.md b/docs/content/scripts/maplibre/2.api/10.attribution-control.md new file mode 100644 index 000000000..3d276444a --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/10.attribution-control.md @@ -0,0 +1,44 @@ +--- +title: +--- + +Moves or restyles the map's attribution. Use it to change the attribution position, force compact mode, or add your own credits. + +```vue + +``` + +::callout{color="amber"} +Most tile providers require attribution. OpenFreeMap and OpenStreetMap require it. Keep an attribution control on every map that shows their tiles. +:: + +## How it works with the default attribution + +MapLibre adds an attribution control to every map by default. Two controls would show the attribution twice. This component prevents that: + +- When the component mounts, it removes the map's attribution control and adds its own. +- When the component unmounts, it removes its own control and adds the map's control back. + +The map therefore shows attribution exactly once. Unmounting the component does not remove the default attribution. + +If you set `attributionControl: false` in the map `options`, the map has no control to restore. The component then adds its own control, and unmounting removes it. + +Use one ``{lang="html"} per map. The map's control returns to its default position, bottom-right, when the component unmounts. + +## Attribution text + +The control always shows the attribution from the style sources, for example OpenFreeMap and OpenStreetMap. `options` cannot remove it. + +`options.customAttribution` adds text. It also replaces MapLibre's default "MapLibre" link. Omit `options` to keep MapLibre's defaults. + +`new maplibregl.AttributionControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. + +::script-types{script-key="maplibre" filter="ScriptMapLibreAttributionControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/7.scale-control.md b/docs/content/scripts/maplibre/2.api/7.scale-control.md new file mode 100644 index 000000000..b16f2949b --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/7.scale-control.md @@ -0,0 +1,18 @@ +--- +title: +--- + +Adds MapLibre's scale bar to the nearest parent map. MapLibre places it in the bottom-left corner by default. + +```vue + +``` + +`new maplibregl.ScaleControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. `options.unit` also updates reactively through `setUnit()`{lang="ts"}. To apply any other change, change the component `key`. + +::script-types{script-key="maplibre" filter="ScriptMapLibreScaleControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/8.geolocate-control.md b/docs/content/scripts/maplibre/2.api/8.geolocate-control.md new file mode 100644 index 000000000..8a11671ef --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/8.geolocate-control.md @@ -0,0 +1,58 @@ +--- +title: +--- + +Adds MapLibre's geolocate button to the nearest parent map. The button asks the browser for the user's location and moves the camera there. + +```vue + + + +``` + +`new maplibregl.GeolocateControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. + +The component exposes the MapLibre control as `control`. Call `control.value.trigger()`{lang="ts"} to request the location from code. + +## Events + +The component emits every `GeolocateControl` event with MapLibre's event object: + +- `geolocate` fires when the browser returns a position. +- `error` fires when the browser returns an error. +- `outofmaxbounds` fires when the position is outside the map's `maxBounds`. +- `trackuserlocationstart` and `trackuserlocationend` fire when tracking starts and stops. They need `trackUserLocation`. +- `userlocationfocus` and `userlocationlostfocus` fire when the camera locks to or leaves the user's location. + +## Permission denied + +Geolocation can fail in two ways. The component reports both. + +- If the user denies the permission prompt, `error` fires with `code` 1. MapLibre then disables the button. +- If the permission was already denied when the control loaded, MapLibre disables the button and fires no event. The component emits `unavailable` with `'permission-denied'`. +- If the browser has no Geolocation API, the component emits `unavailable` with `'unsupported'`. + +Geolocation needs a secure context. On a plain HTTP origin other than `localhost`, the browser denies it. + +::script-types{script-key="maplibre" filter="ScriptMapLibreGeolocateControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/9.fullscreen-control.md b/docs/content/scripts/maplibre/2.api/9.fullscreen-control.md new file mode 100644 index 000000000..3748a546d --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/9.fullscreen-control.md @@ -0,0 +1,24 @@ +--- +title: +--- + +Adds MapLibre's fullscreen button to the nearest parent map. The map container goes fullscreen by default. Pass `options.container` to make a different element fullscreen. + +```vue + +``` + +`new maplibregl.FullscreenControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. + +If the browser has no Fullscreen API, or `options.pseudo` is `true`, MapLibre expands the map with CSS instead. + +::script-types{script-key="maplibre" filter="ScriptMapLibreFullscreenControl"} +:: diff --git a/docs/content/scripts/maplibre/index.md b/docs/content/scripts/maplibre/index.md index ded3fc3ec..860546ec4 100644 --- a/docs/content/scripts/maplibre/index.md +++ b/docs/content/scripts/maplibre/index.md @@ -93,6 +93,10 @@ OpenFreeMap's public instance needs no API key, but it has no SLA. Read [Styles - [``{lang="html"}](/scripts/maplibre/api/marker) adds an accessible, reactive marker. - [``{lang="html"}](/scripts/maplibre/api/popup) binds slotted HTML to a marker or coordinate. - [``{lang="html"}](/scripts/maplibre/api/navigation-control) adds zoom, compass, and pitch controls. +- [``{lang="html"}](/scripts/maplibre/api/scale-control) adds a scale bar. +- [``{lang="html"}](/scripts/maplibre/api/geolocate-control) finds the user's location. +- [``{lang="html"}](/scripts/maplibre/api/fullscreen-control) toggles fullscreen mode. +- [``{lang="html"}](/scripts/maplibre/api/attribution-control) moves or restyles the map attribution. - [``{lang="html"}](/scripts/maplibre/api/geojson) manages a GeoJSON source and its style layers, including [clustering](/scripts/maplibre/api/geojson#clustering). ## Guides diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 8fe7f2637..12c5c7956 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -3,10 +3,15 @@ import type { ProxyDevtoolsScript } from './devtools' import type { NormalizedRegistryEntry } from './normalize' import type { ProxyAliasConfig } from './proxy-alias' import type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, ScriptMapLibreGeoJsonEmits, ScriptMapLibreGeoJsonLayer, ScriptMapLibreGeoJsonProps, ScriptMapLibreGeoJsonResource, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, ScriptMapLibreMapEmits, ScriptMapLibreMapExpose, ScriptMapLibreMapProps, @@ -16,6 +21,7 @@ import type { ScriptMapLibreNavigationControlProps, ScriptMapLibrePopupEmits, ScriptMapLibrePopupProps, + ScriptMapLibreScaleControlProps, } from './runtime/components/MapLibre/types' import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' import type { @@ -63,10 +69,15 @@ import { validateScriptsEnvVars } from './validate-env' export type { FirstPartyPrivacy } export type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, ScriptMapLibreGeoJsonEmits, ScriptMapLibreGeoJsonLayer, ScriptMapLibreGeoJsonProps, ScriptMapLibreGeoJsonResource, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, ScriptMapLibreMapEmits, ScriptMapLibreMapExpose, ScriptMapLibreMapProps, @@ -76,6 +87,7 @@ export type { ScriptMapLibreNavigationControlProps, ScriptMapLibrePopupEmits, ScriptMapLibrePopupProps, + ScriptMapLibreScaleControlProps, } const UPPER_RE = /([A-Z])/g diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 6212c94d6..02f4afb27 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -839,16 +839,41 @@ "kind": "interface", "code": "export interface MapLibreApi {\n maplibregl: typeof MapLibre\n}" }, + { + "name": "ScriptMapLibreAttributionControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreAttributionControlProps {\n /** Position of the attribution control. MapLibre places it bottom-right by default. */\n position?: MapLibre.ControlPosition\n /**\n * Options passed to `new maplibregl.AttributionControl()`.\n * Source attribution from the style and tiles always shows. `customAttribution` only adds text.\n */\n options?: MapLibre.AttributionControlOptions\n}" + }, + { + "name": "ScriptMapLibreFullscreenControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreFullscreenControlProps {\n /** Position of the fullscreen control. */\n position?: MapLibre.ControlPosition\n /** Options passed to `new maplibregl.FullscreenControl()`. */\n options?: MapLibre.FullscreenControlOptions\n}" + }, + { + "name": "ScriptMapLibreFullscreenControlEvents", + "kind": "interface", + "code": "interface ScriptMapLibreFullscreenControlEvents {\n fullscreenstart: MapLibre.FullscreenControlEventType['fullscreenstart']\n fullscreenend: MapLibre.FullscreenControlEventType['fullscreenend']\n}" + }, { "name": "ScriptMapLibreGeoJsonProps", "kind": "interface", - "code": "interface ScriptMapLibreGeoJsonProps {\n /** MapLibre source ID. Changing it rebuilds the owned source and layers. */\n sourceId: string\n /** Inline GeoJSON data or a URL returning GeoJSON. */\n data: GeoJSON | string\n /** GeoJSON source options. `type` and `data` are supplied by the component. */\n sourceOptions?: Omit\n /** Style layers backed by this source. */\n layers: ScriptMapLibreGeoJsonLayer[]\n /** Existing layer ID before which the layers are inserted. */\n beforeId?: string\n /** CSS cursor applied while the pointer is over one of these layers. */\n cursor?: string\n}" + "code": "interface ScriptMapLibreGeoJsonProps {\n /** MapLibre source ID. Changing it rebuilds the owned source and layers. */\n sourceId: string\n /** Inline GeoJSON data or a URL returning GeoJSON. */\n data: GeoJSON | string\n /** GeoJSON source options. `type` and `data` are supplied by the component. */\n sourceOptions?: Omit\n /**\n * Style layers backed by this source.\n * A paint, layout or filter change updates the layers in place.\n * Any other change rebuilds the source and the layers.\n */\n layers: ScriptMapLibreGeoJsonLayer[]\n /** Existing layer ID before which the layers are inserted. */\n beforeId?: string\n /** CSS cursor applied while the pointer is over one of these layers. */\n cursor?: string\n}" }, { "name": "ScriptMapLibreGeoJsonEvents", "kind": "interface", "code": "interface ScriptMapLibreGeoJsonEvents {\n error: Error\n click: MapLibre.MapLayerMouseEvent\n mouseenter: MapLibre.MapLayerMouseEvent\n mouseleave: MapLibre.MapLayerMouseEvent\n}" }, + { + "name": "ScriptMapLibreGeolocateControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreGeolocateControlProps {\n /** Position of the geolocate control. */\n position?: MapLibre.ControlPosition\n /** Options passed to `new maplibregl.GeolocateControl()`. */\n options?: MapLibre.GeolocateControlOptions\n}" + }, + { + "name": "ScriptMapLibreGeolocateControlEvents", + "kind": "interface", + "code": "interface ScriptMapLibreGeolocateControlEvents {\n geolocate: MapLibre.GeolocateControlEventType['geolocate']\n error: MapLibre.GeolocateControlEventType['error']\n outofmaxbounds: MapLibre.GeolocateControlEventType['outofmaxbounds']\n trackuserlocationstart: MapLibre.GeolocateControlEventType['trackuserlocationstart']\n trackuserlocationend: MapLibre.GeolocateControlEventType['trackuserlocationend']\n userlocationfocus: MapLibre.GeolocateControlEventType['userlocationfocus']\n userlocationlostfocus: MapLibre.GeolocateControlEventType['userlocationlostfocus']\n unavailable: 'unsupported' | 'permission-denied'\n}" + }, { "name": "ScriptMapLibreMapProps", "kind": "interface", @@ -898,6 +923,11 @@ "name": "ScriptMapLibrePopupSlots", "kind": "interface", "code": "interface ScriptMapLibrePopupSlots {\n default?: () => any\n}" + }, + { + "name": "ScriptMapLibreScaleControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreScaleControlProps {\n /** Position of the scale control. MapLibre places it bottom-left by default. */\n position?: MapLibre.ControlPosition\n /**\n * Options passed to `new maplibregl.ScaleControl()`.\n * `unit` also updates reactively through `setUnit()`.\n */\n options?: MapLibre.ScaleControlOptions\n}" } ], "matomo-analytics": [ @@ -4165,6 +4195,43 @@ "required": false } ], + "ScriptMapLibreAttributionControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.AttributionControlOptions", + "required": false, + "description": "Options passed to `new maplibregl.AttributionControl()`. Source attribution from the style and tiles always shows. `customAttribution` only adds text." + } + ], + "ScriptMapLibreFullscreenControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.FullscreenControlOptions", + "required": false + } + ], + "ScriptMapLibreFullscreenControlEvents": [ + { + "name": "fullscreenstart", + "type": "MapLibre.FullscreenControlEventType['fullscreenstart']", + "required": false + }, + { + "name": "fullscreenend", + "type": "MapLibre.FullscreenControlEventType['fullscreenend']", + "required": false + } + ], "ScriptMapLibreGeoJsonProps": [ { "name": "sourceId", @@ -4184,7 +4251,8 @@ { "name": "layers", "type": "ScriptMapLibreGeoJsonLayer[]", - "required": true + "required": true, + "description": "Style layers backed by this source. A paint, layout or filter change updates the layers in place. Any other change rebuilds the source and the layers." }, { "name": "beforeId", @@ -4201,7 +4269,8 @@ { "name": "error", "type": "Error", - "required": false + "required": false, + "description": "The component could not apply a source or layer. A failed rebuild removes the component's own source and layers. A failed paint, layout or filter update leaves them on the map." }, { "name": "click", @@ -4219,6 +4288,62 @@ "required": false } ], + "ScriptMapLibreGeolocateControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.GeolocateControlOptions", + "required": false + } + ], + "ScriptMapLibreGeolocateControlEvents": [ + { + "name": "geolocate", + "type": "MapLibre.GeolocateControlEventType['geolocate']", + "required": false + }, + { + "name": "error", + "type": "MapLibre.GeolocateControlEventType['error']", + "required": false, + "description": "The Geolocation API returned an error. `code` 1 means the user denied permission. MapLibre then disables the button." + }, + { + "name": "outofmaxbounds", + "type": "MapLibre.GeolocateControlEventType['outofmaxbounds']", + "required": false + }, + { + "name": "trackuserlocationstart", + "type": "MapLibre.GeolocateControlEventType['trackuserlocationstart']", + "required": false + }, + { + "name": "trackuserlocationend", + "type": "MapLibre.GeolocateControlEventType['trackuserlocationend']", + "required": false + }, + { + "name": "userlocationfocus", + "type": "MapLibre.GeolocateControlEventType['userlocationfocus']", + "required": false + }, + { + "name": "userlocationlostfocus", + "type": "MapLibre.GeolocateControlEventType['userlocationlostfocus']", + "required": false + }, + { + "name": "unavailable", + "type": "'unsupported' | 'permission-denied'", + "required": false, + "description": "Geolocation cannot work in this browser. `permission-denied` means the user blocked it before the control loaded. MapLibre disables the button and fires no `error` event in both cases." + } + ], "ScriptMapLibreMapProps": [ { "name": "trigger", @@ -4516,6 +4641,19 @@ "required": false } ], + "ScriptMapLibreScaleControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.ScaleControlOptions", + "required": false, + "description": "Options passed to `new maplibregl.ScaleControl()`. `unit` also updates reactively through `setUnit()`." + } + ], "ScriptBlueskyEmbedProps": [ { "name": "postUrl", diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue new file mode 100644 index 000000000..8427b9410 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue new file mode 100644 index 000000000..61fb4563e --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue new file mode 100644 index 000000000..022e51e95 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue new file mode 100644 index 000000000..ac6ee8af4 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/script/src/runtime/components/MapLibre/types.ts b/packages/script/src/runtime/components/MapLibre/types.ts index 7b6b1bb42..ef3044b16 100644 --- a/packages/script/src/runtime/components/MapLibre/types.ts +++ b/packages/script/src/runtime/components/MapLibre/types.ts @@ -194,3 +194,70 @@ export interface ScriptMapLibreNavigationControlProps { /** Options passed to `new maplibregl.NavigationControl()`. */ options?: MapLibre.NavigationControlOptions } + +export interface ScriptMapLibreScaleControlProps { + /** Position of the scale control. MapLibre places it bottom-left by default. */ + position?: MapLibre.ControlPosition + /** + * Options passed to `new maplibregl.ScaleControl()`. + * `unit` also updates reactively through `setUnit()`. + */ + options?: MapLibre.ScaleControlOptions +} + +export interface ScriptMapLibreGeolocateControlProps { + /** Position of the geolocate control. */ + position?: MapLibre.ControlPosition + /** Options passed to `new maplibregl.GeolocateControl()`. */ + options?: MapLibre.GeolocateControlOptions +} + +export interface ScriptMapLibreGeolocateControlEmits { + /** The Geolocation API returned a position. */ + geolocate: [event: MapLibre.GeolocateControlEventType['geolocate']] + /** + * The Geolocation API returned an error. + * `code` 1 means the user denied permission. MapLibre then disables the button. + */ + error: [event: MapLibre.GeolocateControlEventType['error']] + /** The position is outside the map's `maxBounds`. */ + outofmaxbounds: [event: MapLibre.GeolocateControlEventType['outofmaxbounds']] + /** The control entered the active lock state. Needs `trackUserLocation`. */ + trackuserlocationstart: [event: MapLibre.GeolocateControlEventType['trackuserlocationstart']] + /** The control left the active lock state. Needs `trackUserLocation`. */ + trackuserlocationend: [event: MapLibre.GeolocateControlEventType['trackuserlocationend']] + /** The user clicked the button in the active lock state. */ + userlocationfocus: [event: MapLibre.GeolocateControlEventType['userlocationfocus']] + /** The user moved the map in the active lock state. */ + userlocationlostfocus: [event: MapLibre.GeolocateControlEventType['userlocationlostfocus']] + /** + * Geolocation cannot work in this browser. + * `permission-denied` means the user blocked it before the control loaded. + * MapLibre disables the button and fires no `error` event in both cases. + */ + unavailable: [reason: 'unsupported' | 'permission-denied'] +} + +export interface ScriptMapLibreFullscreenControlProps { + /** Position of the fullscreen control. */ + position?: MapLibre.ControlPosition + /** Options passed to `new maplibregl.FullscreenControl()`. */ + options?: MapLibre.FullscreenControlOptions +} + +export interface ScriptMapLibreFullscreenControlEmits { + /** The map entered fullscreen mode. */ + fullscreenstart: [event: MapLibre.FullscreenControlEventType['fullscreenstart']] + /** The map left fullscreen mode. */ + fullscreenend: [event: MapLibre.FullscreenControlEventType['fullscreenend']] +} + +export interface ScriptMapLibreAttributionControlProps { + /** Position of the attribution control. MapLibre places it bottom-right by default. */ + position?: MapLibre.ControlPosition + /** + * Options passed to `new maplibregl.AttributionControl()`. + * Source attribution from the style and tiles always shows. `customAttribution` only adds text. + */ + options?: MapLibre.AttributionControlOptions +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 5fe545723..61227ccab 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -60,10 +60,15 @@ export { MARKER_CLUSTERER_INJECTION_KEY } from './components/GoogleMaps/types' // MapLibre component types (re-exported for easy user access) export type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, ScriptMapLibreGeoJsonEmits, ScriptMapLibreGeoJsonLayer, ScriptMapLibreGeoJsonProps, ScriptMapLibreGeoJsonResource, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, ScriptMapLibreMapEmits, ScriptMapLibreMapExpose, ScriptMapLibreMapProps, @@ -73,6 +78,7 @@ export type { ScriptMapLibreNavigationControlProps, ScriptMapLibrePopupEmits, ScriptMapLibrePopupProps, + ScriptMapLibreScaleControlProps, } from './components/MapLibre/types' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' diff --git a/playground/pages/third-parties/maplibre-controls.vue b/playground/pages/third-parties/maplibre-controls.vue new file mode 100644 index 000000000..cde843079 --- /dev/null +++ b/playground/pages/third-parties/maplibre-controls.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/scripts/generate-registry-types.ts b/scripts/generate-registry-types.ts index 20731af22..3ca7b8f14 100644 --- a/scripts/generate-registry-types.ts +++ b/scripts/generate-registry-types.ts @@ -746,6 +746,10 @@ const componentToSlug: Record = { ScriptMapLibrePopup: 'maplibre', ScriptMapLibreGeoJson: 'maplibre', ScriptMapLibreNavigationControl: 'maplibre', + ScriptMapLibreScaleControl: 'maplibre', + ScriptMapLibreGeolocateControl: 'maplibre', + ScriptMapLibreFullscreenControl: 'maplibre', + ScriptMapLibreAttributionControl: 'maplibre', ScriptCarbonAds: 'carbon-ads', ScriptCrisp: 'crisp', ScriptIntercom: 'intercom', diff --git a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts new file mode 100644 index 000000000..92778d448 --- /dev/null +++ b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts @@ -0,0 +1,311 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick, shallowRef } from 'vue' +import ScriptMapLibreAttributionControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue' +import ScriptMapLibreFullscreenControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue' +import ScriptMapLibreGeolocateControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue' +import ScriptMapLibreScaleControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue' +import { MAPLIBRE_MAP_INJECTION_KEY } from '../../packages/script/src/runtime/components/MapLibre/useMapLibreResource' + +interface FakeControl { + kind: string + options: unknown + on: (name: string, listener: (event: unknown) => void) => void + off: (name: string, listener: (event: unknown) => void) => void + fire: (name: string, event: unknown) => void + listenerCount: () => number + setUnit: ReturnType +} + +/** A control that stores its listeners, so a test can fire MapLibre events at it. */ +function initControl(target: object, kind: string, options: unknown): FakeControl { + const listeners = new Map void>>() + return Object.assign(target, { + kind, + options, + on: (name: string, listener: (event: unknown) => void) => { + listeners.set(name, (listeners.get(name) ?? new Set()).add(listener)) + }, + off: (name: string, listener: (event: unknown) => void) => { + listeners.get(name)?.delete(listener) + }, + fire: (name: string, event: unknown) => listeners.get(name)?.forEach(listener => listener(event)), + listenerCount: () => [...listeners.values()].reduce((total, set) => total + set.size, 0), + setUnit: vi.fn(), + }) +} + +/** + * The control list follows MapLibre: `addControl` appends, `removeControl` + * splices, `hasControl` reads the list, and `remove()` empties it. + */ +function createMap() { + const positions = new Map() + const map = { + _controls: [] as FakeControl[], + addControl: vi.fn((control: FakeControl, position?: string) => { + map._controls.push(control) + positions.set(control, position) + return map + }), + removeControl: vi.fn((control: FakeControl) => { + map._controls = map._controls.filter(existing => existing !== control) + return map + }), + hasControl: (control: FakeControl) => map._controls.includes(control), + remove: () => { + map._controls = [] + }, + positionOf: (control: unknown) => positions.get(control), + } + return map +} + +function createMapLibre() { + function ScaleControl(this: object, options: unknown) { + initControl(this, 'scale', options) + } + function FullscreenControl(this: object, options: unknown) { + initControl(this, 'fullscreen', options) + } + function GeolocateControl(this: object, options: unknown) { + initControl(this, 'geolocate', options) + } + function AttributionControl(this: object, options: unknown) { + initControl(this, 'attribution', options) + } + return { ScaleControl, FullscreenControl, GeolocateControl, AttributionControl } +} + +function provideMap(maplibre: unknown, map: unknown) { + return { + provide: { + [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { + map: shallowRef(map), + maplibre: shallowRef(maplibre), + }, + }, + } +} + +function controlsOf(map: ReturnType, kind: string) { + return map._controls.filter(control => control.kind === kind) +} + +function stubGeolocation(state: PermissionState | 'no-api' | 'rejects') { + if (state === 'no-api') { + vi.stubGlobal('navigator', {}) + return + } + vi.stubGlobal('navigator', { + geolocation: {}, + permissions: { + query: state === 'rejects' + ? () => Promise.reject(new TypeError('geolocation is not a valid permission name')) + : () => Promise.resolve({ state }), + }, + }) +} + +async function flush() { + await nextTick() + await new Promise(resolve => setTimeout(resolve, 0)) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('mapLibre scale control', () => { + it('waits for the map, then adds the control once', async () => { + const maplibre = createMapLibre() + const map = createMap() + const mapRef = shallowRef() + mount(ScriptMapLibreScaleControl, { + props: { position: 'bottom-left', options: { unit: 'metric' } }, + global: { provide: { [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { map: mapRef, maplibre: shallowRef(maplibre) } } }, + }) + await nextTick() + expect(map.addControl).not.toHaveBeenCalled() + + mapRef.value = map + await nextTick() + const [scale] = controlsOf(map, 'scale') + expect(controlsOf(map, 'scale')).toHaveLength(1) + expect(scale!.options).toEqual({ unit: 'metric' }) + expect(map.positionOf(scale)).toBe('bottom-left') + }) + + it('updates the unit in place and removes the control on unmount', async () => { + const map = createMap() + const wrapper = mount(ScriptMapLibreScaleControl, { + props: { options: { unit: 'metric' } }, + global: provideMap(createMapLibre(), map), + }) + await nextTick() + const [scale] = controlsOf(map, 'scale') + + await wrapper.setProps({ options: { unit: 'imperial' } }) + expect(scale!.setUnit).toHaveBeenCalledWith('imperial') + expect(map.addControl).toHaveBeenCalledOnce() + + wrapper.unmount() + expect(map._controls).toEqual([]) + }) +}) + +describe('mapLibre fullscreen control', () => { + it('re-emits fullscreen events and detaches on unmount', async () => { + const map = createMap() + const wrapper = mount(ScriptMapLibreFullscreenControl, { + props: { position: 'top-left', options: { pseudo: true } }, + global: provideMap(createMapLibre(), map), + }) + await nextTick() + const [fullscreen] = controlsOf(map, 'fullscreen') + expect(map.positionOf(fullscreen)).toBe('top-left') + expect(fullscreen!.options).toEqual({ pseudo: true }) + + fullscreen!.fire('fullscreenstart', { type: 'fullscreenstart' }) + fullscreen!.fire('fullscreenend', { type: 'fullscreenend' }) + expect(wrapper.emitted('fullscreenstart')).toEqual([[{ type: 'fullscreenstart' }]]) + expect(wrapper.emitted('fullscreenend')).toEqual([[{ type: 'fullscreenend' }]]) + + wrapper.unmount() + expect(map._controls).toEqual([]) + expect(fullscreen!.listenerCount()).toBe(0) + }) +}) + +describe('mapLibre geolocate control', () => { + it('re-emits position and permission errors from the control', async () => { + stubGeolocation('prompt') + const map = createMap() + const wrapper = mount(ScriptMapLibreGeolocateControl, { + props: { position: 'top-right', options: { trackUserLocation: true } }, + global: provideMap(createMapLibre(), map), + }) + await flush() + const [geolocate] = controlsOf(map, 'geolocate') + expect(map.positionOf(geolocate)).toBe('top-right') + + const position = { type: 'geolocate', coords: { latitude: -42.88, longitude: 147.33 } } + const denied = { type: 'error', code: 1, message: 'User denied Geolocation' } + geolocate!.fire('geolocate', position) + geolocate!.fire('error', denied) + geolocate!.fire('trackuserlocationstart', { type: 'trackuserlocationstart' }) + + expect(wrapper.emitted('geolocate')).toEqual([[position]]) + expect(wrapper.emitted('error')).toEqual([[denied]]) + expect(wrapper.emitted('trackuserlocationstart')).toHaveLength(1) + expect(wrapper.emitted('unavailable')).toBeUndefined() + + wrapper.unmount() + expect(map._controls).toEqual([]) + expect(geolocate!.listenerCount()).toBe(0) + }) + + it.each([ + ['denied', 'permission-denied'], + ['no-api', 'unsupported'], + ] as const)('emits unavailable when the permission state is %s', async (state, reason) => { + stubGeolocation(state) + const wrapper = mount(ScriptMapLibreGeolocateControl, { + global: provideMap(createMapLibre(), createMap()), + }) + await flush() + expect(wrapper.emitted('unavailable')).toEqual([[reason]]) + }) + + it('trusts the geolocation API when the permission query rejects', async () => { + stubGeolocation('rejects') + const wrapper = mount(ScriptMapLibreGeolocateControl, { + global: provideMap(createMapLibre(), createMap()), + }) + await flush() + expect(wrapper.emitted('unavailable')).toBeUndefined() + }) + + it('does not emit unavailable after unmount', async () => { + stubGeolocation('denied') + const wrapper = mount(ScriptMapLibreGeolocateControl, { + global: provideMap(createMapLibre(), createMap()), + }) + await nextTick() + wrapper.unmount() + await flush() + expect(wrapper.emitted('unavailable')).toBeUndefined() + }) +}) + +describe('mapLibre attribution control', () => { + function mapWithDefaultAttribution(maplibre: ReturnType) { + const map = createMap() + const builtIn = new (maplibre.AttributionControl as any)({ compact: true }) as FakeControl + map.addControl(builtIn) + map.addControl.mockClear() + return { map, builtIn } + } + + it('replaces the map default so attribution shows once, then restores it', async () => { + const maplibre = createMapLibre() + const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { + props: { position: 'bottom-left', options: { compact: false } }, + global: provideMap(maplibre, map), + }) + await nextTick() + + const mounted = controlsOf(map, 'attribution') + expect(mounted).toHaveLength(1) + expect(mounted[0]).not.toBe(builtIn) + expect(mounted[0]!.options).toEqual({ compact: false }) + expect(map.positionOf(mounted[0])).toBe('bottom-left') + + wrapper.unmount() + expect(controlsOf(map, 'attribution')).toEqual([builtIn]) + }) + + it('adds and removes its own control when the map has no default', async () => { + const map = createMap() + const wrapper = mount(ScriptMapLibreAttributionControl, { + global: provideMap(createMapLibre(), map), + }) + await nextTick() + expect(controlsOf(map, 'attribution')).toHaveLength(1) + + wrapper.unmount() + expect(map._controls).toEqual([]) + }) + + it('restores the map default when adding its own control throws', async () => { + const maplibre = createMapLibre() + const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const failure = new Error('onAdd failed') + map.addControl.mockImplementationOnce(() => { + throw failure + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mount(ScriptMapLibreAttributionControl, { + global: provideMap(maplibre, map), + }) + await nextTick() + + expect(controlsOf(map, 'attribution')).toEqual([builtIn]) + expect(consoleError).toHaveBeenCalledWith('[nuxt-scripts] MapLibre resource creation failed:', failure) + consoleError.mockRestore() + }) + + it('does not restore onto a map that was already removed', async () => { + const maplibre = createMapLibre() + const { map } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { + global: provideMap(maplibre, map), + }) + await nextTick() + + map.remove() + wrapper.unmount() + expect(map._controls).toEqual([]) + }) +}) diff --git a/test/types/maplibre-controls.test-d.ts b/test/types/maplibre-controls.test-d.ts new file mode 100644 index 000000000..a28342a42 --- /dev/null +++ b/test/types/maplibre-controls.test-d.ts @@ -0,0 +1,46 @@ +import type { + ScriptMapLibreAttributionControlProps, + ScriptMapLibreFullscreenControlEmits, + ScriptMapLibreFullscreenControlProps, + ScriptMapLibreGeolocateControlEmits, + ScriptMapLibreGeolocateControlProps, + ScriptMapLibreScaleControlProps, +} from '@nuxt/scripts' +import { describe, expectTypeOf, it } from 'vitest' + +/** + * PC-15: the control components need public prop and emit types, so a consumer + * can type options and handlers without a deep import. These come from the + * package entry only, so a missing export fails this file. + */ +describe('mapLibre control types', () => { + it('types the control options from the MapLibre constructors', () => { + const scale: ScriptMapLibreScaleControlProps = { position: 'bottom-left', options: { unit: 'metric', maxWidth: 120 } } + const geolocate: ScriptMapLibreGeolocateControlProps = { options: { trackUserLocation: true, positionOptions: { enableHighAccuracy: true } } } + const fullscreen: ScriptMapLibreFullscreenControlProps = { position: 'top-left', options: { pseudo: true } } + const attribution: ScriptMapLibreAttributionControlProps = { options: { compact: false, customAttribution: ['Data: Tasmania'] } } + + // @ts-expect-error ScaleControl accepts only imperial, metric or nautical + const badUnit: ScriptMapLibreScaleControlProps = { options: { unit: 'furlong' } } + // @ts-expect-error a control position names a map corner + const badPosition: ScriptMapLibreFullscreenControlProps = { position: 'middle' } + + expectTypeOf(scale).not.toBeAny() + expectTypeOf(geolocate).not.toBeAny() + expectTypeOf(fullscreen).not.toBeAny() + expectTypeOf(attribution).not.toBeAny() + expectTypeOf(badUnit).not.toBeAny() + expectTypeOf(badPosition).not.toBeAny() + }) + + it('types the geolocate event payloads', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<'unsupported' | 'permission-denied'>() + }) + + it('types the fullscreen event payloads', () => { + expectTypeOf().not.toBeAny() + expectTypeOf().toEqualTypeOf<'fullscreenstart' | 'fullscreenend'>() + }) +}) From 3385012b4a992b3475ced067cae95beeabeafe7f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 19:49:52 +1000 Subject: [PATCH 2/3] fix(maplibre): keep map attribution credits and restore after removal The replacement control ignored `customAttribution` set through the map's `attributionControl` option, so those credits vanished while mounted. Cleanup also treated a missing component control as a removed map, so a consumer who removed it by hand lost the default attribution for good. It now checks `map._removed` instead. --- .../maplibre/2.api/10.attribution-control.md | 6 ++- .../ScriptMapLibreAttributionControl.vue | 22 ++++++---- .../maplibre-controls.nuxt.test.ts | 41 +++++++++++++++++-- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/content/scripts/maplibre/2.api/10.attribution-control.md b/docs/content/scripts/maplibre/2.api/10.attribution-control.md index 3d276444a..45dd17c2f 100644 --- a/docs/content/scripts/maplibre/2.api/10.attribution-control.md +++ b/docs/content/scripts/maplibre/2.api/10.attribution-control.md @@ -24,7 +24,7 @@ Most tile providers require attribution. OpenFreeMap and OpenStreetMap require i MapLibre adds an attribution control to every map by default. Two controls would show the attribution twice. This component prevents that: - When the component mounts, it removes the map's attribution control and adds its own. -- When the component unmounts, it removes its own control and adds the map's control back. +- When the component unmounts, it adds the map's control back. This also happens if your code removed the component's control first. The map therefore shows attribution exactly once. Unmounting the component does not remove the default attribution. @@ -36,7 +36,9 @@ Use one ``{lang="html"} per map. The map's con The control always shows the attribution from the style sources, for example OpenFreeMap and OpenStreetMap. `options` cannot remove it. -`options.customAttribution` adds text. It also replaces MapLibre's default "MapLibre" link. Omit `options` to keep MapLibre's defaults. +The component `options` override the options of the map's attribution control. Options you omit keep the map's values. + +If you omit `options.customAttribution`, the credits from the map's control stay, including MapLibre's default "MapLibre" link. If you set it, your value replaces them. `new maplibregl.AttributionControl()`{lang="ts"} reads `options` and `position` once, when it creates the control. To apply a change, change the component `key`. diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue index 8427b9410..32500aa28 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue @@ -11,7 +11,7 @@ const props = defineProps() * map shows attribution once. Unmounting restores the replaced control, so * required attribution never disappears. */ -let replaced: MapLibre.IControl[] = [] +let replaced: MapLibre.AttributionControl[] = [] function restore(map: MapLibre.Map): void { for (const existing of replaced) { @@ -23,9 +23,15 @@ function restore(map: MapLibre.Map): void { const control = useMapLibreResource({ create({ maplibre, map }) { - const instance = new maplibre.AttributionControl(props.options) // `_controls` is the list that `hasControl()` reads. `filter` copies it before removal. - replaced = map._controls.filter(existing => existing instanceof maplibre.AttributionControl) + replaced = map._controls.filter((existing): existing is MapLibre.AttributionControl => existing instanceof maplibre.AttributionControl) + // The component options override the map's `attributionControl` options. + // Credits the map already configured stay unless the component sets its own. + const inherited = replaced[0]?.options + const options = inherited || props.options + ? { ...inherited, ...props.options, customAttribution: props.options?.customAttribution ?? inherited?.customAttribution } + : undefined + const instance = new maplibre.AttributionControl(options) for (const existing of replaced) map.removeControl(existing) try { @@ -38,13 +44,13 @@ const control = useMapLibreResource({ return instance }, cleanup(instance, { map }) { + if (map.hasControl(instance)) + map.removeControl(instance) // A removed map has already dropped every control, and adding to it throws. - if (!map.hasControl(instance)) { + if (map._removed) replaced = [] - return - } - map.removeControl(instance) - restore(map) + else + restore(map) }, }) diff --git a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts index 92778d448..a4f455e1d 100644 --- a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts +++ b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts @@ -43,6 +43,7 @@ function createMap() { const positions = new Map() const map = { _controls: [] as FakeControl[], + _removed: false, addControl: vi.fn((control: FakeControl, position?: string) => { map._controls.push(control) positions.set(control, position) @@ -55,6 +56,7 @@ function createMap() { hasControl: (control: FakeControl) => map._controls.includes(control), remove: () => { map._controls = [] + map._removed = true }, positionOf: (control: unknown) => positions.get(control), } @@ -239,9 +241,9 @@ describe('mapLibre geolocate control', () => { }) describe('mapLibre attribution control', () => { - function mapWithDefaultAttribution(maplibre: ReturnType) { + function mapWithDefaultAttribution(maplibre: ReturnType, options: Record = { compact: true }) { const map = createMap() - const builtIn = new (maplibre.AttributionControl as any)({ compact: true }) as FakeControl + const builtIn = new (maplibre.AttributionControl as any)(options) as FakeControl map.addControl(builtIn) map.addControl.mockClear() return { map, builtIn } @@ -259,7 +261,7 @@ describe('mapLibre attribution control', () => { const mounted = controlsOf(map, 'attribution') expect(mounted).toHaveLength(1) expect(mounted[0]).not.toBe(builtIn) - expect(mounted[0]!.options).toEqual({ compact: false }) + expect(mounted[0]!.options).toMatchObject({ compact: false }) expect(map.positionOf(mounted[0])).toBe('bottom-left') wrapper.unmount() @@ -296,6 +298,39 @@ describe('mapLibre attribution control', () => { consoleError.mockRestore() }) + it('keeps credits the map default configured unless the component sets its own', async () => { + const maplibre = createMapLibre() + const { map } = mapWithDefaultAttribution(maplibre, { compact: true, customAttribution: 'Data: Hobart City Council' }) + const inheriting = mount(ScriptMapLibreAttributionControl, { + props: { options: { compact: false } }, + global: provideMap(maplibre, map), + }) + await nextTick() + expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: false, customAttribution: 'Data: Hobart City Council' }) + inheriting.unmount() + + const overriding = mount(ScriptMapLibreAttributionControl, { + props: { options: { customAttribution: 'Data: Tasmania' } }, + global: provideMap(maplibre, map), + }) + await nextTick() + expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: true, customAttribution: 'Data: Tasmania' }) + overriding.unmount() + }) + + it('restores the map default after consumer code removed the component control', async () => { + const maplibre = createMapLibre() + const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { + global: provideMap(maplibre, map), + }) + await nextTick() + + map.removeControl(controlsOf(map, 'attribution')[0]!) + wrapper.unmount() + expect(controlsOf(map, 'attribution')).toEqual([builtIn]) + }) + it('does not restore onto a map that was already removed', async () => { const maplibre = createMapLibre() const { map } = mapWithDefaultAttribution(maplibre) From 8f19a85d1b3f06084086d8f2b356917e4171b813 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 20:42:00 +1000 Subject: [PATCH 3/3] fix(maplibre): render controls as null to stop hydration mismatch A control template held only a comment. A production build strips it, so the server rendered nothing while the client expected a comment node, and hydration reported a mismatch. `nuxt dev` keeps comments and hid the bug. A `render` returning `null` yields a comment on both sides. The new e2e test compiles with `comments: false`, because the Vitest worker loads the development Vue compiler, which keeps comments. --- .../ScriptMapLibreAttributionControl.vue | 10 ++-- .../ScriptMapLibreFullscreenControl.vue | 10 ++-- .../ScriptMapLibreGeolocateControl.vue | 10 ++-- .../ScriptMapLibreNavigationControl.vue | 10 ++-- .../MapLibre/ScriptMapLibreScaleControl.vue | 10 ++-- test/e2e/maplibre-hydration.test.ts | 53 +++++++++++++++++++ test/fixtures/maplibre/pages/controls.vue | 31 +++++++++++ 7 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 test/e2e/maplibre-hydration.test.ts create mode 100644 test/fixtures/maplibre/pages/controls.vue diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue index 32500aa28..53fb68291 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue @@ -3,6 +3,12 @@ import type * as MapLibre from 'maplibre-gl' import type { ScriptMapLibreAttributionControlProps } from './types' import { useMapLibreResource } from './useMapLibreResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A comment-only template does not: +// a production build strips the comment, so the server renders nothing and +// hydration reports a mismatch. +defineOptions({ render: () => null }) + const props = defineProps() /** @@ -56,7 +62,3 @@ const control = useMapLibreResource({ defineExpose({ control }) - - diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue index 61fb4563e..120f1e591 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreFullscreenControl.vue @@ -3,6 +3,12 @@ import type * as MapLibre from 'maplibre-gl' import type { ScriptMapLibreFullscreenControlEmits, ScriptMapLibreFullscreenControlProps } from './types' import { useMapLibreResource } from './useMapLibreResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A comment-only template does not: +// a production build strips the comment, so the server renders nothing and +// hydration reports a mismatch. +defineOptions({ render: () => null }) + const props = defineProps() const emit = defineEmits() @@ -28,7 +34,3 @@ const control = useMapLibreResource({ defineExpose({ control }) - - diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue index 022e51e95..1d219f3c0 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeolocateControl.vue @@ -3,6 +3,12 @@ import type * as MapLibre from 'maplibre-gl' import type { ScriptMapLibreGeolocateControlEmits, ScriptMapLibreGeolocateControlProps } from './types' import { useMapLibreResource } from './useMapLibreResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A comment-only template does not: +// a production build strips the comment, so the server renders nothing and +// hydration reports a mismatch. +defineOptions({ render: () => null }) + const props = defineProps() const emit = defineEmits() @@ -59,7 +65,3 @@ const control = useMapLibreResource({ defineExpose({ control }) - - diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue index 5e832e2d4..f7750c24f 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue @@ -3,6 +3,12 @@ import type * as MapLibre from 'maplibre-gl' import type { ScriptMapLibreNavigationControlProps } from './types' import { useMapLibreResource } from './useMapLibreResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A comment-only template does not: +// a production build strips the comment, so the server renders nothing and +// hydration reports a mismatch. +defineOptions({ render: () => null }) + const props = defineProps() const control = useMapLibreResource({ @@ -19,7 +25,3 @@ const control = useMapLibreResource({ defineExpose({ control }) - - diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue index ac6ee8af4..3c3761145 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreScaleControl.vue @@ -4,6 +4,12 @@ import type { ScriptMapLibreScaleControlProps } from './types' import { watch } from 'vue' import { useMapLibreResource } from './useMapLibreResource' +// Renders no DOM of its own. A render function that returns `null` gives a +// comment node on the server and the client. A comment-only template does not: +// a production build strips the comment, so the server renders nothing and +// hydration reports a mismatch. +defineOptions({ render: () => null }) + const props = defineProps() const control = useMapLibreResource({ @@ -25,7 +31,3 @@ watch(() => props.options?.unit, (unit) => { defineExpose({ control }) - - diff --git a/test/e2e/maplibre-hydration.test.ts b/test/e2e/maplibre-hydration.test.ts new file mode 100644 index 000000000..a10917498 --- /dev/null +++ b/test/e2e/maplibre-hydration.test.ts @@ -0,0 +1,53 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +/** + * A production compile strips template comments. A renderless component whose + * template holds only a comment then renders nothing on the server, while the + * client expects a comment node, so hydration reports a mismatch. + * + * `@nuxt/test-utils` builds inside the Vitest worker, where `NODE_ENV` is `test`. + * `@vue/compiler-core` picks its development build there, and its `comments` + * option defaults to `true`, so the bug cannot appear. Setting `comments: false` + * applies the production default. The first test proves it took effect. + */ +describe('maplibre hydration in a production build', { timeout: 120000 }, async () => { + await setup({ + rootDir: resolve('../fixtures/maplibre'), + browser: true, + nuxtConfig: { + vue: { compilerOptions: { comments: false } }, + }, + }) + + it('compiles the fixture in production mode', async () => { + // Guards the guard: a development compile keeps this template comment. + const html = await $fetch('/controls') + expect(html).not.toContain('production-build-probe') + }) + + it('hydrates every control component without a mismatch', async () => { + const page = await createPage() + const messages: string[] = [] + page.on('console', message => messages.push(`${message.type()}: ${message.text()}`)) + page.on('pageerror', error => messages.push(`pageerror: ${error.message}`)) + await page.goto(url('/controls'), { waitUntil: 'hydration' }) + await page.waitForFunction(() => (window as any).__ready === true, undefined, { timeout: 20000 }) + + expect(messages.filter(message => /hydrat|mismatch/i.test(message))).toEqual([]) + + const controls = await page.evaluate(() => ({ + navigation: document.querySelectorAll('.maplibregl-ctrl-top-right .maplibregl-ctrl-zoom-in').length, + scale: document.querySelectorAll('.maplibregl-ctrl-bottom-left .maplibregl-ctrl-scale').length, + geolocate: document.querySelectorAll('.maplibregl-ctrl-top-left .maplibregl-ctrl-geolocate').length, + fullscreen: document.querySelectorAll('.maplibregl-ctrl-top-left .maplibregl-ctrl-fullscreen').length, + attribution: document.querySelectorAll('.maplibregl-ctrl-attrib').length, + attributionCorner: document.querySelectorAll('.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib').length, + })) + expect(controls).toEqual({ navigation: 1, scale: 1, geolocate: 1, fullscreen: 1, attribution: 1, attributionCorner: 1 }) + await page.close() + }) +}) diff --git a/test/fixtures/maplibre/pages/controls.vue b/test/fixtures/maplibre/pages/controls.vue new file mode 100644 index 000000000..392bf6c23 --- /dev/null +++ b/test/fixtures/maplibre/pages/controls.vue @@ -0,0 +1,31 @@ + + +