From b37fde19169e7dff2855ae6d121572575484ec1c Mon Sep 17 00:00:00 2001 From: bruehlca Date: Fri, 6 Mar 2026 09:47:03 +0100 Subject: [PATCH 01/22] feat(core): add plugin store watcher --- src/core/composables/usePluginStoreWatcher.ts | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/core/composables/usePluginStoreWatcher.ts diff --git a/src/core/composables/usePluginStoreWatcher.ts b/src/core/composables/usePluginStoreWatcher.ts new file mode 100644 index 0000000000..de7b30fb80 --- /dev/null +++ b/src/core/composables/usePluginStoreWatcher.ts @@ -0,0 +1,165 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/core/composables/usePluginStoreWatcher + */ +/* eslint-enable tsdoc/syntax */ + +import { computed, watch, type ComputedRef, type WatchHandle } from 'vue' + +import type { StoreReference } from '@/core/types' + +import { usePluginStore } from '@/core/stores/plugin' + +/** + * Configuration for a single store reference watcher. + * @internal + */ +interface WatcherConfig { + callback: (value: unknown) => void | Promise + handle: WatchHandle | null + source: StoreReference +} + +/** + * Generic composable for watching multiple plugin store references. + * + * It watches the list of installed plugins to detect when target plugins + * are added or removed, and manages the corresponding watchers accordingly. + * + * @param sources - Array of plugin store references to watch, or a computed reference to them, or a function returning them + * @param callback - Function called when any watched plugin store value changes + * @returns Object with setup and cleanup methods for plugin lifecycle + * + * @example + * ```typescript + * const { setupPlugin, teardownPlugin } = usePluginStoreWatcher( + * () => configuration.value.coordinateSources || [], + * (coordinate) => { + * if (coordinate) { + * await reverseGeocode(coordinate) + * } + * } + * ) + * + * // In setupPlugin(): + * setupPlugin() + * + * // In teardownPlugin(): + * teardownPlugin() + * ``` + * + * @internal + */ +export function usePluginStoreWatcher( + sources: + | StoreReference[] + | ComputedRef + | (() => StoreReference[]), + callback: (value: unknown) => void | Promise +) { + const pluginStore = usePluginStore() + const sourcesArray = computed(() => { + if (typeof sources === 'function') { + return sources() + } + if ('value' in sources) { + return sources.value + } + return sources + }) + + const watchers: WatcherConfig[] = [] + let pluginListWatcher: WatchHandle | null = null + let sourcesWatcher: WatchHandle | null = null + + function setupWatcherForSource(watcherConfig: WatcherConfig) { + if (watcherConfig.handle !== null || !watcherConfig.source.plugin) { + return + } + + const store = pluginStore.getPluginStore(watcherConfig.source.plugin) + + if (!store) { + return + } + + watcherConfig.handle = watch( + () => store[watcherConfig.source.key], + watcherConfig.callback + ) + } + + function removeWatcherForSource(watcherConfig: WatcherConfig) { + if (watcherConfig.handle) { + watcherConfig.handle() + watcherConfig.handle = null + } + } + + function updateWatchersBasedOnInstalledPlugins() { + const currentSources = sourcesArray.value + + watchers.forEach((watcherConfig, index) => { + if (!currentSources.some((s) => s === watcherConfig.source)) { + removeWatcherForSource(watcherConfig) + watchers.splice(index, 1) + } + }) + + currentSources.forEach((source) => { + let watcherConfig = watchers.find((w) => w.source === source) + + if (!watcherConfig) { + watcherConfig = { source, callback, handle: null } + watchers.push(watcherConfig) + } + + const pluginIsInstalled = pluginStore.plugins.some( + (plugin) => plugin.id === source.plugin + ) + + if (pluginIsInstalled && !watcherConfig.handle) { + setupWatcherForSource(watcherConfig) + } else if (!pluginIsInstalled && watcherConfig.handle) { + removeWatcherForSource(watcherConfig) + } + }) + } + + function setupPlugin() { + updateWatchersBasedOnInstalledPlugins() + + sourcesWatcher = watch(sourcesArray, () => { + updateWatchersBasedOnInstalledPlugins() + }) + + pluginListWatcher = watch( + () => pluginStore.plugins.map((p) => p.id), + () => { + updateWatchersBasedOnInstalledPlugins() + } + ) + } + + function teardownPlugin() { + watchers.forEach((watcher) => { + removeWatcherForSource(watcher) + }) + watchers.length = 0 + + if (sourcesWatcher) { + sourcesWatcher() + sourcesWatcher = null + } + + if (pluginListWatcher) { + pluginListWatcher() + pluginListWatcher = null + } + } + + return { + setupPlugin, + teardownPlugin, + } +} From 4f33de0884a9c16be9e55dd3c777b6ee893ee492 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Fri, 6 Mar 2026 11:27:09 +0100 Subject: [PATCH 02/22] refactor(pins): use pluginStoreWatcher for watching coordinateSource --- src/plugins/pins/store.ts | 78 ++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/plugins/pins/store.ts b/src/plugins/pins/store.ts index 981c207710..9da4f442a7 100644 --- a/src/plugins/pins/store.ts +++ b/src/plugins/pins/store.ts @@ -16,10 +16,11 @@ import VectorLayer from 'ol/layer/Vector' import { toLonLat } from 'ol/proj' import { Vector } from 'ol/source' import { defineStore } from 'pinia' -import { computed, ref, watch, type WatchHandle } from 'vue' +import { computed, ref } from 'vue' import type { PolarGeoJsonFeature } from '@/core' +import { usePluginStoreWatcher } from '@/core/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import type { PinMovable, PinsPluginOptions } from './types' @@ -28,6 +29,35 @@ import { getPinStyle } from './utils/getPinStyle' import { getPointCoordinate } from './utils/getPointCoordinate' import { isCoordinateInBoundaryLayer } from './utils/isCoordinateInBoundaryLayer' +function isPolarGeoJsonPoint( + feature: unknown +): feature is PolarGeoJsonFeature { + if (feature === null || typeof feature !== 'object') { + return false + } + + const obj = feature as Record + // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features + // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. + if (!('type' in obj) || obj.type === 'reverse_geocoded') { + return false + } + + if ( + !('geometry' in obj) || + typeof obj.geometry !== 'object' || + obj.geometry === null + ) { + return false + } + + const geometry = obj.geometry as Record + + return ( + 'type' in geometry && geometry.type === 'Point' && 'coordinates' in geometry + ) +} + /* eslint-disable tsdoc/syntax */ /** * @function @@ -77,7 +107,17 @@ export const usePinsStore = defineStore('plugins/pins', () => { configuration.value.minZoomLevel, layers: [pinLayer], }) - let coordinateSourceWatcher: WatchHandle | null = null + + const sourceWatchers = usePluginStoreWatcher( + () => configuration.value.coordinateSources || [], + (feature: unknown) => { + if (isPolarGeoJsonPoint(feature)) { + addPin(feature.geometry.coordinates, false, { + type: feature.geometry.type, + }) + } + } + ) function setupPlugin() { coreStore.map.addLayer(pinLayer) @@ -85,7 +125,7 @@ export const usePinsStore = defineStore('plugins/pins', () => { coreStore.map.on('singleclick', async ({ coordinate }) => { await click(coordinate) }) - setupCoordinateSource() + sourceWatchers.setupPlugin() setupInitial() setupInteractions() } @@ -99,37 +139,7 @@ export const usePinsStore = defineStore('plugins/pins', () => { map.removeLayer(pinLayer) map.removeInteraction(move) map.removeInteraction(translate) - if (coordinateSourceWatcher) { - coordinateSourceWatcher() - } - } - - function setupCoordinateSource() { - const { coordinateSources } = configuration.value - if (!coordinateSources) { - return - } - coordinateSources.forEach((source) => { - const store = source.plugin - ? coreStore.getPluginStore(source.plugin) - : coreStore - if (!store) { - return - } - // redo pin if source (e.g. from addressSearch) changes - coordinateSourceWatcher = watch( - () => store[source.key], - (feature: PolarGeoJsonFeature | null) => { - // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features - // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. - if (feature && feature.type !== 'reverse_geocoded') { - addPin(feature.geometry.coordinates, false, { - type: feature.geometry.type, - }) - } - } - ) - }) + sourceWatchers.teardownPlugin() } function setupInitial() { From 11b075c978fa314f5e9819a4e9992a7f06d19939 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Fri, 6 Mar 2026 11:27:37 +0100 Subject: [PATCH 03/22] refactor(pins): use pluginStoreWatcher for watching coordinateSource --- src/plugins/reverseGeocoder/store.ts | 45 +++++++++++++--------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts index 31ac5cbdc4..0dbea9299b 100644 --- a/src/plugins/reverseGeocoder/store.ts +++ b/src/plugins/reverseGeocoder/store.ts @@ -9,8 +9,9 @@ import type { Mock } from 'vitest' import { easeOut } from 'ol/easing' import { Point } from 'ol/geom' import { acceptHMRUpdate, defineStore } from 'pinia' -import { computed, ref, watch, type Reactive, type WatchHandle } from 'vue' +import { computed, type Reactive } from 'vue' +import { usePluginStoreWatcher } from '@/core/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import { indicateLoading } from '@/lib/indicateLoading' @@ -21,6 +22,15 @@ import { } from './types' import { reverseGeocode as reverseGeocodeUtil } from './utils/reverseGeocode' +function isCoordinate(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' + ) +} + /* eslint-disable tsdoc/syntax */ /** * @function @@ -37,34 +47,21 @@ export const useReverseGeocoderStore = defineStore( () => coreStore.configuration[PluginId] as ReverseGeocoderPluginOptions ) - const watchHandles = ref([]) - - function setupPlugin() { - for (const source of configuration.value.coordinateSources || []) { - const store = source.plugin - ? coreStore.getPluginStore(source.plugin) - : coreStore - if (!store) { - continue + const sourceWatchers = usePluginStoreWatcher( + () => configuration.value.coordinateSources || [], + async (coordinate: unknown) => { + if (isCoordinate(coordinate)) { + await reverseGeocode(coordinate) } - watchHandles.value.push( - watch( - () => store[source.key], - async (coordinate) => { - if (coordinate) { - await reverseGeocode(coordinate) - } - }, - { immediate: true } - ) - ) } + ) + + function setupPlugin() { + sourceWatchers.setupPlugin() } function teardownPlugin() { - watchHandles.value.forEach((handle) => { - handle() - }) + sourceWatchers.teardownPlugin() } function passFeatureToTarget( From 1fc27741363053d53431a43cf2239ef146359882 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Fri, 6 Mar 2026 14:58:09 +0100 Subject: [PATCH 04/22] Merge remote-tracking branch 'origin/next' into vue3/add-plugin-store-watcher From f528a15e25ac429c7ed5092a6489e6ed20137a12 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Fri, 6 Mar 2026 16:02:50 +0100 Subject: [PATCH 05/22] test: accept use of usePluginStoreWatcher in plugins --- src/architecture.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/architecture.spec.ts b/src/architecture.spec.ts index ff18b41f7a..fbac6ab065 100644 --- a/src/architecture.spec.ts +++ b/src/architecture.spec.ts @@ -44,7 +44,9 @@ describe('Architectural checks', () => { .matchingPattern('^plugins/.*$') .shouldNot() .dependOnFiles() - .matchingPattern('^core/(?!(index|stores/index)\\.ts$).*$') + .matchingPattern( + '^core/(?!(index|stores/index|composables/usePluginStoreWatcher)\\.ts$).*$' + ) .check() expect(violations).toEqual([]) }) From be3377b2e98c80940ccfefa67bf93eaed4c565ed Mon Sep 17 00:00:00 2001 From: bruehlca Date: Fri, 6 Mar 2026 16:07:31 +0100 Subject: [PATCH 06/22] fix(core): watch coreStore in usePluginWatcher --- src/core/composables/usePluginStoreWatcher.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/core/composables/usePluginStoreWatcher.ts b/src/core/composables/usePluginStoreWatcher.ts index de7b30fb80..a05aa9b4be 100644 --- a/src/core/composables/usePluginStoreWatcher.ts +++ b/src/core/composables/usePluginStoreWatcher.ts @@ -8,6 +8,7 @@ import { computed, watch, type ComputedRef, type WatchHandle } from 'vue' import type { StoreReference } from '@/core/types' +import { useCoreStore } from '@/core/stores' import { usePluginStore } from '@/core/stores/plugin' /** @@ -57,6 +58,7 @@ export function usePluginStoreWatcher( | (() => StoreReference[]), callback: (value: unknown) => void | Promise ) { + const coreStore = useCoreStore() const pluginStore = usePluginStore() const sourcesArray = computed(() => { if (typeof sources === 'function') { @@ -73,7 +75,18 @@ export function usePluginStoreWatcher( let sourcesWatcher: WatchHandle | null = null function setupWatcherForSource(watcherConfig: WatcherConfig) { - if (watcherConfig.handle !== null || !watcherConfig.source.plugin) { + if (watcherConfig.handle !== null) { + return + } + + if (!watcherConfig.source.plugin) { + watcherConfig.handle = watch( + () => + (coreStore as unknown as Record)[ + watcherConfig.source.key + ], + watcherConfig.callback + ) return } @@ -114,9 +127,9 @@ export function usePluginStoreWatcher( watchers.push(watcherConfig) } - const pluginIsInstalled = pluginStore.plugins.some( - (plugin) => plugin.id === source.plugin - ) + const pluginIsInstalled = + !source.plugin || + pluginStore.plugins.some((plugin) => plugin.id === source.plugin) if (pluginIsInstalled && !watcherConfig.handle) { setupWatcherForSource(watcherConfig) From c5ed058f6091361fd2afc7d88f1573c4bc577afe Mon Sep 17 00:00:00 2001 From: bruehlca Date: Mon, 9 Mar 2026 09:44:02 +0100 Subject: [PATCH 07/22] test: switch order of plugins for testing plugin store watcher --- examples/snowbox/index.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index ac13fea773..f3c08b2216 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -288,20 +288,6 @@ addPlugin( }, }) ) -addPlugin( - map, - pluginPins({ - coordinateSources: [{ plugin: 'addressSearch', key: 'chosenAddress' }], - boundary: { - layerId: hamburgBorder, - }, - movable: 'drag', - style: { - fill: '#FF0019', - }, - toZoomLevel: 7, - }) -) addPlugin( map, pluginReverseGeocoder({ @@ -319,6 +305,20 @@ addPlugin( zoomTo: 7, }) ) +addPlugin( + map, + pluginPins({ + coordinateSources: [{ plugin: 'addressSearch', key: 'chosenAddress' }], + boundary: { + layerId: hamburgBorder, + }, + movable: 'drag', + style: { + fill: '#FF0019', + }, + toZoomLevel: 7, + }) +) addPlugin( map, pluginIconMenu({ From 43dc80943106129e3d63172211f35e7d375a0270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carina=20Br=C3=BChl?= <151126989+jedi-of-the-sea@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:55:36 +0100 Subject: [PATCH 08/22] Update src/core/composables/usePluginStoreWatcher.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pascal Röhling <73653210+dopenguin@users.noreply.github.com> --- src/core/composables/usePluginStoreWatcher.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/composables/usePluginStoreWatcher.ts b/src/core/composables/usePluginStoreWatcher.ts index a05aa9b4be..e313ba7ede 100644 --- a/src/core/composables/usePluginStoreWatcher.ts +++ b/src/core/composables/usePluginStoreWatcher.ts @@ -29,7 +29,6 @@ interface WatcherConfig { * * @param sources - Array of plugin store references to watch, or a computed reference to them, or a function returning them * @param callback - Function called when any watched plugin store value changes - * @returns Object with setup and cleanup methods for plugin lifecycle * * @example * ```typescript From 7c0f3cccf09a0270d17c3a85beff7d394298ad50 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 14:19:38 +0100 Subject: [PATCH 09/22] test: restore original test --- src/architecture.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/architecture.spec.ts b/src/architecture.spec.ts index fbac6ab065..ff18b41f7a 100644 --- a/src/architecture.spec.ts +++ b/src/architecture.spec.ts @@ -44,9 +44,7 @@ describe('Architectural checks', () => { .matchingPattern('^plugins/.*$') .shouldNot() .dependOnFiles() - .matchingPattern( - '^core/(?!(index|stores/index|composables/usePluginStoreWatcher)\\.ts$).*$' - ) + .matchingPattern('^core/(?!(index|stores/index)\\.ts$).*$') .check() expect(violations).toEqual([]) }) From a977903f7e6458514705bef9a7a16968a3220244 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 14:27:15 +0100 Subject: [PATCH 10/22] refactor: extract pluginStoreWatcher from core --- src/{core => }/composables/usePluginStoreWatcher.ts | 0 src/plugins/pins/store.ts | 2 +- src/plugins/reverseGeocoder/store.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{core => }/composables/usePluginStoreWatcher.ts (100%) diff --git a/src/core/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts similarity index 100% rename from src/core/composables/usePluginStoreWatcher.ts rename to src/composables/usePluginStoreWatcher.ts diff --git a/src/plugins/pins/store.ts b/src/plugins/pins/store.ts index 9da4f442a7..69ceaf8a09 100644 --- a/src/plugins/pins/store.ts +++ b/src/plugins/pins/store.ts @@ -20,7 +20,7 @@ import { computed, ref } from 'vue' import type { PolarGeoJsonFeature } from '@/core' -import { usePluginStoreWatcher } from '@/core/composables/usePluginStoreWatcher' +import { usePluginStoreWatcher } from '@/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import type { PinMovable, PinsPluginOptions } from './types' diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts index 0dbea9299b..a42fd39e5a 100644 --- a/src/plugins/reverseGeocoder/store.ts +++ b/src/plugins/reverseGeocoder/store.ts @@ -11,7 +11,7 @@ import { Point } from 'ol/geom' import { acceptHMRUpdate, defineStore } from 'pinia' import { computed, type Reactive } from 'vue' -import { usePluginStoreWatcher } from '@/core/composables/usePluginStoreWatcher' +import { usePluginStoreWatcher } from '@/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import { indicateLoading } from '@/lib/indicateLoading' From 074ec91d6071b28e0bf90e1ececa249b98b214f6 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 14:32:33 +0100 Subject: [PATCH 11/22] fix: delete unnnecessary comments --- src/composables/usePluginStoreWatcher.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index e313ba7ede..1edbc8276c 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -40,12 +40,6 @@ interface WatcherConfig { * } * } * ) - * - * // In setupPlugin(): - * setupPlugin() - * - * // In teardownPlugin(): - * teardownPlugin() * ``` * * @internal From bea374ccf0762f3ebc95e7bc3ecc16c1488543d4 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 14:50:07 +0100 Subject: [PATCH 12/22] refactor: access plugin store through coreStore --- src/composables/usePluginStoreWatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index 1edbc8276c..33e79b5844 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -83,7 +83,7 @@ export function usePluginStoreWatcher( return } - const store = pluginStore.getPluginStore(watcherConfig.source.plugin) + const store = coreStore.getPluginStore(watcherConfig.source.plugin) if (!store) { return From 9456b592042bc7c0263f42ed748f08264a2516c3 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 15:01:07 +0100 Subject: [PATCH 13/22] refactor: use getPluginStore instead of manually looking through plugin array --- src/composables/usePluginStoreWatcher.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index 33e79b5844..2387fbb123 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -121,8 +121,7 @@ export function usePluginStoreWatcher( } const pluginIsInstalled = - !source.plugin || - pluginStore.plugins.some((plugin) => plugin.id === source.plugin) + !source.plugin || coreStore.getPluginStore(source.plugin) !== null if (pluginIsInstalled && !watcherConfig.handle) { setupWatcherForSource(watcherConfig) From e8a36a9bb9918e189c53e08457c5483d899d43f7 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 15:14:50 +0100 Subject: [PATCH 14/22] refactor(core): add getter usedPlugins --- src/composables/usePluginStoreWatcher.ts | 4 +--- src/core/stores/index.ts | 7 +++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index 2387fbb123..a64eb10578 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -9,7 +9,6 @@ import { computed, watch, type ComputedRef, type WatchHandle } from 'vue' import type { StoreReference } from '@/core/types' import { useCoreStore } from '@/core/stores' -import { usePluginStore } from '@/core/stores/plugin' /** * Configuration for a single store reference watcher. @@ -52,7 +51,6 @@ export function usePluginStoreWatcher( callback: (value: unknown) => void | Promise ) { const coreStore = useCoreStore() - const pluginStore = usePluginStore() const sourcesArray = computed(() => { if (typeof sources === 'function') { return sources() @@ -139,7 +137,7 @@ export function usePluginStoreWatcher( }) pluginListWatcher = watch( - () => pluginStore.plugins.map((p) => p.id), + () => coreStore.usedPlugins, () => { updateWatchersBasedOnInstalledPlugins() } diff --git a/src/core/stores/index.ts b/src/core/stores/index.ts index 1f173e286a..f018ba79c6 100644 --- a/src/core/stores/index.ts +++ b/src/core/stores/index.ts @@ -169,6 +169,13 @@ export const useCoreStore = defineStore('core', () => { */ getPluginStore: pluginStore.getPluginStore, + /** + * Returns a list of IDs of all currently installed plugins. + * + * @readonly + */ + usedPlugins: computed(() => pluginStore.plugins.map((p) => p.id)), + /** * Allows reading or setting the OIDC token used for service accesses. */ From 52e24fd1327f62d7c5c5482ae4078bb211616be4 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 15:46:24 +0100 Subject: [PATCH 15/22] refactor: change pluginIsInstalled to require both plugin ID and loaded store --- src/composables/usePluginStoreWatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index a64eb10578..36c9342177 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -119,7 +119,7 @@ export function usePluginStoreWatcher( } const pluginIsInstalled = - !source.plugin || coreStore.getPluginStore(source.plugin) !== null + source.plugin && coreStore.getPluginStore(source.plugin) if (pluginIsInstalled && !watcherConfig.handle) { setupWatcherForSource(watcherConfig) From 2aac0a44071fed3a8a7d0fba80319526e6903c3c Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 16:32:25 +0100 Subject: [PATCH 16/22] refactor: delete unreachable code, leave guard check to prevent type narrowing/safety error --- src/composables/usePluginStoreWatcher.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index 36c9342177..6fae82bb60 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -71,13 +71,6 @@ export function usePluginStoreWatcher( } if (!watcherConfig.source.plugin) { - watcherConfig.handle = watch( - () => - (coreStore as unknown as Record)[ - watcherConfig.source.key - ], - watcherConfig.callback - ) return } From 4a7d35277a178f8d8e4e4d0dddd800bd4c10de8a Mon Sep 17 00:00:00 2001 From: bruehlca Date: Wed, 11 Mar 2026 16:38:11 +0100 Subject: [PATCH 17/22] feat: add warnings when plugin store cannot be found --- src/composables/usePluginStoreWatcher.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts index 6fae82bb60..53b2cae998 100644 --- a/src/composables/usePluginStoreWatcher.ts +++ b/src/composables/usePluginStoreWatcher.ts @@ -77,6 +77,9 @@ export function usePluginStoreWatcher( const store = coreStore.getPluginStore(watcherConfig.source.plugin) if (!store) { + console.warn( + `usePluginStoreWatcher: "${watcherConfig.source.plugin}" not found. Cannot watch "${watcherConfig.source.key}".` + ) return } From ed83242c9b893df26d1089b41c61b894435531b2 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Thu, 12 Mar 2026 09:30:09 +0100 Subject: [PATCH 18/22] fix(pins): correct callback parameter types for usePluginStoreWatcher --- src/plugins/pins/store.ts | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/src/plugins/pins/store.ts b/src/plugins/pins/store.ts index 69ceaf8a09..82afed238a 100644 --- a/src/plugins/pins/store.ts +++ b/src/plugins/pins/store.ts @@ -29,35 +29,6 @@ import { getPinStyle } from './utils/getPinStyle' import { getPointCoordinate } from './utils/getPointCoordinate' import { isCoordinateInBoundaryLayer } from './utils/isCoordinateInBoundaryLayer' -function isPolarGeoJsonPoint( - feature: unknown -): feature is PolarGeoJsonFeature { - if (feature === null || typeof feature !== 'object') { - return false - } - - const obj = feature as Record - // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features - // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. - if (!('type' in obj) || obj.type === 'reverse_geocoded') { - return false - } - - if ( - !('geometry' in obj) || - typeof obj.geometry !== 'object' || - obj.geometry === null - ) { - return false - } - - const geometry = obj.geometry as Record - - return ( - 'type' in geometry && geometry.type === 'Point' && 'coordinates' in geometry - ) -} - /* eslint-disable tsdoc/syntax */ /** * @function @@ -110,8 +81,11 @@ export const usePinsStore = defineStore('plugins/pins', () => { const sourceWatchers = usePluginStoreWatcher( () => configuration.value.coordinateSources || [], - (feature: unknown) => { - if (isPolarGeoJsonPoint(feature)) { + (value: unknown) => { + const feature = value as PolarGeoJsonFeature | null + // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features + // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. + if (feature && feature.type !== 'reverse_geocoded') { addPin(feature.geometry.coordinates, false, { type: feature.geometry.type, }) From f89d83e22491405fc9bb99de9090cce21e295105 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Thu, 12 Mar 2026 09:30:31 +0100 Subject: [PATCH 19/22] fix(reverseGeocoder): correct callback parameter types for usePluginStoreWatcher --- src/plugins/reverseGeocoder/store.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts index a42fd39e5a..64f4fef273 100644 --- a/src/plugins/reverseGeocoder/store.ts +++ b/src/plugins/reverseGeocoder/store.ts @@ -22,15 +22,6 @@ import { } from './types' import { reverseGeocode as reverseGeocodeUtil } from './utils/reverseGeocode' -function isCoordinate(value: unknown): value is [number, number] { - return ( - Array.isArray(value) && - value.length === 2 && - typeof value[0] === 'number' && - typeof value[1] === 'number' - ) -} - /* eslint-disable tsdoc/syntax */ /** * @function @@ -49,8 +40,9 @@ export const useReverseGeocoderStore = defineStore( const sourceWatchers = usePluginStoreWatcher( () => configuration.value.coordinateSources || [], - async (coordinate: unknown) => { - if (isCoordinate(coordinate)) { + async (value: unknown) => { + const coordinate = value as [number, number] | null + if (coordinate) { await reverseGeocode(coordinate) } } From 9f6d623b155a4c560bf9d59772e2345b694873c8 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Thu, 12 Mar 2026 13:02:13 +0100 Subject: [PATCH 20/22] test(reverseGeocoder): adjust tests to watcher changes --- src/plugins/reverseGeocoder/store.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts index 64f4fef273..a5b2bbcd5a 100644 --- a/src/plugins/reverseGeocoder/store.ts +++ b/src/plugins/reverseGeocoder/store.ts @@ -149,11 +149,16 @@ if (import.meta.vitest) { coreStore: [ async ({}, use) => { const fit = vi.fn() + const pluginStores = { + pins: reactive({ + coordinate: null, + }), + } const coreStore = reactive({ configuration: { [PluginId]: { url: 'https://wps.example', - coordinateSources: [{ key: 'coordinateSource' }], + coordinateSources: [{ plugin: 'pins', key: 'coordinate' }], addressTarget: { key: 'addressTarget' }, zoomTo: 99, }, @@ -161,12 +166,12 @@ if (import.meta.vitest) { map: { getView: () => ({ fit }), }, - coordinateSource: null, addressTarget: vi.fn(), + getPluginStore: (plugin) => pluginStores[plugin] || null, }) // @ts-expect-error | Mocking useCoreStore vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(coreStore) - await use(coreStore) + await use({ ...coreStore, pluginStores }) }, { auto: true }, ], @@ -187,7 +192,12 @@ if (import.meta.vitest) { reverseGeocodeUtil, coreStore, }) => { - coreStore.coordinateSource = [1, 2] + const pluginStore = coreStore.pluginStores as { + pins: { + coordinate: [number, number] | null + } + } + pluginStore.pins.coordinate = [1, 2] await new Promise((resolve) => setTimeout(resolve)) expect(reverseGeocodeUtil).toHaveBeenCalledWith( 'https://wps.example', From fecfba85a494cceb916c355c2b66e26e40fe5729 Mon Sep 17 00:00:00 2001 From: bruehlca Date: Thu, 12 Mar 2026 13:33:50 +0100 Subject: [PATCH 21/22] refactor(attributions): use pluginStoreWatcher for watching listenToChanges --- src/plugins/attributions/store.ts | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/plugins/attributions/store.ts b/src/plugins/attributions/store.ts index 98e82ae7f0..355f23f6ee 100644 --- a/src/plugins/attributions/store.ts +++ b/src/plugins/attributions/store.ts @@ -5,10 +5,9 @@ /* eslint-enable tsdoc/syntax */ import { acceptHMRUpdate, defineStore } from 'pinia' -import { computed, ref, watch, type WatchHandle } from 'vue' - -import type { StoreReference } from '@/core' +import { computed, ref } from 'vue' +import { usePluginStoreWatcher } from '@/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import { getVisibleAttributions } from '@/plugins/attributions/utils/getVisibleAttributions.ts' @@ -32,14 +31,9 @@ export const useAttributionsStore = defineStore('plugins/attributions', () => { const layers = ref([]) const windowIsOpen = ref(false) - const watchHandles = ref([]) - const configuration = computed( () => coreStore.configuration.attributions || {} ) - const listenToChanges = computed( - () => configuration.value.listenToChanges || [] - ) const mapInfo = computed(() => buildMapInfo( getVisibleAttributions(layers.value, attributions.value), @@ -59,19 +53,13 @@ export const useAttributionsStore = defineStore('plugins/attributions', () => { ) const windowWidth = computed(() => configuration.value.windowWidth || 500) + const listenersWatchers = usePluginStoreWatcher( + () => configuration.value.listenToChanges || [], + updateLayers + ) + function setupPlugin() { - // TODO: addPlugin order is still relevant if the wather is added like this - for (const listenReference of listenToChanges.value) { - const store = listenReference.plugin - ? coreStore.getPluginStore(listenReference.plugin) - : coreStore - if (!store) { - continue - } - watchHandles.value.push( - watch(() => store[listenReference.key], updateLayers) - ) - } + listenersWatchers.setupPlugin() const allLayers = coreStore.map.getLayers() allLayers.on('add', updateLayers) @@ -91,6 +79,7 @@ export const useAttributionsStore = defineStore('plugins/attributions', () => { } function teardownPlugin() { + listenersWatchers.teardownPlugin() const allLayers = coreStore.map.getLayers() allLayers.un('add', updateLayers) allLayers.un('add', updateAttributions) From d5941bbee078ba4fa69fa5738b462c38fd74034e Mon Sep 17 00:00:00 2001 From: bruehlca Date: Thu, 12 Mar 2026 14:28:02 +0100 Subject: [PATCH 22/22] test: change order of plugins for more testing --- examples/snowbox/index.js | 135 +++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 66 deletions(-) diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index f3c08b2216..f9ed044760 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -232,6 +232,56 @@ document.getElementById('secondMapClean').addEventListener('click', () => { document.getElementById('secondMapContainer').innerText = '' }) +addPlugin( + map, + pluginFooter({ + leftEntries: [{ id: 'mockPointer', component: MockPointerPosition }], + rightEntries: [ + pluginScale({}), + pluginAttributions({ + icons: { + close: 'kern-icon--keyboard-arrow-up', + }, + listenToChanges: [ + { + key: 'activeBackgroundId', + plugin: 'layerChooser', + }, + { + key: 'activeMaskIds', + plugin: 'layerChooser', + }, + { + key: 'zoom', + }, + ], + layerAttributions: [ + { + id: basemapId, + title: 'snowbox.attributions.basemap', + }, + { + id: basemapGreyId, + title: 'snowbox.attributions.basemapGrey', + }, + { + id: reports, + title: 'snowbox.attributions.reports', + }, + { + id: ausgleichsflaechen, + title: 'snowbox.attributions.ausgleichsflaechen', + }, + { + id: denkmal, + title: `Karte Kulturdenkmale (Denkmalliste): © Landesamt für Denkmalpflege `, + }, + ], + }), + ], + }) +) + addPlugin( map, pluginToast({ @@ -265,29 +315,7 @@ addPlugin( loaderStyle: 'BasicLoader', }) ) -addPlugin( - map, - pluginAddressSearch({ - searchMethods: [ - { - queryParameters: { - searchStreets: true, - searchHouseNumbers: true, - }, - type: 'mpapi', - url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', - }, - ], - minLength: 3, - waitMs: 300, - focusAfterSearch: true, - groupProperties: { - defaultGroup: { - limitResults: 5, - }, - }, - }) -) + addPlugin( map, pluginReverseGeocoder({ @@ -373,53 +401,28 @@ addPlugin( ], }) ) + addPlugin( map, - pluginFooter({ - leftEntries: [{ id: 'mockPointer', component: MockPointerPosition }], - rightEntries: [ - pluginScale({}), - pluginAttributions({ - icons: { - close: 'kern-icon--keyboard-arrow-up', + pluginAddressSearch({ + searchMethods: [ + { + queryParameters: { + searchStreets: true, + searchHouseNumbers: true, }, - listenToChanges: [ - { - key: 'activeBackgroundId', - plugin: 'layerChooser', - }, - { - key: 'activeMaskIds', - plugin: 'layerChooser', - }, - { - key: 'zoom', - }, - ], - layerAttributions: [ - { - id: basemapId, - title: 'snowbox.attributions.basemap', - }, - { - id: basemapGreyId, - title: 'snowbox.attributions.basemapGrey', - }, - { - id: reports, - title: 'snowbox.attributions.reports', - }, - { - id: ausgleichsflaechen, - title: 'snowbox.attributions.ausgleichsflaechen', - }, - { - id: denkmal, - title: `Karte Kulturdenkmale (Denkmalliste): © Landesamt für Denkmalpflege `, - }, - ], - }), + type: 'mpapi', + url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', + }, ], + minLength: 3, + waitMs: 300, + focusAfterSearch: true, + groupProperties: { + defaultGroup: { + limitResults: 5, + }, + }, }) )