From e04037e5f58d212c8c2c96811e02f5aa8ecce69e Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 10 Sep 2026 14:57:21 -0500 Subject: [PATCH 1/2] Run the FetchStats analysis once per selection The modern layout starts the plugin through initialize() and make(), and both subscribed to the AOI selection, so one selection ran the statistics round twice. The second start is now a no-op, and a run still in flight when the plugin is destroyed no longer reaches the bus. --- .../Tools/FetchStats/FetchStatsTool.js | 18 ++- .../__tests__/fetchStatsLifecycle.spec.js | 103 ++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js diff --git a/src/essence/Tools/FetchStats/FetchStatsTool.js b/src/essence/Tools/FetchStats/FetchStatsTool.js index e94158b27..bbb9c2b98 100644 --- a/src/essence/Tools/FetchStats/FetchStatsTool.js +++ b/src/essence/Tools/FetchStats/FetchStatsTool.js @@ -37,12 +37,18 @@ const FetchStatsTool = { MMGISInterface: null, _api: null, _cleanups: [], + made: false, initialize() { this.make(null) }, make(targetId) { + // The modern layout calls initialize() then make(); the guard makes + // the second start a no-op. It discards targetId, which is safe only + // because this plugin renders nothing. + if (this.made) return + this.made = true this.MMGISInterface = new interfaceWithMMGIS(this, targetId) this._api = (typeof window !== 'undefined' && @@ -64,9 +70,13 @@ const FetchStatsTool = { }, destroy() { + this.made = false this._cleanups.forEach((fn) => fn()) this._cleanups = [] this.MMGISInterface?.separateFromMMGIS() + // An analysis already awaiting the network cannot be cancelled, so + // dropping the handle is what keeps its remaining emits off the bus. + this._api = null }, async _getAnalyzableVisibleLayers() { @@ -92,11 +102,11 @@ const FetchStatsTool = { const layers = await this._getAnalyzableVisibleLayers() if (!layers.length) { - this._api.emit('analysisSkipped', { reason: 'no-eligible-layers' }) + this._api?.emit('analysisSkipped', { reason: 'no-eligible-layers' }) return } - this._api.emit('analysisProgress', { done: 0, total: layers.length }) + this._api?.emit('analysisProgress', { done: 0, total: layers.length }) const body = JSON.stringify({ type: 'Feature', @@ -110,13 +120,13 @@ const FetchStatsTool = { const displayName = layer.display_name || layer.name const result = await this._postStatsForLayer(layer, body) done += 1 - this._api.emit('analysisProgress', { done, total: layers.length }) + this._api?.emit('analysisProgress', { done, total: layers.length }) return [displayName, result] }) ) const analysisData = Object.fromEntries(entries) - this._api.emit('analysisReady', { analysisData }) + this._api?.emit('analysisReady', { analysisData }) }, async _postStatsForLayer(layer, body) { diff --git a/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js b/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js new file mode 100644 index 000000000..18ab8320c --- /dev/null +++ b/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js @@ -0,0 +1,103 @@ +import { test, expect, vi, beforeEach, afterEach } from 'vitest' + +import FetchStatsTool from '../FetchStatsTool' + +// FetchStats renders nothing, so the classic layout reaches it through +// initialize() alone while the modern layout calls initialize() and then +// make(). One subscription either way is the point: two would answer a single +// AOI selection with two full analysis rounds. + +const AOI_READY = 'plugin:aoi:analysisAOIReady' + +const AOI = { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, +} + +const ANALYSIS_LAYER = { + display_name: 'Burn severity', + variables: { + analysis: { + is_analysis_supported: true, + itemUrl: 'https://titiler.example/item', + assets: ['data'], + }, + }, +} + +const flush = () => new Promise((resolve) => setTimeout(resolve)) + +let subscriptions +let handlers +let emit +let deferConfig + +beforeEach(() => { + subscriptions = [] + handlers = {} + emit = vi.fn() + deferConfig = null + window.mmgisAPI = { + on: (event, handler) => { + subscriptions.push(event) + handlers[event] = handler + return vi.fn() + }, + forPlugin: () => ({ emit }), + request: (name) => { + if (name === 'layers:getVisible') return Promise.resolve({ uuid: true }) + if (name === 'layers:getAll') return Promise.resolve(['uuid']) + // Parks the analysis run mid-flight so the test can tear the + // plugin down before any result comes back. + return new Promise((resolve) => { + deferConfig = resolve + }) + }, + } +}) + +afterEach(() => { + FetchStatsTool.destroy() + delete window.mmgisAPI + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +test('the classic layout subscribes from initialize() alone', () => { + FetchStatsTool.initialize() + + expect(subscriptions).toEqual([AOI_READY]) +}) + +test('the modern layout, calling both start hooks, still subscribes once', () => { + FetchStatsTool.initialize() + FetchStatsTool.make('fetch-stats-target') + + expect(subscriptions).toEqual([AOI_READY]) +}) + +test('an analysis resolving after teardown announces nothing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ b1: {} }) })) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + FetchStatsTool.initialize() + + handlers[AOI_READY]({ feature: AOI }) + await flush() + + FetchStatsTool.destroy() + deferConfig(ANALYSIS_LAYER) + await flush() + + expect(emit).not.toHaveBeenCalled() + // Reaching a dropped handle through a bare .emit throws, and the + // subscription's catch would log that away instead of failing the + // assertion above. + expect(warn).not.toHaveBeenCalled() +}) From 15a63b77bcb9902527a37f782fc62fc61b60bbda Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 10 Sep 2026 16:43:13 -0500 Subject: [PATCH 2/2] Pin that a reload subscribes again --- src/essence/Tools/FetchStats/FetchStatsTool.js | 5 ++--- .../FetchStats/__tests__/fetchStatsLifecycle.spec.js | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/essence/Tools/FetchStats/FetchStatsTool.js b/src/essence/Tools/FetchStats/FetchStatsTool.js index bbb9c2b98..50ab4d3cf 100644 --- a/src/essence/Tools/FetchStats/FetchStatsTool.js +++ b/src/essence/Tools/FetchStats/FetchStatsTool.js @@ -44,9 +44,8 @@ const FetchStatsTool = { }, make(targetId) { - // The modern layout calls initialize() then make(); the guard makes - // the second start a no-op. It discards targetId, which is safe only - // because this plugin renders nothing. + // The guard drops the targetId the second start passes, which is + // safe only because this plugin renders nothing. if (this.made) return this.made = true this.MMGISInterface = new interfaceWithMMGIS(this, targetId) diff --git a/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js b/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js index 18ab8320c..ec84ad10c 100644 --- a/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js +++ b/src/essence/Tools/FetchStats/__tests__/fetchStatsLifecycle.spec.js @@ -80,6 +80,17 @@ test('the modern layout, calling both start hooks, still subscribes once', () => expect(subscriptions).toEqual([AOI_READY]) }) +test('a reload subscribes again, once per live start', () => { + FetchStatsTool.initialize() + FetchStatsTool.destroy() + FetchStatsTool.initialize() + + // destroy() clears the started flag, so the reload reaches api.on a + // second time. A plugin left flagged as started would sit deaf for the + // rest of the session. + expect(subscriptions).toEqual([AOI_READY, AOI_READY]) +}) + test('an analysis resolving after teardown announces nothing', async () => { vi.stubGlobal( 'fetch',