From c26eb98bd955cf9b58173e23e6fe43e6c4612c3d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 23:23:34 +1000 Subject: [PATCH 1/3] fix(maplibre): replace default attribution through public API The attribution control found the map default by reading `map._controls`. A MapLibre release can rename that field. The component would then show attribution twice, or fail to restore required attribution on unmount. `` now adds the default attribution control itself and shares the instance through the map context. The attribution component removes and restores that instance with `hasControl`, `removeControl` and `addControl`, and tracks map removal with the public `remove` event. Attribution controls that consumer code adds are no longer removed. --- .../maplibre/2.api/10.attribution-control.md | 2 + .../ScriptMapLibreAttributionControl.vue | 57 +++++----- .../components/MapLibre/ScriptMapLibreMap.vue | 11 ++ .../MapLibre/useMapLibreResource.ts | 5 + test/e2e/maplibre.test.ts | 28 +++++ test/fixtures/maplibre/pages/attribution.vue | 48 ++++++++ .../maplibre-controls.nuxt.test.ts | 106 ++++++++++-------- test/nuxt-runtime/maplibre-map.nuxt.test.ts | 27 +++++ test/unit/maplibre-lifecycle.test.ts | 2 +- 9 files changed, 212 insertions(+), 74 deletions(-) create mode 100644 test/fixtures/maplibre/pages/attribution.vue 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 45dd17c2f..34b3ca06c 100644 --- a/docs/content/scripts/maplibre/2.api/10.attribution-control.md +++ b/docs/content/scripts/maplibre/2.api/10.attribution-control.md @@ -30,6 +30,8 @@ The map therefore shows attribution exactly once. Unmounting the component does 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. +The component replaces only the control that ``{lang="html"} adds. If your code adds an attribution control with `map.addControl()`{lang="ts"}, that control stays on the map. + Use one ``{lang="html"} per map. The map's control returns to its default position, bottom-right, when the component unmounts. ## Attribution text diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue index 95b3d54e3..6816fc4b6 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreAttributionControl.vue @@ -1,7 +1,8 @@ + + diff --git a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts index 4c44e48ad..b6db79299 100644 --- a/test/nuxt-runtime/maplibre-controls.nuxt.test.ts +++ b/test/nuxt-runtime/maplibre-controls.nuxt.test.ts @@ -37,27 +37,38 @@ function initControl(target: object, kind: string, options: unknown): FakeContro /** * The control list follows MapLibre: `addControl` appends, `removeControl` - * splices, `hasControl` reads the list, and `remove()` empties it. + * splices, `hasControl` reads the list, and `remove()` empties it and fires + * `remove`. The list is private to the fake, so a component can reach it only + * through the public MapLibre API. */ function createMap() { const positions = new Map() + const listeners = new Map void>>() + let controls: FakeControl[] = [] const map = { - _controls: [] as FakeControl[], - _removed: false, addControl: vi.fn((control: FakeControl, position?: string) => { - map._controls.push(control) + controls.push(control) positions.set(control, position) return map }), removeControl: vi.fn((control: FakeControl) => { - map._controls = map._controls.filter(existing => existing !== control) + controls = controls.filter(existing => existing !== control) return map }), - hasControl: (control: FakeControl) => map._controls.includes(control), + hasControl: (control: FakeControl) => controls.includes(control), + on: (name: string, listener: () => void) => { + listeners.set(name, (listeners.get(name) ?? new Set()).add(listener)) + return map + }, + off: (name: string, listener: () => void) => { + listeners.get(name)?.delete(listener) + return map + }, remove: () => { - map._controls = [] - map._removed = true + controls = [] + listeners.get('remove')?.forEach(listener => listener()) }, + controls: () => [...controls], positionOf: (control: unknown) => positions.get(control), } return map @@ -79,19 +90,20 @@ function createMapLibre() { return { ScaleControl, FullscreenControl, GeolocateControl, AttributionControl } } -function provideMap(maplibre: unknown, map: unknown) { +function provideMap(maplibre: unknown, map: unknown, defaultAttributionControl?: unknown) { return { provide: { [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { map: shallowRef(map), maplibre: shallowRef(maplibre), + defaultAttributionControl: shallowRef(defaultAttributionControl), }, }, } } function controlsOf(map: ReturnType, kind: string) { - return map._controls.filter(control => control.kind === kind) + return map.controls().filter(control => control.kind === kind) } function stubGeolocation(state: PermissionState | 'no-api' | 'rejects') { @@ -125,7 +137,7 @@ describe('mapLibre scale control', () => { 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) } } }, + global: { provide: { [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { map: mapRef, maplibre: shallowRef(maplibre), defaultAttributionControl: shallowRef() } } }, }) await nextTick() expect(map.addControl).not.toHaveBeenCalled() @@ -152,7 +164,7 @@ describe('mapLibre scale control', () => { expect(map.addControl).toHaveBeenCalledOnce() wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) }) }) @@ -174,7 +186,7 @@ describe('mapLibre fullscreen control', () => { expect(wrapper.emitted('fullscreenend')).toEqual([[{ type: 'fullscreenend' }]]) wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) expect(fullscreen!.listenerCount()).toBe(0) }) }) @@ -203,7 +215,7 @@ describe('mapLibre geolocate control', () => { expect(wrapper.emitted('unavailable')).toBeUndefined() wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) expect(geolocate!.listenerCount()).toBe(0) }) @@ -241,20 +253,21 @@ describe('mapLibre geolocate control', () => { }) describe('mapLibre attribution control', () => { + /** Mirrors ``: the map adds its default control and shares it through the context. */ function mapWithDefaultAttribution(maplibre: ReturnType, options: Record = { compact: true }) { const map = createMap() const builtIn = new (maplibre.AttributionControl as any)(options) as FakeControl map.addControl(builtIn) map.addControl.mockClear() - return { map, builtIn } + return { map, builtIn, global: provideMap(maplibre, map, builtIn) } } it('replaces the map default so attribution shows once, then restores it', async () => { const maplibre = createMapLibre() - const { map, builtIn } = mapWithDefaultAttribution(maplibre) + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) const wrapper = mount(ScriptMapLibreAttributionControl, { props: { position: 'bottom-left', options: { compact: false } }, - global: provideMap(maplibre, map), + global, }) await nextTick() @@ -277,20 +290,18 @@ describe('mapLibre attribution control', () => { expect(controlsOf(map, 'attribution')).toHaveLength(1) wrapper.unmount() - expect(map._controls).toEqual([]) + 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 { map, builtIn, global } = 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), - }) + mount(ScriptMapLibreAttributionControl, { global }) await nextTick() expect(controlsOf(map, 'attribution')).toEqual([builtIn]) @@ -300,46 +311,42 @@ describe('mapLibre attribution control', () => { 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 { map, global } = mapWithDefaultAttribution(maplibre, { compact: true, customAttribution: 'Data: Hobart City Council' }) const inheriting = mount(ScriptMapLibreAttributionControl, { props: { options: { compact: false } }, - global: provideMap(maplibre, map), + global, }) await nextTick() - expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: false, customAttribution: ['Data: Hobart City Council'] }) + 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), + global, }) await nextTick() expect(controlsOf(map, 'attribution')[0]!.options).toEqual({ compact: true, customAttribution: 'Data: Tasmania' }) overriding.unmount() }) - it('keeps the credits of every replaced attribution control', async () => { + it('never removes an attribution control that consumer code added', async () => { const maplibre = createMapLibre() - const { map } = mapWithDefaultAttribution(maplibre, { compact: true, customAttribution: 'MapLibre' }) - map.addControl(new (maplibre.AttributionControl as any)({ customAttribution: ['Data: Hobart City Council', 'MapLibre'] })) - const wrapper = mount(ScriptMapLibreAttributionControl, { - global: provideMap(maplibre, map), - }) + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) + const added = new (maplibre.AttributionControl as any)({ customAttribution: 'Data: Hobart City Council' }) as FakeControl + map.addControl(added) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) await nextTick() - const [mounted] = controlsOf(map, 'attribution') - expect(controlsOf(map, 'attribution')).toHaveLength(1) - expect((mounted!.options as { customAttribution: unknown }).customAttribution).toEqual(['MapLibre', 'Data: Hobart City Council']) + expect(controlsOf(map, 'attribution')).toContain(added) + expect(controlsOf(map, 'attribution')).not.toContain(builtIn) wrapper.unmount() - expect(controlsOf(map, 'attribution')).toHaveLength(2) + expect(controlsOf(map, 'attribution')).toEqual([added, builtIn]) }) 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), - }) + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) await nextTick() map.removeControl(controlsOf(map, 'attribution')[0]!) @@ -347,16 +354,25 @@ describe('mapLibre attribution control', () => { expect(controlsOf(map, 'attribution')).toEqual([builtIn]) }) + it('does not add back a default that consumer code removed before mount', async () => { + const maplibre = createMapLibre() + const { map, builtIn, global } = mapWithDefaultAttribution(maplibre) + map.removeControl(builtIn) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) + await nextTick() + + wrapper.unmount() + expect(map.controls()).toEqual([]) + }) + 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), - }) + const { map, global } = mapWithDefaultAttribution(maplibre) + const wrapper = mount(ScriptMapLibreAttributionControl, { global }) await nextTick() map.remove() wrapper.unmount() - expect(map._controls).toEqual([]) + expect(map.controls()).toEqual([]) }) }) diff --git a/test/nuxt-runtime/maplibre-map.nuxt.test.ts b/test/nuxt-runtime/maplibre-map.nuxt.test.ts index 10cecea9b..ca6d8007f 100644 --- a/test/nuxt-runtime/maplibre-map.nuxt.test.ts +++ b/test/nuxt-runtime/maplibre-map.nuxt.test.ts @@ -52,6 +52,7 @@ function createMapLibreMock() { setStyle: vi.fn(() => map), resize: vi.fn(() => map), remove: vi.fn(), + addControl: vi.fn(() => map), getCanvas: vi.fn(() => document.createElement('canvas')), keyboard: { isEnabled: vi.fn(() => true) }, } @@ -65,6 +66,9 @@ function createMapLibreMock() { const maplibregl = { Map: vi.fn(MapConstructor), + AttributionControl: vi.fn(function (this: { options: unknown }, options: unknown) { + this.options = options + }), LngLat: { convert: vi.fn(value => ({ lng: value[0], lat: value[1] })) }, } return { @@ -262,4 +266,27 @@ describe('scriptMapLibreMap', () => { expect(wrapper.emitted('error')?.[0]).toEqual([initializationFailure]) expect(wrapper.emitted('ready')).toBeUndefined() }) + + it.each([ + ['omitted', undefined, [undefined]], + ['options', { compact: false, customAttribution: 'Data: Hobart City Council' }, [{ compact: false, customAttribution: 'Data: Hobart City Council' }]], + ['false', false, undefined], + ] as const)('adds the default attribution control itself when attributionControl is %s', async (_, attributionControl, expected) => { + const mocks = createMapLibreMock() + mount(ScriptMapLibreMap, { + props: { + mapStyle: 'https://demotiles.maplibre.org/style.json', + center: [0, 0], + options: attributionControl === undefined ? {} : { attributionControl }, + }, + }) + await nextTick() + scriptState.callbacks[0]!({ maplibregl: mocks.maplibregl }) + await nextTick() + + // MapLibre must not add a second control of its own. + expect(mocks.maplibregl.Map).toHaveBeenCalledWith(expect.objectContaining({ attributionControl: false })) + const controls = mocks.map.addControl.mock.calls.map(([control]: [{ options: unknown }]) => control.options) + expect(controls).toEqual(expected ?? []) + }) }) diff --git a/test/unit/maplibre-lifecycle.test.ts b/test/unit/maplibre-lifecycle.test.ts index bf4d69aea..65c14a6d5 100644 --- a/test/unit/maplibre-lifecycle.test.ts +++ b/test/unit/maplibre-lifecycle.test.ts @@ -12,7 +12,7 @@ function createProvider(immediate = true, clearBeforeChildren = false) { const Provider = defineComponent({ setup(_, { slots }) { - provide(MAPLIBRE_MAP_INJECTION_KEY, { map, maplibre }) + provide(MAPLIBRE_MAP_INJECTION_KEY, { map, maplibre, defaultAttributionControl: shallowRef() }) if (clearBeforeChildren) { onBeforeUnmount(() => { map.value = undefined From 934239ac199c1b1969c6267eb30c2c836dfb4d76 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 23:47:04 +1000 Subject: [PATCH 2/3] docs(maplibre): qualify the attribution count --- docs/content/scripts/maplibre/2.api/10.attribution-control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 34b3ca06c..daf621aed 100644 --- a/docs/content/scripts/maplibre/2.api/10.attribution-control.md +++ b/docs/content/scripts/maplibre/2.api/10.attribution-control.md @@ -26,7 +26,7 @@ MapLibre adds an attribution control to every map by default. Two controls would - When the component mounts, it removes the map's attribution control and adds its own. - 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. +When no other attribution control is present, the map 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. From 74c0314870034974608b3fcc5867a4d45f9d0bc4 Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Thu, 17 Sep 2026 01:37:29 +1000 Subject: [PATCH 3/3] fix(maplibre): keep native logo and attribution order in a shared corner --- .../components/MapLibre/ScriptMapLibreMap.vue | 12 ++++++++-- test/e2e/maplibre.test.ts | 15 ++++++++++++ test/fixtures/maplibre/pages/logo.vue | 24 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/maplibre/pages/logo.vue diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue index 1e16e1bb6..e7071059b 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue @@ -139,12 +139,14 @@ onMounted(() => { maplibre.value = instance.maplibregl let mapInstance: MapLibre.Map | undefined try { - const attributionOptions = toRaw(props.options)?.attributionControl + const mapOptions = toRaw(props.options) + const attributionOptions = mapOptions?.attributionControl mapInstance = new instance.maplibregl.Map({ - ...toRaw(props.options), + ...mapOptions, // The component adds the default attribution control itself, so // `` can replace it through the public API. attributionControl: false, + maplibreLogo: false, container: mapEl.value, style: toRaw(props.mapStyle), center: toRaw(props.center), @@ -153,10 +155,16 @@ onMounted(() => { pitch: props.pitch, interactive: props.interactive, }) + // MapLibre's constructor adds the attribution control first and the logo + // second, and a bottom corner renders its first child on top. Adding the + // controls in that order here keeps the native vertical order when the + // logo shares the attribution corner. if (attributionOptions !== false) { defaultAttributionControl.value = new instance.maplibregl.AttributionControl(typeof attributionOptions === 'object' ? attributionOptions : undefined) mapInstance.addControl(defaultAttributionControl.value) } + if (mapOptions?.maplibreLogo) + mapInstance.addControl(new instance.maplibregl.LogoControl(), mapOptions.logoPosition) configureCanvasAccessibility(mapInstance) bindMapEvents(mapInstance) map.value = mapInstance diff --git a/test/e2e/maplibre.test.ts b/test/e2e/maplibre.test.ts index a81d2958f..e3e6c72a8 100644 --- a/test/e2e/maplibre.test.ts +++ b/test/e2e/maplibre.test.ts @@ -161,6 +161,21 @@ describe('maplibre in a real browser', { timeout: 60000 }, async () => { expect(center[0]).toBeCloseTo(-1.2, 3) }) + it('keeps the native vertical order when the logo shares the attribution corner', async () => { + const page = await openMap('/logo') + const readCorner = () => page.evaluate(() => { + const corner = document.querySelector('.maplibregl-ctrl-bottom-right') + const logo = corner?.querySelector('.maplibregl-ctrl-logo')?.closest('.maplibregl-ctrl') + const attribution = corner?.querySelector('.maplibregl-ctrl-attrib') + if (!corner || !logo || !attribution) + return null + // A native map renders the logo above the attribution when both sit in a + // bottom corner, which is the DOM order inside the corner container. + return Boolean(logo.compareDocumentPosition(attribution) & Node.DOCUMENT_POSITION_FOLLOWING) + }) + await expect.poll(readCorner).toBe(true) + }) + it('shows the default attribution once on a map without an attribution control', async () => { const page = await openMap('/style-swap') const readAttribution = () => page.evaluate(() => [...document.querySelectorAll('.maplibregl-ctrl-attrib')].map(control => control.textContent!.trim())) diff --git a/test/fixtures/maplibre/pages/logo.vue b/test/fixtures/maplibre/pages/logo.vue new file mode 100644 index 000000000..2eb733caa --- /dev/null +++ b/test/fixtures/maplibre/pages/logo.vue @@ -0,0 +1,24 @@ + + +