diff --git a/CHANGELOG.md b/CHANGELOG.md index 60675eedd..981a7803a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior. + - Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths. - Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9739c6bac..62611d8af 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -448,3 +448,16 @@ location framing use the supplied operations. Voice shares the same boundary and floor services, with analyst memory scoped to the runner. Direct compatibility entrypoints retain default services; normal assembly does not configure their source slots. + +## Layer lifecycle and presentation + +`data/lifecycle` owns registrations, visibility intent, refresh transactions, +parameters and teardown. Its package group contains one module and no external +imports. Adding a panel, renderer or application dependency fails the boundary +build, including unused imports. + +`app/layerPresentation` mounts the toggle panel and turns lifecycle activity into +render requests and detection invalidation. It owns hidden-panel refresh and +listener cleanup. Application data assembly constructs both owners explicitly; +`data/manager` is the compatibility facade for direct callers. The ordinary +state subscriptions retain their existing event contract. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dcc8cc85d..daccbb96c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +Layer lifecycle transactions are owned by `data/lifecycle`, which has no panel, +render-governor or detection imports. Application composition mounts a separate +layer presentation owner that reacts to status, accepted visibility/parameter +changes and non-throwing updates. Rejected partial updates still invalidate the +rendered detection set. The legacy manager retains panel helper compatibility; +registration sealing, restoration order and failed-transition rollback remain +unchanged. + + Application scene setup owns request services, terrain/floor caches and annotation lookup state. Controls, layers and voice share those owners, and cancellation clears caches and removes map-stack listeners. Standalone setup supplies the diff --git a/package.json b/package.json index 5469db8b7..d7adaae42 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "./application/catalog": "./src/app/catalog.js", "./standalone/catalog": "./src/standalone/catalog.js", "./application/layers": "./src/app/constructCatalog.js", - "./application/operations": "./src/app/operations.js" + "./application/operations": "./src/app/operations.js", + "./data/lifecycle": "./src/data/lifecycle.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b830e0562..3f37f1b28 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -586,5 +586,8 @@ "src/services/groundFloor.js", "src/services/meshFloorSampler.js", "src/services/groundSnap.js", - "src/app/operations.test.mjs" + "src/app/operations.test.mjs", + "src/data/lifecycle.js", + "src/data/manager.js", + "src/app/layerPresentation.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5ebd26b62..9a2b7040a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1051,16 +1051,38 @@ "src/annotations/annotationResolver.js", "src/annotations/hybridAnnotationRenderer.js", "src/annotations/index.js", + "src/annotations/resolver.js", "src/annotations/screenAnnotationRenderer.js", "src/annotations/worldAnnotationRenderer.js", + "src/app/catalog.js", + "src/app/constructCatalog.js", "src/app/controls.js", "src/app/data.js", "src/app/errors.js", + "src/app/layerPresentation.js", + "src/app/layers/aisLiveVessels.js", + "src/app/layers/alprCameras.js", + "src/app/layers/bikeshare.js", + "src/app/layers/cctv.js", + "src/app/layers/earthquakes.js", + "src/app/layers/firms.js", + "src/app/layers/flights.js", + "src/app/layers/militaryAwareness.js", + "src/app/layers/militaryFlights.js", + "src/app/layers/militaryInstallations.js", + "src/app/layers/overlayHost.js", + "src/app/layers/radio.js", + "src/app/layers/rocketLaunches.js", + "src/app/layers/satellites.js", + "src/app/layers/submarineCables.js", + "src/app/layers/traffic.js", + "src/app/operations.js", "src/app/scene.js", "src/app/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", "src/app/stateChannel.js", + "src/app/surfaceServices.js", "src/app/tools.js", "src/app/viewer.js", "src/bloom.js", @@ -1120,6 +1142,7 @@ "src/data/issPass.js", "src/data/labelArbiter.js", "src/data/layerState.js", + "src/data/lifecycle.js", "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", @@ -1152,6 +1175,7 @@ "src/data/satellites.js", "src/data/scenePick.js", "src/data/spriteOrder.js", + "src/data/surfaceServices.js", "src/data/telegeographySubmarineCables.js", "src/data/terrainHeights.js", "src/data/tomtomTiles.js", @@ -1413,13 +1437,19 @@ "src/search/http.js", "src/search/placeSearch.js", "src/services/application.js", + "src/services/groundFloor.js", + "src/services/groundSnap.js", + "src/services/meshFloorSampler.js", "src/services/requests.js", + "src/services/terrainHeights.js", "src/sharelink.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", "src/sources/live/standalone.js", "src/sources/live/vessels.js", "src/splitFlap.js", + "src/standalone/catalog.js", + "src/standalone/layerSources.js", "src/styles/anime.js", "src/styles/noir.js", "src/styles/retro.js", @@ -1501,35 +1531,7 @@ "src/voice/realtimeController.js", "src/voice/voiceCost.js", "src/weatherEffectsMath.js", - "src/worldFocus.js", - "src/app/catalog.js", - "src/standalone/catalog.js", - "src/app/layers/militaryInstallations.js", - "src/app/layers/alprCameras.js", - "src/app/layers/traffic.js", - "src/app/layers/submarineCables.js", - "src/app/layers/militaryFlights.js", - "src/app/layers/bikeshare.js", - "src/app/layers/flights.js", - "src/app/layers/radio.js", - "src/app/layers/earthquakes.js", - "src/app/layers/cctv.js", - "src/app/layers/firms.js", - "src/app/layers/satellites.js", - "src/app/layers/militaryAwareness.js", - "src/app/layers/rocketLaunches.js", - "src/app/layers/overlayHost.js", - "src/app/layers/aisLiveVessels.js", - "src/app/constructCatalog.js", - "src/standalone/layerSources.js", - "src/app/surfaceServices.js", - "src/app/operations.js", - "src/data/surfaceServices.js", - "src/annotations/resolver.js", - "src/services/terrainHeights.js", - "src/services/groundFloor.js", - "src/services/meshFloorSampler.js", - "src/services/groundSnap.js" + "src/worldFocus.js" ], "external": [ "@mapbox/vector-tile", @@ -1904,5 +1906,10 @@ "pbf", "mgrs" ] + }, + "layer-lifecycle": { + "exports": ["./data/lifecycle"], + "modules": ["src/data/lifecycle.js"], + "external": [] } } diff --git a/src/app/data.js b/src/app/data.js index 14fd7df68..257b14583 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -1,4 +1,5 @@ -import { DataLayerManager } from '../data/manager.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; +import { LayerPresentation } from './layerPresentation.js'; /** Register the application layer catalog before allowing state restoration. */ export function createApplicationData({ scene: { viewer }, @@ -9,7 +10,7 @@ export function createApplicationData({ defer, }) { // Initialize data layer manager - const dataManager = new DataLayerManager(viewer, { + const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, }); defer(async () => { @@ -19,6 +20,8 @@ export function createApplicationData({ `Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`, ); }); + const presentation = new LayerPresentation(dataManager); + defer(() => presentation.destroy()); onData?.(dataManager); if (!catalog?.layers || !catalog?.metadata) throw new TypeError('An application layer catalog is required'); @@ -46,8 +49,8 @@ export function createApplicationData({ delete window.__gevQaUnregisterLayer; }); } - dataManager.buildTogglePanel(document.getElementById('data-toggles')); + presentation.mount(document.getElementById('data-toggles')); styleManager.attachDataManager(dataManager); - return { dataManager, catalog }; + return { dataManager, catalog, presentation }; } diff --git a/src/app/layerPresentation.js b/src/app/layerPresentation.js new file mode 100644 index 000000000..fd75891cb --- /dev/null +++ b/src/app/layerPresentation.js @@ -0,0 +1,85 @@ +import { LayerPanel } from '../ui/layers.js'; +import { governorRequestRender } from '../renderGovernor.js'; +import { markDetectionSourcesChanged } from '../data/detection.js'; + +/** Own the layer panel and application reactions to lifecycle activity. */ +export class LayerPresentation { + constructor( + manager, + { + requestRender = governorRequestRender, + invalidateDetection = markDetectionSourcesChanged, + } = {}, + ) { + this.manager = manager; + this._panel = null; + this.pendingVisible = false; + this._unsubscribe = manager.subscribeActivity((change) => { + if (change.type === 'status') this.refresh(); + else if (change.type === 'destroy-all') this.destroy(); + else { + const reason = + change.type === 'data-updated' + ? `layer-tick:${change.layerId}` + : change.type === 'visibility-settled' + ? 'layer-visibility' + : change.type === 'params-settled' + ? `layer-params:${change.layerId}` + : null; + if (!reason) return; + requestRender(reason); + if (change.type !== 'params-settled') invalidateDetection(reason); + } + }); + } + get panel() { + if (!this._panel) + this._panel = new LayerPanel({ + getLayers: () => this.manager.getAll(), + isEnabled: (id) => this.manager.isEnabled(id), + setEnabled: (id, enabled, options) => + this.manager.setEnabled(id, enabled, options), + setLayerParams: (id, params, options) => + this.manager.setLayerParams(id, params, options), + getRowControls: (id) => { + const module = this.manager.layers.get(id)?.module; + try { + return module?.getRowControls?.() || null; + } catch (error) { + console.warn(`[Data] ${id} getRowControls error:`, error); + return null; + } + }, + hasRowControls: (id) => + typeof this.manager.layers.get(id)?.module?.getRowControls === + 'function', + subscribeRowControls: (id, listener) => { + const module = this.manager.layers.get(id)?.module; + module?.setRowControlsListener?.(listener); + return () => module?.setRowControlsListener?.(null); + }, + onHiddenRefresh: () => { + this.pendingVisible = true; + }, + }); + return this._panel; + } + mount(container) { + this.panel.mount(container); + } + refresh() { + this._panel?._refreshTogglePanel(); + } + flushVisible() { + if (!this.pendingVisible) return; + this.pendingVisible = false; + this.refresh(); + } + destroy() { + this._panel?.destroy(); + this._panel = null; + this.pendingVisible = false; + this._unsubscribe?.(); + this._unsubscribe = null; + } +} diff --git a/src/app/tools.js b/src/app/tools.js index 497b60720..6384d1805 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -69,10 +69,7 @@ export function createApplicationTools({ viewer.useDefaultRenderLoop = !hidden; cockpitCloudEffects?.setSuspended?.(hidden); if (!hidden) { - if (dataManager._panelRefreshPendingOnVisible) { - dataManager._panelRefreshPendingOnVisible = false; - dataManager._refreshTogglePanel(); - } + data.presentation.flushVisible(); governorRequestRender('visibility-restore'); } }; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index a6db28237..05803fa34 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -31,7 +31,7 @@ const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html') const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const css = readStylesheet(path.join(ROOT, 'style.css')); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); -const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8'); +const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8'); const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); @@ -764,7 +764,7 @@ test('Global Context uses its dedicated right rail without a duplicate Data Laye assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/); const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8'); assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/); - assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/); + assert.match(fs.readFileSync(path.join(ROOT, 'src', 'app', 'layerPresentation.js'), 'utf8'), /getLayers: \(\) => this\.manager\.getAll\(\)/); assert.match(html, /id="global-context-panel"/); assert.match(html, /id="global-context-flights-btn"/); assert.match(html, /id="global-context-missions-btn"/); diff --git a/src/data/detectionRenderDemand.test.mjs b/src/data/detectionRenderDemand.test.mjs index 56a1c7dcf..d6af9d607 100644 --- a/src/data/detectionRenderDemand.test.mjs +++ b/src/data/detectionRenderDemand.test.mjs @@ -359,14 +359,38 @@ test('a layer whose detectable set changed dirties the solve', async () => { assert.match(hook, /if \(_mode === MODE_OFF\) return;/, 'and it stays inert while detection is off'); - const manager = await readFile(new URL('./manager.js', import.meta.url), 'utf8'); - assert.match(manager, /import \{ markDetectionSourcesChanged \} from '\.\/detection\.js';/); - // Both discrete events that can change the detectable set, next to the render - // request each already made. - assert.match(manager, /governorRequestRender\(`layer-tick:\$\{layerId\}`\);[\s\S]{0,700}?markDetectionSourcesChanged\(`layer-tick:\$\{layerId\}`\);/, - 'a poll tick marks the solve dirty alongside its render request'); - assert.match(manager, /governorRequestRender\('layer-visibility'\);[\s\S]{0,400}?markDetectionSourcesChanged\('layer-visibility'\);/, - 'so does a layer appearing or disappearing'); + const { LayerLifecycle } = await import('./lifecycle.js'); + const { LayerPresentation } = await import('../app/layerPresentation.js'); + const manager = new LayerLifecycle({}); + const reactions = []; + const presentation = new LayerPresentation(manager, { + requestRender: (reason) => reactions.push(['render', reason]), + invalidateDetection: (reason) => reactions.push(['detection', reason]), + }); + let result = true; + manager.register({ id: 'fixture', name: 'Fixture', updateInterval: 0, + init() {}, enable() {}, disable() {}, destroy() {}, + update() { if (result instanceof Error) throw result; return result; }, + getStats() { return { count: 1 }; }, + }); + await manager.setEnabled('fixture', true); + assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]); + reactions.length = 0; + await manager.refreshLayer('fixture'); + assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']]); + reactions.length = 0; + result = false; + await manager.refreshLayer('fixture'); + assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']], 'partial/rejected updates still invalidate detection'); + reactions.length = 0; + result = new Error('fixture update failure'); + await manager.refreshLayer('fixture'); + assert.deepEqual(reactions, [], 'throwing updates do not publish changed data'); + result = true; + await manager.setEnabled('fixture', false); + assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]); + presentation.destroy(); + await manager.destroyAll(); }); test('the render-governor gate covers the parked case, with teeth on the painter', async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js new file mode 100644 index 000000000..2f39a1ca6 --- /dev/null +++ b/src/data/lifecycle.js @@ -0,0 +1,2310 @@ +function cloneLayerParams(value) { + if (Array.isArray(value)) return value.map(cloneLayerParams); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key, + cloneLayerParams(nested), + ]), + ); + } + return value; +} + +const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); +const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ + 'enabled-only', + 'enabled+options', + 'enabled+mirrored-options', +]); + +function isAbortError(error) { + return error?.name === 'AbortError'; +} + +function lifecycleRejectedError(layerId, phase) { + const error = new Error( + `[Data] ${layerId} ${phase} rejected the lifecycle transition`, + ); + error.name = 'LifecycleRejectedError'; + return error; +} + +function paramsRejectedError(layerId) { + const error = new Error(`[Data] ${layerId} rejected layer parameters`); + error.name = 'LayerParamsRejectedError'; + return error; +} + +function isExplicitLayerIntentOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; +} + +function cancelPendingLayerRestore(entry, origin, reason) { + if (!isExplicitLayerIntentOrigin(origin)) return; + try { + ( + entry.module?.cancelPendingRestore || + entry.module?.cancelPendingTrackingRestore + )?.({ origin, reason }); + } catch (error) { + console.warn( + `[Data] ${entry.module?.id || 'layer'} pending restore cancellation error:`, + error, + ); + } +} + +function refreshFailureFromStats(stats, label) { + const specific = stats?.error || stats?.lastError; + if (specific) + return specific instanceof Error ? specific : new Error(String(specific)); + if (stats?.unavailable === true || stats?.available === false) { + return new Error(`${label} refresh unavailable`); + } + return null; +} + +/** + * LayerLifecycle — Manages registration, toggling, and update loops + * for real-time data overlays on the CesiumJS globe. + */ +export class LayerLifecycle { + constructor(viewer, { allowQaRegistration = false } = {}) { + this.viewer = viewer; + this._activityListeners = new Set(); + this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } + this._listeners = new Set(); + this._visibilityRequestListeners = new Set(); + this._beforeDestroyListeners = new Set(); + this._visibilityGuards = new Set(); + this._registrationsFinalized = false; + this._registrationDispositions = null; + this._allowQaRegistration = allowQaRegistration === true; + this._qaLayerIds = new Set(); + } + + register(layerModule) { + if (this._registrationsFinalized) { + throw new Error('Data-layer registrations are finalized'); + } + this._registerLayer(layerModule); + } + + /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ + registerForQa(layerModule) { + if (!this._allowQaRegistration || !this._registrationsFinalized) { + throw new Error('QA layer registration is not authorized'); + } + this._registerLayer(layerModule); + this._qaLayerIds.add(layerModule.id); + return layerModule.id; + } + + /** Destroy a layer previously registered through the dev QA seam. */ + async unregisterForQa(layerId) { + if (!this._allowQaRegistration || !this._qaLayerIds.has(layerId)) + return false; + const destroyed = await this.destroyLayer(layerId); + if (destroyed) this._qaLayerIds.delete(layerId); + return destroyed; + } + + _registerLayer(layerModule) { + if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) { + throw new Error('Data layer must provide a stable id'); + } + if (this.layers.has(layerModule.id)) { + throw new Error(`Duplicate data-layer id: ${layerModule.id}`); + } + this.layers.set(layerModule.id, { + module: layerModule, + enabled: false, + initialized: false, + intervalId: null, + // Periodic data refreshes are manager-owned work, independent from the + // authoritative enable/disable lifecycle above. Every registered layer + // receives the same normalized presentation contract even when its own + // getStats() omits loading fields. + refreshing: false, + refreshEpoch: 0, + managerRefreshError: null, + // `enabled` is authoritative settled visibility. Awaited lifecycle work + // is reported separately so callers never mistake activation for ON or + // teardown for OFF before the transaction settles. + lifecycleState: 'disabled', + // A lifecycle rejection can leave the module's real state unknowable. + // Keep the conservative public state, but do not let setEnabled() treat + // that state as settled until the requested lifecycle is reconciled. + lifecycleUncertain: false, + // Absolute setEnabled() calls own a monotonic intent lane separate from + // relative toggle() calls. A newer request can abort lifecycle work that + // is already inside this entry's serialized queue, while the epoch keeps + // older queued requests from starting after they have been superseded. + visibilityIntentEpoch: 0, + visibilityIntentEnabled: false, + visibilityIntentOrigin: 'programmatic', + activeVisibilityIntent: null, + latestQueuedAbsoluteIntent: null, + pendingVisibilityAdoptionEpoch: 0, + // Exact absolute-intent completions are retained in a small bounded map. + // Context transactions use these records to adopt a named successor + // without guessing from mutable lifecycle state during listener re-entry. + visibilityIntentRecords: new Map(), + visibilityIntentFailures: new Map(), + paramsIntentEpoch: 0, + paramsIntentOrigin: 'programmatic', + // Teardown owns the layer from its synchronous entry boundary. New + // visibility work is refused while destroy drains and cleans up earlier + // intents, so no request can publish settled state into a dying entry. + destroying: false, + // Clear All reserves its complete target set synchronously before any + // per-layer teardown begins. A later absolute request supersedes this + // reservation by advancing visibilityIntentEpoch. + clearVisibilityReservation: null, + // Promise chain that serializes toggle() calls for THIS entry. Without it + // a second toggle during the awaited init()/first-update() of the first + // interleaves: the disable branch runs while enable is mid-flight, the + // interval is armed after the user already turned the layer off, and a + // subsequent enable arms a SECOND interval → 2× poll → OpenSky 429 (M1). + toggleChain: Promise.resolve(), + }); + } + + /** Seal registration and prove each production layer has one share disposition. */ + finalizeRegistrations(serializationRegistry) { + if (this._registrationsFinalized) + throw new Error('Data-layer registrations are already finalized'); + if (!Array.isArray(serializationRegistry)) + throw new Error('Layer serialization registry must be an array'); + const dispositions = new Map(); + for (const entry of serializationRegistry) { + if (!entry?.id || !entry?.disposition) + throw new Error('Layer serialization disposition is incomplete'); + if (dispositions.has(entry.id)) + throw new Error( + `Duplicate layer serialization disposition: ${entry.id}`, + ); + if (!VALID_LAYER_SERIALIZATION_DISPOSITIONS.has(entry.disposition)) { + throw new Error(`Invalid layer serialization disposition: ${entry.id}`); + } + dispositions.set(entry.id, entry.disposition); + } + const registeredIds = [...this.layers.keys()]; + const missing = registeredIds.filter((id) => !dispositions.has(id)); + const extra = [...dispositions.keys()].filter((id) => !this.layers.has(id)); + if (missing.length || extra.length) { + throw new Error( + `Layer serialization registry mismatch (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'})`, + ); + } + this._registrationDispositions = dispositions; + this._registrationsFinalized = true; + return true; + } + + get registrationsFinalized() { + return this._registrationsFinalized; + } + + _moduleStats(entry) { + if (!entry?.initialized || typeof entry.module?.getStats !== 'function') { + return { count: 0, lastUpdate: null }; + } + try { + const stats = entry.module.getStats(); + return stats && typeof stats === 'object' + ? stats + : { count: 0, lastUpdate: null }; + } catch (error) { + console.warn(`[Data] ${entry.module.id} getStats error:`, error); + return { count: 0, lastUpdate: null, error }; + } + } + + _normalizedStats(entry) { + const moduleStats = this._moduleStats(entry); + const lifecycleLoading = + entry.lifecycleState === 'enabling' || + entry.lifecycleState === 'disabling'; + return { + count: 0, + lastUpdate: null, + ...moduleStats, + loading: lifecycleLoading || moduleStats.loading === true, + refreshing: entry.refreshing || moduleStats.refreshing === true, + managerRefreshError: entry.managerRefreshError, + }; + } + + _invalidateRefresh(layerId, entry, reason = 'invalidated') { + const wasRefreshing = entry.refreshing; + const refreshEpoch = entry.refreshEpoch; + entry.refreshEpoch += 1; + entry.refreshing = false; + if (wasRefreshing) { + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + type: 'refresh-cancelled', + layerId, + enabled: entry.enabled, + refreshEpoch, + reason, + }); + } + } + + async _runPeriodicUpdate(layerId, entry, { signal = null } = {}) { + if ( + !entry.enabled || + entry.lifecycleState !== 'enabled' || + entry.destroying || + entry.refreshing || + signal?.aborted + ) + return false; + const refreshEpoch = ++entry.refreshEpoch; + entry.refreshing = true; + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + type: 'refresh-transition', + layerId, + enabled: true, + refreshEpoch, + }); + + let result; + let failure = null; + try { + result = await entry.module.update(this.viewer, { signal }); + // Even a rejected/partial update may have changed the data exposed by + // the layer. Publish before classifying its result so observers see it. + this._publishActivity({ type: 'data-updated', layerId }); + if (result === false) + failure = lifecycleRejectedError(layerId, 'refresh'); + if (!failure) + failure = refreshFailureFromStats( + this._moduleStats(entry), + entry.module.name || layerId, + ); + } catch (error) { + failure = error; + } + + if (signal?.aborted) { + if ( + this.layers.get(layerId) === entry && + !entry.destroying && + entry.enabled && + entry.refreshEpoch === refreshEpoch + ) { + entry.refreshing = false; + entry.managerRefreshError = null; + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + type: 'refresh-cancelled', + layerId, + enabled: true, + refreshEpoch, + }); + } + return false; + } + + if ( + this.layers.get(layerId) !== entry || + entry.destroying || + !entry.enabled || + entry.refreshEpoch !== refreshEpoch + ) { + return false; + } + + entry.refreshing = false; + entry.managerRefreshError = failure + ? String(failure.message || failure) + : null; + this._publishActivity({ type: 'status' }); + if (failure) { + console.warn(`[Data] ${layerId} refresh error:`, failure); + this._notifyListeners({ + type: 'refresh-failed', + layerId, + enabled: true, + refreshEpoch, + phase: 'refresh', + error: failure, + }); + return false; + } + this._notifyListeners({ + type: 'refresh', + layerId, + enabled: true, + refreshEpoch, + }); + return result !== false; + } + + /** + * Request one fresh update for an already-enabled layer. If the periodic + * loop currently owns a refresh, wait for it to settle and then run a new + * update so viewport-dependent callers do not reuse work started for the + * prior camera location. + * @param {string} layerId Registered layer id. + * @param {object} [options] Refresh authority. + * @param {AbortSignal|null} [options.signal] Caller cancellation authority. + * @returns {Promise} True only when the requested fresh update settles successfully. + */ + async refreshLayer(layerId, { signal = null } = {}) { + const entry = this.layers.get(layerId); + if ( + !entry || + !entry.enabled || + entry.lifecycleState !== 'enabled' || + entry.destroying || + signal?.aborted + ) + return false; + + if (entry.refreshing) { + const settled = await new Promise((resolve) => { + let done = false; + const finish = (value) => { + if (done) return; + done = true; + unsubscribe(); + signal?.removeEventListener?.('abort', onAbort); + resolve(value); + }; + const onAbort = () => finish(false); + const unsubscribe = this.subscribe((change) => { + if ( + change?.layerId === layerId && + ['refresh', 'refresh-failed', 'refresh-cancelled'].includes( + change.type, + ) + ) { + finish(true); + } + }); + signal?.addEventListener?.('abort', onAbort, { once: true }); + queueMicrotask(() => { + if (!entry.refreshing) finish(true); + }); + }); + if ( + !settled || + signal?.aborted || + this.layers.get(layerId) !== entry || + !entry.enabled || + entry.lifecycleState !== 'enabled' || + entry.destroying + ) + return false; + } + + return this._runPeriodicUpdate(layerId, entry, { signal }); + } + + /** + * Refresh one enabled tracked layer at the destination, then let that layer + * decide whether the requested ID was present in an authoritative snapshot. + * Lifecycle success alone is deliberately insufficient for this decision. + */ + async resolveLayerTrackingTarget( + layerId, + targetId, + { signal = null, origin = 'share-restore' } = {}, + ) { + const entry = this.layers.get(layerId); + const base = { + layerId, + targetId, + origin, + refreshSucceeded: false, + }; + if (!entry || !entry.enabled || entry.destroying) { + return { ...base, status: 'unavailable', reason: 'layer-unavailable' }; + } + if (typeof entry.module?.resolveTrackingRestoreTarget !== 'function') { + return { + ...base, + status: 'unsupported', + reason: 'tracking-restore-unsupported', + }; + } + if (signal?.aborted) { + return { + ...base, + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + } + + const refreshSucceeded = await this.refreshLayer(layerId, { signal }); + if (signal?.aborted) { + return { + ...base, + refreshSucceeded, + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + } + if ( + this.layers.get(layerId) !== entry || + entry.destroying || + !entry.enabled + ) { + return { + ...base, + refreshSucceeded, + status: 'destroyed', + reason: 'layer-destroyed', + }; + } + + try { + const resolution = await entry.module.resolveTrackingRestoreTarget( + targetId, + { + signal, + origin, + refreshSucceeded, + }, + ); + if (signal?.aborted) { + return { + ...base, + refreshSucceeded, + status: 'cancelled', + reason: String(signal.reason || 'aborted'), + }; + } + const status = [ + 'found', + 'missing', + 'source-unavailable', + 'cancelled', + 'superseded', + 'destroyed', + ].includes(resolution?.status) + ? resolution.status + : 'source-unavailable'; + return { ...base, refreshSucceeded, ...resolution, status }; + } catch (error) { + if (signal?.aborted || isAbortError(error)) { + return { + ...base, + refreshSucceeded, + status: 'cancelled', + reason: String(signal?.reason || error?.message || 'aborted'), + errorClass: 'AbortError', + }; + } + return { + ...base, + refreshSucceeded, + status: 'source-unavailable', + reason: String(error?.message || error), + errorClass: error?.name || 'Error', + }; + } + } + + _armUpdateLoop(layerId, entry) { + const configuredRefreshInterval = Number(entry.module.refreshInterval); + const updateInterval = Number(entry.module.updateInterval); + const refreshInterval = + configuredRefreshInterval > 0 + ? configuredRefreshInterval + : updateInterval > 0 + ? updateInterval + : 0; + if (refreshInterval > 0) { + entry.intervalId = setInterval(() => { + void this._runPeriodicUpdate(layerId, entry); + }, refreshInterval); + } else if (updateInterval === 0) { + entry.intervalId = setInterval(() => { + if (!entry.enabled) return; + this._publishActivity({ type: 'status' }); + }, entry.module.statsRefreshInterval || 1000); + } + } + + toggle(layerId, { origin = 'programmatic', notificationToken = null } = {}) { + const entry = this.layers.get(layerId); + if (!entry || entry.destroying) return Promise.resolve(false); + // A relative user action still needs the same revocable authority as an + // absolute request. Invert the effective (latest-intent) state now, then + // let the absolute-intent lane serialize and cancel obsolete lifecycle + // work. This also makes two rapid toggles deterministically mean ON, OFF. + return this._setEnabledWithIntent( + layerId, + !this.isEffectivelyEnabled(layerId), + { + origin, + notificationToken, + notifyWillChangeBeforeEffective: true, + }, + ).promise; + } + + _enqueueToggle(entry, operation) { + const next = entry.toggleChain.catch(() => {}).then(operation); + entry.toggleChain = next; + return next; + } + + _setLifecycleTransition(entry, requestedChange, lifecycleState) { + entry.lifecycleState = lifecycleState; + this._syncModuleLifecyclePresentation(entry); + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + ...requestedChange, + type: 'visibility-transition', + lifecycleState, + settledEnabled: entry.enabled, + }); + } + + _settleLifecycle(entry) { + entry.lifecycleState = entry.enabled ? 'enabled' : 'disabled'; + this._syncModuleLifecyclePresentation(entry); + } + + _syncModuleLifecyclePresentation(entry) { + if (typeof entry.module.setLifecyclePresentation !== 'function') return; + try { + entry.module.setLifecyclePresentation({ + lifecycleState: entry.lifecycleState, + enabled: entry.enabled, + uncertain: entry.lifecycleUncertain, + }); + } catch (error) { + console.warn( + `[Data] ${entry.module.id} lifecycle presentation error:`, + error, + ); + } + } + + _visibilityCancellationMetadata( + entry, + intentEpoch, + signal, + phase, + resourceAbort = false, + ) { + const hasSuccessor = + Number.isInteger(intentEpoch) && + entry.visibilityIntentEpoch > intentEpoch; + const metadata = { + ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), + phase, + cancellationReason: resourceAbort + ? 'resource-abort' + : hasSuccessor || signal?.reason === SUPERSEDED_VISIBILITY_INTENT + ? 'superseded' + : 'caller-abort', + ...(hasSuccessor + ? { + successorIntentEpoch: entry.visibilityIntentEpoch, + successorEnabled: entry.visibilityIntentEnabled, + successorOrigin: entry.visibilityIntentOrigin, + } + : {}), + }; + const record = entry.visibilityIntentRecords.get(intentEpoch); + if (record) Object.assign(record, metadata); + return metadata; + } + + _setVisibilityIntentPhase(entry, intentEpoch, phase) { + if (!Number.isInteger(intentEpoch)) return; + const record = entry.visibilityIntentRecords.get(intentEpoch); + if (record) record.phase = phase; + } + + async _doToggle( + entry, + layerId, + origin, + { + signal = null, + targetEnabled = !entry.enabled, + notificationToken = null, + intentEpoch = null, + suppressWillChangeNotification = false, + beforeEnableParams = null, + } = {}, + ) { + const desiredState = Boolean(targetEnabled); + const recordVisibilityFailure = (phase, error) => { + if (!Number.isInteger(intentEpoch)) return; + entry.visibilityIntentFailures.set(intentEpoch, { + phase, + error: error || lifecycleRejectedError(layerId, phase), + }); + }; + const isSuperseded = () => + intentEpoch !== null && intentEpoch !== entry.visibilityIntentEpoch; + const settleLifecycle = () => { + if (!isSuperseded()) { + entry.pendingVisibilityAdoptionEpoch = 0; + this._settleLifecycle(entry); + // setLifecyclePresentation() is synchronous and may notify a + // subscriber that immediately issues a newer absolute request. Re-read + // the epoch after that callback boundary before treating settlement as + // owned by this transaction. + if (!isSuperseded()) return true; + } + // Cleanup belongs to the obsolete transaction, so retain the actual + // conservative state but keep presentation transitional/hidden. The + // latest queued absolute request will reconcile or adopt it and alone + // publish settled visibility under its own origin. + entry.lifecycleState = entry.visibilityIntentEnabled + ? 'enabling' + : 'disabling'; + entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; + this._syncModuleLifecyclePresentation(entry); + return false; + }; + const requestedChange = { + type: 'visibility-will-change', + layerId, + enabled: desiredState, + origin, + ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), + ...(notificationToken ? { notificationToken } : {}), + }; + if (signal?.aborted) return false; + if (!suppressWillChangeNotification) this._notifyListeners(requestedChange); + const blockReason = await this._visibilityBlockReason(requestedChange); + if (signal?.aborted) { + this._notifyListeners({ + ...requestedChange, + type: 'visibility-cancelled', + ...this._visibilityCancellationMetadata( + entry, + intentEpoch, + signal, + 'guard', + ), + }); + return false; + } + if (blockReason) { + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + ...requestedChange, + type: 'visibility-blocked', + reason: blockReason, + }); + return false; + } + this._setLifecycleTransition( + entry, + requestedChange, + desiredState ? 'enabling' : 'disabling', + ); + if (!desiredState) { + // Disable + this._invalidateRefresh(layerId, entry, 'layer-disabled'); + const finishCancelledDisable = async ( + phase = 'disable', + resourceAbort = false, + ) => { + // The module may already have completed its disable work, so compensate + // inside this serialized manager transaction. Restore ON only after a + // successful enable; otherwise remain truthfully OFF and let the next + // setEnabled(true) perform real lifecycle work. + let compensated = false; + let compensationError = null; + try { + compensated = (await entry.module.enable(this.viewer)) !== false; + if (!compensated) { + compensationError = lifecycleRejectedError( + layerId, + 'cancel-disable-compensation', + ); + } + } catch (error) { + compensationError = error; + console.warn( + `[Data] ${layerId} cancelled-disable cleanup error:`, + error, + ); + } + let cleanupConfirmed = false; + if (!compensated) { + try { + cleanupConfirmed = + (await entry.module.disable(this.viewer)) !== false; + } catch (error) { + console.warn( + `[Data] ${layerId} cancelled-disable final cleanup error:`, + error, + ); + } + } + // A failed enable may have partially activated the module. Only record + // OFF when a subsequent disable positively confirms cleanup; otherwise + // retain ON as the conservative authoritative state. + entry.enabled = compensated || !cleanupConfirmed; + entry.lifecycleUncertain = !compensated && !cleanupConfirmed; + settleLifecycle(); + if (!entry.enabled && entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + this._publishActivity({ type: 'status' }); + if (!compensated) { + recordVisibilityFailure( + 'cancel-disable-compensation', + compensationError || + lifecycleRejectedError(layerId, 'cancel-disable-compensation'), + ); + } + this._notifyListeners({ + ...requestedChange, + type: compensated ? 'visibility-cancelled' : 'visibility-failed', + ...(compensated + ? this._visibilityCancellationMetadata( + entry, + intentEpoch, + signal, + phase, + resourceAbort, + ) + : {}), + ...(compensated + ? {} + : { + phase: 'cancel-disable-compensation', + error: compensationError, + }), + }); + return false; + }; + try { + const disabled = await entry.module.disable(this.viewer, { signal }); + if (disabled === false) + throw lifecycleRejectedError(layerId, 'disable'); + } catch (e) { + if (signal?.aborted || isAbortError(e)) { + return finishCancelledDisable( + 'disable', + isAbortError(e) && !signal?.aborted, + ); + } + // Fail closed: the module may still be polling or rendering, so keep + // the manager's authoritative state enabled and preserve its interval. + entry.enabled = true; + entry.lifecycleUncertain = true; + settleLifecycle(); + console.warn(`[Data] ${layerId} disable error:`, e); + recordVisibilityFailure('disable', e); + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + ...requestedChange, + type: 'visibility-failed', + phase: 'disable', + error: e, + }); + return false; + } + if (signal?.aborted) return finishCancelledDisable('disable'); + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + entry.enabled = false; + entry.lifecycleUncertain = false; + if (!settleLifecycle() || signal?.aborted) + return finishCancelledDisable('settle'); + } else { + // Enable + let abortCleanup = null; + const cancelEnable = () => { + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + // Disable immediately so modules with their own AbortController (Radio) + // cancel pending update work at the same turn boundary. A second + // disable after the current lifecycle await settles closes the race + // where an asynchronous enable finishes after this callback. + try { + abortCleanup = Promise.resolve( + entry.module.disable(this.viewer), + ).catch((error) => { + console.warn( + `[Data] ${layerId} cancelled-enable cleanup error:`, + error, + ); + return false; + }); + } catch (error) { + console.warn( + `[Data] ${layerId} cancelled-enable cleanup error:`, + error, + ); + abortCleanup = Promise.resolve(false); + } + }; + const finishCancelledEnable = async (phase, resourceAbort = false) => { + // A resource-local AbortError settles this transaction without + // aborting the caller's signal. Release that signal's listener now so + // a later abort cannot revoke a successful retry. + signal?.removeEventListener('abort', cancelEnable); + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + await abortCleanup; + let cleanupConfirmed = false; + try { + cleanupConfirmed = + (await entry.module.disable(this.viewer)) !== false; + } catch (error) { + console.warn( + `[Data] ${layerId} cancelled-enable final cleanup error:`, + error, + ); + } + entry.enabled = !cleanupConfirmed; + entry.lifecycleUncertain = !cleanupConfirmed; + settleLifecycle(); + this._publishActivity({ type: 'status' }); + if (!cleanupConfirmed) { + recordVisibilityFailure( + 'cancel-enable-cleanup', + lifecycleRejectedError(layerId, 'cancel-enable-cleanup'), + ); + } + this._notifyListeners({ + ...requestedChange, + type: cleanupConfirmed ? 'visibility-cancelled' : 'visibility-failed', + ...(cleanupConfirmed + ? this._visibilityCancellationMetadata( + entry, + intentEpoch, + signal, + phase, + resourceAbort, + ) + : {}), + ...(cleanupConfirmed ? {} : { phase: 'cancel-enable-cleanup' }), + }); + return false; + }; + const finishFailedEnable = async (phase, error) => { + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + let cleanupConfirmed = false; + try { + cleanupConfirmed = + (await entry.module.disable(this.viewer)) !== false; + } catch (cleanupError) { + console.warn( + `[Data] ${layerId} failed-enable cleanup error:`, + cleanupError, + ); + } + entry.enabled = !cleanupConfirmed; + entry.lifecycleUncertain = !cleanupConfirmed; + settleLifecycle(); + console.warn(`[Data] ${layerId} ${phase} error:`, error); + recordVisibilityFailure(phase, error); + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + ...requestedChange, + type: 'visibility-failed', + phase, + error, + }); + signal?.removeEventListener('abort', cancelEnable); + return false; + }; + signal?.addEventListener('abort', cancelEnable, { once: true }); + if (!entry.initialized) { + this._setVisibilityIntentPhase(entry, intentEpoch, 'init'); + try { + const initialized = await entry.module.init(this.viewer, { signal }); + if (initialized === false) + throw lifecycleRejectedError(layerId, 'init'); + entry.initialized = true; + } catch (e) { + if (signal?.aborted || isAbortError(e)) { + return finishCancelledEnable( + 'init', + isAbortError(e) && !signal?.aborted, + ); + } + return finishFailedEnable('init', e); + } + } + if (signal?.aborted) return finishCancelledEnable('init'); + if (beforeEnableParams) { + this._setVisibilityIntentPhase(entry, intentEpoch, 'params'); + const paramsResult = this._applyLayerParamsIntent( + layerId, + beforeEnableParams.params, + { + origin: beforeEnableParams.origin, + paramsIntentEpoch: beforeEnableParams.paramsIntentEpoch, + }, + ); + beforeEnableParams.result = paramsResult; + if (!paramsResult.succeeded) { + if (signal?.aborted || paramsResult.cancellationReason) { + return finishCancelledEnable('params'); + } + return finishFailedEnable( + 'params', + paramsResult.error || paramsRejectedError(layerId), + ); + } + } + if (signal?.aborted) return finishCancelledEnable('params'); + entry.lifecycleUncertain = false; + this._setVisibilityIntentPhase(entry, intentEpoch, 'enable'); + try { + const enabled = await entry.module.enable(this.viewer, { signal }); + if (enabled === false) throw lifecycleRejectedError(layerId, 'enable'); + } catch (e) { + if (signal?.aborted || isAbortError(e)) { + return finishCancelledEnable( + 'enable', + isAbortError(e) && !signal?.aborted, + ); + } + return finishFailedEnable('enable', e); + } + if (signal?.aborted) return finishCancelledEnable('enable'); + + // First update immediately + this._setVisibilityIntentPhase(entry, intentEpoch, 'update'); + try { + const updated = await entry.module.update(this.viewer, { signal }); + if (updated === false) throw lifecycleRejectedError(layerId, 'update'); + } catch (e) { + if (signal?.aborted || isAbortError(e)) { + return finishCancelledEnable( + 'update', + isAbortError(e) && !signal?.aborted, + ); + } + return finishFailedEnable('update', e); + } + if (signal?.aborted) return finishCancelledEnable('update'); + entry.managerRefreshError = null; + + entry.enabled = true; + entry.lifecycleUncertain = false; + this._setVisibilityIntentPhase(entry, intentEpoch, 'settle'); + if (!settleLifecycle() || signal?.aborted) + return finishCancelledEnable('settle'); + + // Always clear any stale interval before assigning a new one, so we never + // orphan a running timer and end up double-polling. + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + + // Manager-owned periodic refresh work has one normalized loading/error + // contract. Camera-driven layers may keep updateInterval=0 and opt into + // a slower data fetch with refreshInterval. + this._armUpdateLoop(layerId, entry); + signal?.removeEventListener('abort', cancelEnable); + } + + this._publishActivity({ type: 'status' }); + this._publishActivity({ type: 'visibility-settled', layerId }); + + this._notifyListeners({ + type: 'visibility', + layerId, + enabled: entry.enabled, + origin, + ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), + ...(notificationToken ? { notificationToken } : {}), + }); + return true; + } + + /** + * Ensure a layer is in the requested enabled/disabled state. + * Deterministic helper for scripted scene playback. + */ + setEnabled( + layerId, + shouldEnable, + { origin = 'programmatic', signal = null, notificationToken = null } = {}, + ) { + return this._setEnabledWithIntent(layerId, shouldEnable, { + origin, + signal, + notificationToken, + }).promise; + } + + /** + * Internal absolute-visibility request with an exact intent handle. + * The ordinary setEnabled() promise remains the public control contract. + */ + _setEnabledWithIntent( + layerId, + shouldEnable, + { + origin = 'programmatic', + signal = null, + notificationToken = null, + notifyWillChangeBeforeEffective = false, + beforeEnableParams = null, + } = {}, + ) { + const entry = this.layers.get(layerId); + if (!entry) return { intentEpoch: null, promise: Promise.resolve() }; + const desiredState = Boolean(shouldEnable); + if (entry.destroying) { + return { + intentEpoch: null, + promise: Promise.resolve(desiredState === false), + }; + } + cancelPendingLayerRestore(entry, origin, 'explicit-visibility'); + const intentEpoch = ++entry.visibilityIntentEpoch; + entry.visibilityIntentEnabled = desiredState; + entry.visibilityIntentOrigin = origin; + let resolveIntentRecord; + const settled = new Promise((resolve) => { + resolveIntentRecord = resolve; + }); + const intentRecord = { + intentEpoch, + enabled: desiredState, + origin, + phase: 'queued', + settled, + resolve: resolveIntentRecord, + }; + entry.visibilityIntentRecords.set(intentEpoch, intentRecord); + for (const [recordEpoch, record] of entry.visibilityIntentRecords) { + if (entry.visibilityIntentRecords.size <= 16) break; + if (recordEpoch !== intentEpoch && record.completed) + entry.visibilityIntentRecords.delete(recordEpoch); + } + // Relative toggle historically exposes its will-change edge while the + // settled/effective snapshot is still the pre-click state. Preserve that + // Context capture boundary, but reserve the epoch first so a re-entrant + // listener can still supersede this request authoritatively. + if (notifyWillChangeBeforeEffective) { + this._notifyListeners({ + type: 'visibility-will-change', + layerId, + enabled: desiredState, + origin, + intentEpoch, + ...(notificationToken ? { notificationToken } : {}), + }); + } + // Effective visibility must follow the NEWEST absolute intent from the + // synchronous moment it is requested — the superseded transaction's + // cleanup updates lifecycleState later, and Context capture can run in + // between. Cleared by this request's own queue turn when it finishes. + if (entry.visibilityIntentEpoch === intentEpoch) { + entry.latestQueuedAbsoluteIntent = { intentEpoch, enabled: desiredState }; + } + this._notifyVisibilityRequest({ + type: 'visibility-requested', + layerId, + enabled: desiredState, + origin, + intentEpoch, + ...(notificationToken ? { notificationToken } : {}), + }); + // Advance absolute intent before aborting so the obsolete transaction's + // cleanup can defer settlement to this exact latest epoch. Supersede even + // a same-target request: its newer origin may carry explicit user intent + // that must own the eventual persistence-bearing visibility event. + entry.activeVisibilityIntent?.controller.abort( + SUPERSEDED_VISIBILITY_INTENT, + ); + // The idempotency check belongs inside the same per-layer queue as toggle. + // Checking before enqueueing lets two simultaneous setEnabled(true) calls + // both observe OFF and accidentally perform enable-then-disable (M6). + const releaseQueuedIntent = () => { + if (entry.latestQueuedAbsoluteIntent?.intentEpoch === intentEpoch) { + entry.latestQueuedAbsoluteIntent = null; + } + }; + const runIntentTurn = async () => { + const requestedChange = { + type: 'visibility-will-change', + layerId, + enabled: desiredState, + origin, + intentEpoch, + ...(notificationToken ? { notificationToken } : {}), + }; + if (intentEpoch !== entry.visibilityIntentEpoch) { + this._notifyListeners({ + ...requestedChange, + type: 'visibility-cancelled', + ...this._visibilityCancellationMetadata( + entry, + intentEpoch, + null, + 'queued', + ), + }); + return false; + } + if (signal?.aborted) { + if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { + entry.pendingVisibilityAdoptionEpoch = 0; + this._settleLifecycle(entry); + this._publishActivity({ type: 'status' }); + } + // Every accepted absolute request has one authoritative terminal + // outcome. Even when it is aborted before its queue turn and no + // adoption is pending, publish cancellation and populate the exact + // intent record used by waiters. + this._notifyListeners({ + ...requestedChange, + type: 'visibility-cancelled', + ...this._visibilityCancellationMetadata( + entry, + intentEpoch, + signal, + 'queued', + ), + }); + return false; + } + if ( + entry.pendingVisibilityAdoptionEpoch === intentEpoch && + entry.enabled === desiredState && + !entry.lifecycleUncertain + ) { + // Adoption publishes a successful settled visibility, so it must pass + // the same guards a fresh transition would — a guard installed after + // the superseded transaction started (e.g. an exclusive Context mode) + // must be able to veto the adopted state, not just future requests. + const adoptionBlockReason = + await this._visibilityBlockReason(requestedChange); + if (intentEpoch !== entry.visibilityIntentEpoch) { + // The guard is an async boundary. A newer intent can arrive while it + // is pending, so this adoption needs the same exact terminal envelope + // as every other superseded phase. Context follows these successor + // fields instead of guessing from mutable manager state. + if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { + entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; + } + entry.lifecycleState = entry.visibilityIntentEnabled + ? 'enabling' + : 'disabling'; + this._syncModuleLifecyclePresentation(entry); + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + ...requestedChange, + type: 'visibility-cancelled', + ...this._visibilityCancellationMetadata( + entry, + intentEpoch, + null, + 'adoption', + ), + }); + return false; + } + if (signal?.aborted) { + if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { + entry.pendingVisibilityAdoptionEpoch = 0; + this._settleLifecycle(entry); + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + ...requestedChange, + type: 'visibility-cancelled', + ...this._visibilityCancellationMetadata( + entry, + intentEpoch, + signal, + 'adoption', + ), + }); + } + return false; + } + if (adoptionBlockReason) { + entry.pendingVisibilityAdoptionEpoch = 0; + this._publishActivity({ type: 'status' }); + if (entry.enabled === desiredState) { + // The guard forbids the very state the superseded transaction's + // cleanup left behind. Reconcile through the ordinary lifecycle to + // the guard-respecting opposite — but the CALLER's request stays + // unfulfilled either way. Ordering matters: the compensation + // registers as the active intent AND takes over the queued-intent + // record BEFORE the blocked event is announced, so a listener that + // re-enters setEnabled() during the callback has a live intent to + // abort and observes the reconciliation target as effective + // visibility, not the refused request. + const compensationController = new AbortController(); + const compensationIntent = { + intentEpoch, + enabled: !desiredState, + controller: compensationController, + }; + entry.activeVisibilityIntent = compensationIntent; + entry.latestQueuedAbsoluteIntent = { + intentEpoch, + enabled: !desiredState, + }; + this._notifyListeners({ + ...requestedChange, + type: 'visibility-blocked', + reason: adoptionBlockReason, + }); + // The callback may have superseded this turn. The newest intent + // owns reconciliation now — defer exactly like an obsolete + // transaction instead of installing an already-doomed compensation. + if ( + intentEpoch !== entry.visibilityIntentEpoch || + compensationController.signal.aborted + ) { + if (entry.activeVisibilityIntent === compensationIntent) { + entry.activeVisibilityIntent = null; + } + entry.lifecycleState = entry.visibilityIntentEnabled + ? 'enabling' + : 'disabling'; + entry.pendingVisibilityAdoptionEpoch = + entry.visibilityIntentEpoch; + this._syncModuleLifecyclePresentation(entry); + return false; + } + try { + await this._doToggle(entry, layerId, origin, { + signal: compensationController.signal, + targetEnabled: !desiredState, + notificationToken, + intentEpoch, + }); + } finally { + if (entry.activeVisibilityIntent === compensationIntent) { + entry.activeVisibilityIntent = null; + } + } + return false; + } + this._notifyListeners({ + ...requestedChange, + type: 'visibility-blocked', + reason: adoptionBlockReason, + }); + this._settleLifecycle(entry); + return false; + } + entry.pendingVisibilityAdoptionEpoch = 0; + this._settleLifecycle(entry); + if (intentEpoch !== entry.visibilityIntentEpoch) { + entry.lifecycleState = entry.visibilityIntentEnabled + ? 'enabling' + : 'disabling'; + entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; + this._syncModuleLifecyclePresentation(entry); + return false; + } + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + type: 'visibility', + layerId, + enabled: desiredState, + origin, + intentEpoch, + ...(notificationToken ? { notificationToken } : {}), + }); + return true; + } + if (entry.enabled === desiredState && !entry.lifecycleUncertain) { + // Idempotent exit — but an aborted predecessor (e.g. a compensation + // cancelled before its first transition) may have left a stale + // transitional presentation. State and intent agree here, so settle + // the presentation rather than orphaning ENABLING/DISABLING forever. + // Publish the accepted absolute intent even when lifecycle work is a + // no-op: a newer explicit origin can own Context/persistence behavior + // without redundantly re-enabling the module. + if ( + entry.lifecycleState === 'enabling' || + entry.lifecycleState === 'disabling' + ) { + this._settleLifecycle(entry); + } + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + type: 'visibility', + layerId, + enabled: desiredState, + origin, + intentEpoch, + ...(notificationToken ? { notificationToken } : {}), + }); + return true; + } + + entry.pendingVisibilityAdoptionEpoch = 0; + const controller = new AbortController(); + const forwardCallerAbort = () => controller.abort(signal?.reason); + signal?.addEventListener('abort', forwardCallerAbort, { once: true }); + const activeIntent = { intentEpoch, enabled: desiredState, controller }; + entry.activeVisibilityIntent = activeIntent; + try { + return await this._doToggle(entry, layerId, origin, { + signal: controller.signal, + targetEnabled: desiredState, + notificationToken, + intentEpoch, + suppressWillChangeNotification: notifyWillChangeBeforeEffective, + beforeEnableParams, + }); + } finally { + signal?.removeEventListener('abort', forwardCallerAbort); + if (entry.activeVisibilityIntent === activeIntent) { + entry.activeVisibilityIntent = null; + } + } + }; + const promise = this._enqueueToggle(entry, async () => { + try { + return await runIntentTurn(); + } finally { + // This turn no longer owns queued-intent effective visibility — + // either it settled, or a newer epoch superseded it (that epoch's + // own record already replaced this one). + releaseQueuedIntent(); + } + }); + promise.then( + (result) => { + const failure = entry.visibilityIntentFailures.get(intentEpoch) || null; + entry.visibilityIntentFailures.delete(intentEpoch); + intentRecord.completed = true; + intentRecord.result = result; + intentRecord.error = failure?.error || null; + intentRecord.settledEnabled = entry.enabled; + intentRecord.uncertain = entry.lifecycleUncertain; + intentRecord.resolve({ + intentEpoch, + enabled: desiredState, + origin, + phase: failure?.phase || intentRecord.phase, + result, + ...(failure?.error ? { error: failure.error } : {}), + settledEnabled: entry.enabled, + uncertain: entry.lifecycleUncertain, + succeeded: + result !== false && + entry.enabled === desiredState && + !entry.lifecycleUncertain, + cancellationReason: intentRecord.cancellationReason || null, + successorIntentEpoch: intentRecord.successorIntentEpoch ?? null, + successorEnabled: intentRecord.successorEnabled ?? null, + successorOrigin: intentRecord.successorOrigin ?? null, + }); + }, + (error) => { + entry.visibilityIntentFailures.delete(intentEpoch); + intentRecord.completed = true; + intentRecord.error = error; + intentRecord.resolve({ + intentEpoch, + enabled: desiredState, + origin, + phase: intentRecord.phase, + result: false, + error, + settledEnabled: entry.enabled, + uncertain: entry.lifecycleUncertain, + succeeded: false, + cancellationReason: intentRecord.cancellationReason || null, + successorIntentEpoch: intentRecord.successorIntentEpoch ?? null, + successorEnabled: intentRecord.successorEnabled ?? null, + successorOrigin: intentRecord.successorOrigin ?? null, + }); + }, + ); + return { intentEpoch, promise }; + } + + /** Wait for one exact absolute visibility intent to complete. */ + async _waitForVisibilityIntent(layerId, intentEpoch) { + const record = this.layers + .get(layerId) + ?.visibilityIntentRecords?.get(intentEpoch); + return record ? record.settled : null; + } + + /** + * Follow one restore request through any explicit superseding intent chain. + * The newest named successor must reach a terminal state before restore can + * judge the layer; an obsolete caller boolean is never sufficient. + */ + async _waitForAuthoritativeVisibilityIntent(layerId, intentEpoch) { + const entry = this.layers.get(layerId); + if (!entry || !Number.isInteger(intentEpoch)) return null; + let epoch = intentEpoch; + let outcome = null; + while (Number.isInteger(epoch)) { + outcome = await this._waitForVisibilityIntent(layerId, epoch); + if (!outcome) return null; + const newerEpoch = + entry.visibilityIntentEpoch > epoch + ? entry.visibilityIntentEpoch + : outcome.successorIntentEpoch; + if (!Number.isInteger(newerEpoch) || newerEpoch <= epoch) break; + if (outcome.cancellationReason !== 'superseded') return null; + epoch = newerEpoch; + } + return outcome; + } + + /** + * The layer's effective visibility target: settled state, unless awaited + * lifecycle work (or a superseded transaction awaiting adoption) is moving + * it — an ENABLING layer is effectively ON and a DISABLING layer is + * effectively OFF, regardless of which side has settled. + */ + _effectiveEnabled(entry) { + // Newest-intent-wins: an absolute request owns effective visibility from + // the synchronous moment it is made, even while a superseded transaction + // has not yet updated lifecycleState. + if ( + entry.clearVisibilityReservation && + entry.visibilityIntentEpoch === + entry.clearVisibilityReservation.intentEpoch + ) + return false; + if (entry.latestQueuedAbsoluteIntent) + return entry.latestQueuedAbsoluteIntent.enabled; + if (entry.lifecycleState === 'enabling') return true; + if (entry.lifecycleState === 'disabling') return false; + return entry.enabled; + } + + /** + * Whether a layer is effectively enabled, counting in-flight transitions as + * their target state. Context capture/isolation must use THIS (not the + * settled `isEnabled()`) so a layer mid-activation is isolated and + * snapshotted as ON, and a layer honoring a user's in-flight OFF is not + * snapshotted (and later restored) as ON. + * @param {string} layerId Registered layer identifier. + * @returns {boolean} + */ + isEffectivelyEnabled(layerId) { + const entry = this.layers.get(layerId); + return entry ? this._effectiveEnabled(entry) : false; + } + + /** + * Snapshot the exact set of registered layers the user currently intends + * enabled, counting in-flight transitions as their target state. + * @returns {Set} A detached set safe for later restoration. + */ + getEnabledLayerIds() { + return new Set( + [...this.layers] + .filter(([, entry]) => this._effectiveEnabled(entry)) + .map(([layerId]) => layerId), + ); + } + + /** + * Turn off every layer whose latest authoritative intent is currently ON. + * Reverse registration order lets dependents settle their teardown before + * an earlier-registered dependency receives its final OFF intent. Each layer + * keeps its normal latest-intent manager authority, so a newer direct request + * can supersede this batch without being overwritten by a retry loop. + * + * @param {object} [options] Clear transition options. + * @param {string} [options.origin='user'] Visibility-event origin. + * @param {symbol|null} [options.notificationToken] Shared notification owner. + * @returns {Promise<{targetIds:string[],items:object[],clearedIds:string[],notClearedIds:string[]}>} + */ + async clearSelectedLayers({ + origin = 'user', + notificationToken = null, + } = {}) { + const clearBatchId = Symbol('clear-selected-layers'); + const targets = [...this.layers] + .filter(([, entry]) => this._effectiveEnabled(entry)) + .map(([layerId, entry]) => ({ + layerId, + intentEpoch: entry.visibilityIntentEpoch, + })) + .reverse(); + // Reserve the whole batch before the first awaited OFF. This makes Clear + // All's global OFF authority observable synchronously while preserving + // reverse-order lifecycle teardown. Any later absolute intent advances + // the epoch and therefore owns that layer instead. + for (const { layerId, intentEpoch } of targets) { + const entry = this.layers.get(layerId); + if (entry) + entry.clearVisibilityReservation = { clearBatchId, intentEpoch }; + } + const targetIds = targets.map(({ layerId }) => layerId); + const attempts = new Map(); + try { + for (const { layerId, intentEpoch } of targets) { + const entry = this.layers.get(layerId); + const superseded = entry && entry.visibilityIntentEpoch !== intentEpoch; + if (superseded) { + attempts.set(layerId, { superseded: true }); + continue; + } + try { + const clearIntentEpoch = entry.visibilityIntentEpoch + 1; + const result = await this.setEnabled(layerId, false, { + origin, + ...(notificationToken ? { notificationToken } : {}), + }); + attempts.set(layerId, { + result, + superseded: entry.visibilityIntentEpoch !== clearIntentEpoch, + }); + } catch (error) { + attempts.set(layerId, { error }); + } + } + } finally { + for (const { layerId } of targets) { + const entry = this.layers.get(layerId); + if (entry?.clearVisibilityReservation?.clearBatchId === clearBatchId) { + entry.clearVisibilityReservation = null; + } + } + } + const items = targetIds.map((id) => { + const state = this.getLayerLifecycleState(id) || { + enabled: false, + lifecycleState: 'missing', + uncertain: true, + }; + const attempt = attempts.get(id) || {}; + const cleared = + !state.enabled && + state.lifecycleState === 'disabled' && + !state.uncertain; + return { + id, + requested: false, + cleared, + result: attempt.result, + error: attempt.error || null, + superseded: attempt.superseded === true, + ...state, + }; + }); + return { + targetIds, + items, + clearedIds: items.filter((item) => item.cleared).map((item) => item.id), + notClearedIds: items + .filter((item) => !item.cleared) + .map((item) => item.id), + }; + } + + /** + * Restore the exact enabled set captured before a focused mode took over. + * Each transition uses the normal serialized lifecycle and visibility event + * path so subscribers observe the same state changes as direct operations. + * + * @param {Iterable} enabledLayerIds Exact target enabled layer ids. + * @param {object} [options] Restore notification options. + * @param {string} [options.origin='programmatic'] Visibility event origin. + * @param {symbol|null} [options.notificationToken] Opaque caller token used + * to correlate one transition's user-facing failure notification. + * @param {Iterable} [options.excludeLayerIds] Layers owned by an + * in-flight transition that must not enqueue behind themselves. + * @param {AbortSignal|null} [options.signal] Caller cancellation authority. + * @returns {Promise} Resolves after every registered layer settles. + */ + async restoreEnabledLayerIds( + enabledLayerIds, + { + origin = 'programmatic', + excludeLayerIds = [], + notificationToken = null, + signal = null, + } = {}, + ) { + const target = new Set(enabledLayerIds || []); + const excluded = new Set(excludeLayerIds || []); + const layerIds = [...this.layers.keys()].filter((layerId) => { + if (excluded.has(layerId)) return false; + const entry = this.layers.get(layerId); + // Pre-destroy restoration cannot enqueue onto the layer whose teardown + // already owns OFF. That terminal teardown satisfies an OFF snapshot; + // an impossible ON target remains visible to the failure checks below. + return !(entry?.destroying && !target.has(layerId)); + }); + const transitionOptions = { + origin, + ...(notificationToken ? { notificationToken } : {}), + ...(signal ? { signal } : {}), + }; + const handles = layerIds.map((layerId) => + this._setEnabledWithIntent( + layerId, + target.has(layerId), + transitionOptions, + ), + ); + const results = await Promise.allSettled( + handles.map((handle, index) => + this._waitForAuthoritativeVisibilityIntent( + layerIds[index], + handle.intentEpoch, + ), + ), + ); + const failedLayerIds = results.flatMap((result, index) => { + if (result.status === 'rejected') return [layerIds[index]]; + const outcome = result.value; + const layerId = layerIds[index]; + const state = this.getLayerLifecycleState(layerId); + const desiredState = target.has(layerId); + const failed = + !outcome || + outcome.error || + !state || + state.enabled !== desiredState || + state.lifecycleState !== (desiredState ? 'enabled' : 'disabled') || + state.uncertain || + this.layers.get(layerId)?.latestQueuedAbsoluteIntent; + return failed ? [layerId] : []; + }); + if (failedLayerIds.length === 0) return; + const failedIndex = layerIds.indexOf(failedLayerIds[0]); + const failed = results[failedIndex]; + const error = + failed.status === 'rejected' + ? failed.reason + : new Error( + `Failed to restore layer "${layerIds[failedIndex]}" visibility`, + ); + error.failedLayerIds = [ + ...new Set([ + ...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []), + ...failedLayerIds, + ]), + ]; + throw error; + } + + /** + * Wait until lifecycle work already queued for one layer has settled. + * Callers use this outside manager listeners before scheduling reconciliation. + * @param {string} layerId Layer identifier. + * @returns {Promise} Resolves after the captured queue settles. + */ + async waitForLayerSettled(layerId) { + const entry = this.layers.get(layerId); + if (!entry) return; + await entry.toggleChain.catch(() => {}); + } + + _reserveLayerParamsIntent(layerId, params, origin = 'programmatic') { + const entry = this.layers.get(layerId); + if ( + !entry || + entry.destroying || + typeof entry.module?.setParams !== 'function' + ) + return null; + cancelPendingLayerRestore(entry, origin, 'explicit-params'); + const paramsIntentEpoch = ++entry.paramsIntentEpoch; + entry.paramsIntentOrigin = origin; + this._notifyListeners({ + type: 'params-requested', + layerId, + params: cloneLayerParams(params || {}), + origin, + paramsIntentEpoch, + }); + return paramsIntentEpoch; + } + + _applyLayerParamsIntent( + layerId, + params, + { origin = 'programmatic', paramsIntentEpoch = null } = {}, + ) { + const entry = this.layers.get(layerId); + if ( + !entry || + !entry.module || + typeof entry.module.setParams !== 'function' + ) { + return { + succeeded: false, + error: paramsRejectedError(layerId), + params: null, + }; + } + if ( + !Number.isInteger(paramsIntentEpoch) || + entry.paramsIntentEpoch !== paramsIntentEpoch + ) { + const result = { + succeeded: false, + cancellationReason: 'superseded', + params: null, + }; + this._notifyListeners({ + type: 'params-cancelled', + layerId, + origin, + paramsIntentEpoch, + cancellationReason: result.cancellationReason, + successorParamsIntentEpoch: entry.paramsIntentEpoch, + successorOrigin: entry.paramsIntentOrigin, + }); + return result; + } + try { + const accepted = entry.module.setParams(params || {}, { + origin, + paramsIntentEpoch, + }); + if (accepted === false) throw paramsRejectedError(layerId); + if (entry.paramsIntentEpoch !== paramsIntentEpoch) { + const result = { + succeeded: false, + cancellationReason: 'superseded', + params: null, + }; + this._notifyListeners({ + type: 'params-cancelled', + layerId, + origin, + paramsIntentEpoch, + cancellationReason: result.cancellationReason, + successorParamsIntentEpoch: entry.paramsIntentEpoch, + successorOrigin: entry.paramsIntentOrigin, + }); + return result; + } + const appliedParams = + this.getLayerParams(layerId) || cloneLayerParams(params || {}); + this._publishActivity({ type: 'status' }); + this._publishActivity({ type: 'params-settled', layerId }); + this._notifyListeners({ + type: 'params', + layerId, + params: appliedParams, + requestedParams: cloneLayerParams(params || {}), + origin, + paramsIntentEpoch, + }); + return { succeeded: true, params: appliedParams, paramsIntentEpoch }; + } catch (error) { + console.warn(`[Data] ${layerId} setParams error:`, error); + this._notifyListeners({ + type: 'params-failed', + layerId, + params: cloneLayerParams(params || {}), + origin, + paramsIntentEpoch, + error, + }); + return { succeeded: false, error, params: null, paramsIntentEpoch }; + } + } + + /** Apply runtime parameters through an origin-bearing intent lane. */ + setLayerParams(layerId, params, { origin = 'programmatic' } = {}) { + const paramsIntentEpoch = this._reserveLayerParamsIntent( + layerId, + params, + origin, + ); + if (!Number.isInteger(paramsIntentEpoch)) return false; + return this._applyLayerParamsIntent(layerId, params, { + origin, + paramsIntentEpoch, + }).succeeded; + } + + /** Cancel a module-owned pending restore without creating a parameter intent. */ + cancelPendingLayerRestore( + layerId, + { origin = 'programmatic', reason = 'cancelled' } = {}, + ) { + const entry = this.layers.get(layerId); + const cancel = + entry?.module?.cancelPendingRestore || + entry?.module?.cancelPendingTrackingRestore; + if (typeof cancel !== 'function') return false; + try { + cancel.call(entry.module, { origin, reason }); + return true; + } catch (error) { + console.warn( + `[Data] ${layerId} pending restore cancellation error:`, + error, + ); + return false; + } + } + + /** Publish parameters already applied by a layer's direct interaction. */ + adoptLayerParams(layerId, params, { origin = 'programmatic' } = {}) { + const requestedParams = cloneLayerParams(params || {}); + const paramsIntentEpoch = this._reserveLayerParamsIntent( + layerId, + requestedParams, + origin, + ); + if (!Number.isInteger(paramsIntentEpoch)) return false; + const appliedParams = this.getLayerParams(layerId); + const matches = + appliedParams && + Object.entries(requestedParams).every(([key, value]) => + Object.is(appliedParams[key], value), + ); + if (!matches) { + const error = paramsRejectedError(layerId); + this._notifyListeners({ + type: 'params-failed', + layerId, + params: requestedParams, + origin, + paramsIntentEpoch, + error, + }); + return false; + } + this._publishActivity({ type: 'status' }); + this._publishActivity({ type: 'params-settled', layerId }); + this._notifyListeners({ + type: 'params', + layerId, + params: appliedParams, + requestedParams, + origin, + paramsIntentEpoch, + }); + return true; + } + + /** + * Publish an explicit owner adoption of an already-settled layer visibility. + * This is used when a direct selection promotes a Context-owned dependency + * into durable user state without redundantly re-running its lifecycle. + */ + adoptLayerVisibility( + layerId, + enabled, + { origin = 'programmatic', adoptedFromSelection = false } = {}, + ) { + const entry = this.layers.get(layerId); + const desiredState = Boolean(enabled); + if (!entry || entry.enabled !== desiredState || entry.lifecycleUncertain) + return false; + cancelPendingLayerRestore( + entry, + origin, + 'superseded-by-explicit-visibility-adoption', + ); + this._publishActivity({ type: 'status' }); + this._notifyListeners({ + type: 'visibility', + layerId, + enabled: desiredState, + origin, + intentEpoch: entry.visibilityIntentEpoch, + adopted: true, + adoptedFromSelection: Boolean(adoptedFromSelection), + }); + return true; + } + + /** + * Restore one finalized-registry layer independently. Parameters apply after + * init and before enable, with a terminal envelope that never writes local + * persistence. + */ + async restoreLayerState( + layerId, + { enabled = false, params = null } = {}, + { origin = 'programmatic', signal = null } = {}, + ) { + if (!this._registrationsFinalized) + throw new Error('Layer restore requires finalized registrations'); + const entry = this.layers.get(layerId); + const targetEnabled = Boolean(enabled); + if (!entry) { + return { + layerId, + targetEnabled, + origin, + phase: 'missing', + settledEnabled: false, + lifecycleState: 'missing', + lifecycleUncertain: false, + appliedOptions: {}, + errorClass: 'UnknownLayer', + persistenceWrite: false, + succeeded: false, + }; + } + + const requestedParams = + params && typeof params === 'object' && Object.keys(params).length + ? cloneLayerParams(params) + : null; + let paramsEnvelope = null; + let beforeEnableParams = null; + if (requestedParams) { + const paramsIntentEpoch = this._reserveLayerParamsIntent( + layerId, + requestedParams, + origin, + ); + if (Number.isInteger(paramsIntentEpoch)) { + if (targetEnabled && !entry.enabled) { + beforeEnableParams = { + params: requestedParams, + origin, + paramsIntentEpoch, + result: null, + }; + } else { + paramsEnvelope = this._applyLayerParamsIntent( + layerId, + requestedParams, + { + origin, + paramsIntentEpoch, + }, + ); + } + } else { + paramsEnvelope = { + succeeded: false, + error: paramsRejectedError(layerId), + }; + } + } + + const handle = this._setEnabledWithIntent(layerId, targetEnabled, { + origin, + signal, + beforeEnableParams, + }); + const requestedVisibility = await this._waitForVisibilityIntent( + layerId, + handle.intentEpoch, + ); + const authoritativeVisibility = + requestedVisibility?.cancellationReason === 'superseded' + ? await this._waitForAuthoritativeVisibilityIntent( + layerId, + handle.intentEpoch, + ) + : requestedVisibility; + paramsEnvelope = paramsEnvelope || beforeEnableParams?.result; + const state = this.getLayerLifecycleState(layerId); + const paramsSucceeded = + !requestedParams || paramsEnvelope?.succeeded === true; + const visibilitySucceeded = + authoritativeVisibility?.succeeded === true && + state?.enabled === targetEnabled && + state?.uncertain === false; + const error = + paramsEnvelope?.error || + authoritativeVisibility?.error || + requestedVisibility?.error || + null; + return { + layerId, + targetEnabled, + origin, + phase: + requestedVisibility?.phase || + (signal?.aborted ? 'reserved' : 'unknown'), + completionPhase: authoritativeVisibility?.phase || null, + settledEnabled: Boolean(state?.enabled), + lifecycleState: state?.lifecycleState || 'missing', + lifecycleUncertain: Boolean(state?.uncertain), + appliedOptions: paramsSucceeded && requestedParams ? requestedParams : {}, + intentEpoch: handle.intentEpoch, + authoritativeIntentEpoch: + authoritativeVisibility?.intentEpoch ?? handle.intentEpoch, + authoritativeEnabled: authoritativeVisibility?.enabled ?? null, + authoritativeOrigin: authoritativeVisibility?.origin ?? null, + paramsIntentEpoch: + paramsEnvelope?.paramsIntentEpoch ?? + beforeEnableParams?.paramsIntentEpoch ?? + null, + cancellationReason: + requestedVisibility?.cancellationReason || + paramsEnvelope?.cancellationReason || + null, + successorIntentEpoch: requestedVisibility?.successorIntentEpoch ?? null, + successorEnabled: requestedVisibility?.successorEnabled ?? null, + successorOrigin: requestedVisibility?.successorOrigin ?? null, + errorClass: error?.name || (signal?.aborted ? 'AbortError' : null), + ...(error ? { error: String(error.message || error) } : {}), + persistenceWrite: false, + succeeded: paramsSucceeded && visibilitySucceeded, + }; + } + + /** + * Read runtime parameters from a layer if it exposes `getParams()`. + */ + getLayerParams(layerId) { + const entry = this.layers.get(layerId); + if (!entry || !entry.module || typeof entry.module.getParams !== 'function') + return null; + try { + const params = entry.module.getParams(); + if (!params || typeof params !== 'object') return null; + return cloneLayerParams(params); + } catch (error) { + console.warn(`[Data] ${layerId} getParams error:`, error); + return null; + } + } + + /** + * Destroy a single layer — calls its destroy() method if it has one, + * then removes it from the manager. This is the proper cleanup path + * that was previously missing. + */ + async destroyLayer(layerId) { + const entry = this.layers.get(layerId); + if (!entry || entry.destroying) return false; + entry.destroying = true; + this._invalidateRefresh(layerId, entry, 'layer-destroyed'); + // Teardown becomes authoritative before the first await. Advancing the + // intent epoch and aborting active work prevents a pending enable/update + // from publishing a settled ON while the layer is being destroyed. The + // synthetic successor metadata also lets Context restore rather than adopt. + const teardownIntentEpoch = ++entry.visibilityIntentEpoch; + entry.visibilityIntentEnabled = false; + entry.visibilityIntentOrigin = 'teardown'; + entry.latestQueuedAbsoluteIntent = { + intentEpoch: teardownIntentEpoch, + enabled: false, + }; + entry.clearVisibilityReservation = null; + entry.activeVisibilityIntent?.controller.abort( + SUPERSEDED_VISIBILITY_INTENT, + ); + entry.paramsIntentEpoch += 1; + entry.paramsIntentOrigin = 'teardown'; + await entry.toggleChain.catch(() => {}); + // Let focused UI modes restore their exact pre-session state before any + // dependency is irreversibly removed. The intent revocation above makes + // this callback safe to run before the old queue drains. + for (const callback of this._beforeDestroyListeners) { + try { + await callback({ type: 'before-destroy', layerId }); + } catch (error) { + console.warn('[Data] before-destroy listener error:', error); + } + } + await entry.toggleChain.catch(() => {}); + if (this.layers.get(layerId) !== entry) return false; + entry.latestQueuedAbsoluteIntent = null; + if (entry.enabled) { + try { + const disabled = await entry.module.disable(this.viewer); + if (disabled === false) + throw lifecycleRejectedError(layerId, 'destroy-disable'); + } catch (e) { + console.warn(`[Data] ${layerId} disable error:`, e); + entry.destroying = false; + entry.visibilityIntentEnabled = entry.enabled; + this._settleLifecycle(entry); + this._publishActivity({ type: 'status' }); + return false; + } + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + entry.enabled = false; + } + if (typeof entry.module.destroy === 'function') { + try { + const destroyed = await entry.module.destroy(this.viewer); + if (destroyed === false) + throw lifecycleRejectedError(layerId, 'destroy'); + } catch (e) { + console.warn(`[Data] ${layerId} destroy error:`, e); + entry.destroying = false; + entry.visibilityIntentEnabled = entry.enabled; + this._settleLifecycle(entry); + this._publishActivity({ type: 'status' }); + return false; + } + } + this.layers.delete(layerId); + this._qaLayerIds.delete(layerId); + return true; + } + + /** + * Destroy all layers and clear the manager. + * Should be called when the viewer is being torn down. + */ + async destroyAll() { + this._publishActivity({ type: 'destroy-all' }); + for (const layerId of [...this.layers.keys()]) { + await this.destroyLayer(layerId); + } + } + + isEnabled(layerId) { + const entry = this.layers.get(layerId); + return entry ? entry.enabled : false; + } + + /** Return authoritative settled visibility and the current lifecycle phase. */ + getLayerLifecycleState(layerId) { + const entry = this.layers.get(layerId); + if (!entry) return null; + return Object.freeze({ + enabled: entry.enabled, + lifecycleState: entry.lifecycleState, + uncertain: entry.lifecycleUncertain, + }); + } + + getAll() { + const result = []; + for (const [id, entry] of this.layers) { + result.push({ + id, + name: entry.module.name, + icon: entry.module.icon, + source: entry.module.source, + showInTogglePanel: entry.module.showInTogglePanel !== false, + enabled: entry.enabled, + lifecycleState: entry.lifecycleState, + lifecycleUncertain: entry.lifecycleUncertain, + stats: this._normalizedStats(entry), + }); + } + return result; + } + + subscribe(callback) { + if (typeof callback !== 'function') return () => {}; + this._listeners.add(callback); + return () => this._listeners.delete(callback); + } + + /** + * Subscribe to explicit setEnabled intent before it joins the lifecycle queue. + * This lets a newer direct-user OFF request cancel older work before that work + * can publish an intermediate settled visibility state. + * @param {(change:{type:string,layerId:string,enabled:boolean,origin:string}) => void} callback + * @returns {() => void} Unsubscribe function. + */ + subscribeVisibilityRequests(callback) { + if (typeof callback !== 'function') return () => {}; + this._visibilityRequestListeners.add(callback); + return () => this._visibilityRequestListeners.delete(callback); + } + + /** + * Register a visibility guard evaluated before layer lifecycle work. Guards + * may await bounded mode preparation and return a string or `{reason}` to + * refuse a transition. + * @param {(change:{type:string,layerId:string,enabled:boolean,origin:string}) => (string|object|null|void)} callback Guard. + * @returns {() => void} Unsubscribe function. + */ + addVisibilityGuard(callback) { + if (typeof callback !== 'function') return () => {}; + this._visibilityGuards.add(callback); + return () => this._visibilityGuards.delete(callback); + } + + /** + * Subscribe to the awaited pre-destroy lifecycle boundary. + * @param {(change:{type:string,layerId:string}) => (void|Promise)} callback + * @returns {() => void} Unsubscribe function. + */ + subscribeBeforeDestroy(callback) { + if (typeof callback !== 'function') return () => {}; + this._beforeDestroyListeners.add(callback); + return () => this._beforeDestroyListeners.delete(callback); + } + + _notifyListeners(change) { + for (const callback of this._listeners) { + try { + callback(change); + } catch (error) { + console.warn('[Data] listener error:', error); + } + } + } + + _notifyVisibilityRequest(change) { + for (const callback of this._visibilityRequestListeners) { + try { + callback(change); + } catch (error) { + console.warn('[Data] visibility request listener error:', error); + } + } + } + + async _visibilityBlockReason(change) { + for (const callback of this._visibilityGuards) { + try { + const result = await callback(change); + if (typeof result === 'string' && result.trim()) return result.trim(); + if ( + result && + typeof result.reason === 'string' && + result.reason.trim() + ) { + return result.reason.trim(); + } + } catch (error) { + console.warn('[Data] visibility guard error:', error); + } + } + return null; + } + + /** Observe lifecycle activity without owning presentation or rendering. */ + subscribeActivity(callback) { + if (typeof callback !== 'function') return () => {}; + this._activityListeners.add(callback); + return () => this._activityListeners.delete(callback); + } + + _publishActivity(change) { + for (const callback of this._activityListeners) { + try { + callback(change); + } catch (error) { + console.warn('[Data] activity listener error:', error); + } + } + } +} diff --git a/src/data/manager.js b/src/data/manager.js index 682f6115b..a1ae9d226 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -1,2002 +1,32 @@ -import { governorRequestRender } from '../renderGovernor.js'; -import { LayerPanel } from '../ui/layers.js'; +import { LayerLifecycle } from './lifecycle.js'; +import { LayerPresentation } from '../app/layerPresentation.js'; export { layerFeedState } from '../ui/layers.js'; -import { markDetectionSourcesChanged } from './detection.js'; -function cloneLayerParams(value) { - if (Array.isArray(value)) return value.map(cloneLayerParams); - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, nested]) => [key, cloneLayerParams(nested)]), - ); + +/** Compatibility facade for callers that construct a manager with its panel. */ +export class DataLayerManager extends LayerLifecycle { + constructor(viewer, options) { + super(viewer, options); + this._presentation = new LayerPresentation(this); } - return value; -} - -const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); -const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ - 'enabled-only', - 'enabled+options', - 'enabled+mirrored-options', -]); - -function isAbortError(error) { - return error?.name === 'AbortError'; -} - -function lifecycleRejectedError(layerId, phase) { - const error = new Error(`[Data] ${layerId} ${phase} rejected the lifecycle transition`); - error.name = 'LifecycleRejectedError'; - return error; -} - -function paramsRejectedError(layerId) { - const error = new Error(`[Data] ${layerId} rejected layer parameters`); - error.name = 'LayerParamsRejectedError'; - return error; -} - -function isExplicitLayerIntentOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; -} - -function cancelPendingLayerRestore(entry, origin, reason) { - if (!isExplicitLayerIntentOrigin(origin)) return; - try { - (entry.module?.cancelPendingRestore || entry.module?.cancelPendingTrackingRestore)?.({ origin, reason }); - } catch (error) { - console.warn(`[Data] ${entry.module?.id || 'layer'} pending restore cancellation error:`, error); - } -} - -function refreshFailureFromStats(stats, label) { - const specific = stats?.error || stats?.lastError; - if (specific) return specific instanceof Error ? specific : new Error(String(specific)); - if (stats?.unavailable === true || stats?.available === false) { - return new Error(`${label} refresh unavailable`); - } - return null; -} - -/** - * DataLayerManager — Manages registration, toggling, and update loops - * for real-time data overlays on the CesiumJS globe. - */ -export class DataLayerManager { - constructor(viewer, { allowQaRegistration = false } = {}) { - this.viewer = viewer; - this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } - this._listeners = new Set(); - this._visibilityRequestListeners = new Set(); - this._beforeDestroyListeners = new Set(); - this._visibilityGuards = new Set(); - this._registrationsFinalized = false; - this._registrationDispositions = null; - this._allowQaRegistration = allowQaRegistration === true; - this._qaLayerIds = new Set(); - } - - register(layerModule) { - if (this._registrationsFinalized) { - throw new Error('Data-layer registrations are finalized'); - } - this._registerLayer(layerModule); - } - - /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ - registerForQa(layerModule) { - if (!this._allowQaRegistration || !this._registrationsFinalized) { - throw new Error('QA layer registration is not authorized'); - } - this._registerLayer(layerModule); - this._qaLayerIds.add(layerModule.id); - return layerModule.id; - } - - /** Destroy a layer previously registered through the dev QA seam. */ - async unregisterForQa(layerId) { - if (!this._allowQaRegistration || !this._qaLayerIds.has(layerId)) return false; - const destroyed = await this.destroyLayer(layerId); - if (destroyed) this._qaLayerIds.delete(layerId); - return destroyed; - } - - _registerLayer(layerModule) { - if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) { - throw new Error('Data layer must provide a stable id'); - } - if (this.layers.has(layerModule.id)) { - throw new Error(`Duplicate data-layer id: ${layerModule.id}`); - } - this.layers.set(layerModule.id, { - module: layerModule, - enabled: false, - initialized: false, - intervalId: null, - // Periodic data refreshes are manager-owned work, independent from the - // authoritative enable/disable lifecycle above. Every registered layer - // receives the same normalized presentation contract even when its own - // getStats() omits loading fields. - refreshing: false, - refreshEpoch: 0, - managerRefreshError: null, - // `enabled` is authoritative settled visibility. Awaited lifecycle work - // is reported separately so callers never mistake activation for ON or - // teardown for OFF before the transaction settles. - lifecycleState: 'disabled', - // A lifecycle rejection can leave the module's real state unknowable. - // Keep the conservative public state, but do not let setEnabled() treat - // that state as settled until the requested lifecycle is reconciled. - lifecycleUncertain: false, - // Absolute setEnabled() calls own a monotonic intent lane separate from - // relative toggle() calls. A newer request can abort lifecycle work that - // is already inside this entry's serialized queue, while the epoch keeps - // older queued requests from starting after they have been superseded. - visibilityIntentEpoch: 0, - visibilityIntentEnabled: false, - visibilityIntentOrigin: 'programmatic', - activeVisibilityIntent: null, - latestQueuedAbsoluteIntent: null, - pendingVisibilityAdoptionEpoch: 0, - // Exact absolute-intent completions are retained in a small bounded map. - // Context transactions use these records to adopt a named successor - // without guessing from mutable lifecycle state during listener re-entry. - visibilityIntentRecords: new Map(), - visibilityIntentFailures: new Map(), - paramsIntentEpoch: 0, - paramsIntentOrigin: 'programmatic', - // Teardown owns the layer from its synchronous entry boundary. New - // visibility work is refused while destroy drains and cleans up earlier - // intents, so no request can publish settled state into a dying entry. - destroying: false, - // Clear All reserves its complete target set synchronously before any - // per-layer teardown begins. A later absolute request supersedes this - // reservation by advancing visibilityIntentEpoch. - clearVisibilityReservation: null, - // Promise chain that serializes toggle() calls for THIS entry. Without it - // a second toggle during the awaited init()/first-update() of the first - // interleaves: the disable branch runs while enable is mid-flight, the - // interval is armed after the user already turned the layer off, and a - // subsequent enable arms a SECOND interval → 2× poll → OpenSky 429 (M1). - toggleChain: Promise.resolve(), - }); - } - - /** Seal registration and prove each production layer has one share disposition. */ - finalizeRegistrations(serializationRegistry) { - if (this._registrationsFinalized) throw new Error('Data-layer registrations are already finalized'); - if (!Array.isArray(serializationRegistry)) throw new Error('Layer serialization registry must be an array'); - const dispositions = new Map(); - for (const entry of serializationRegistry) { - if (!entry?.id || !entry?.disposition) throw new Error('Layer serialization disposition is incomplete'); - if (dispositions.has(entry.id)) throw new Error(`Duplicate layer serialization disposition: ${entry.id}`); - if (!VALID_LAYER_SERIALIZATION_DISPOSITIONS.has(entry.disposition)) { - throw new Error(`Invalid layer serialization disposition: ${entry.id}`); - } - dispositions.set(entry.id, entry.disposition); - } - const registeredIds = [...this.layers.keys()]; - const missing = registeredIds.filter((id) => !dispositions.has(id)); - const extra = [...dispositions.keys()].filter((id) => !this.layers.has(id)); - if (missing.length || extra.length) { - throw new Error(`Layer serialization registry mismatch (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'})`); - } - this._registrationDispositions = dispositions; - this._registrationsFinalized = true; - return true; - } - - get registrationsFinalized() { - return this._registrationsFinalized; - } - - _moduleStats(entry) { - if (!entry?.initialized || typeof entry.module?.getStats !== 'function') { - return { count: 0, lastUpdate: null }; - } - try { - const stats = entry.module.getStats(); - return stats && typeof stats === 'object' ? stats : { count: 0, lastUpdate: null }; - } catch (error) { - console.warn(`[Data] ${entry.module.id} getStats error:`, error); - return { count: 0, lastUpdate: null, error }; - } - } - - _normalizedStats(entry) { - const moduleStats = this._moduleStats(entry); - const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; - return { - count: 0, - lastUpdate: null, - ...moduleStats, - loading: lifecycleLoading || moduleStats.loading === true, - refreshing: entry.refreshing || moduleStats.refreshing === true, - managerRefreshError: entry.managerRefreshError, - }; - } - - _invalidateRefresh(layerId, entry, reason = 'invalidated') { - const wasRefreshing = entry.refreshing; - const refreshEpoch = entry.refreshEpoch; - entry.refreshEpoch += 1; - entry.refreshing = false; - if (wasRefreshing) { - this._refreshTogglePanel(); - this._notifyListeners({ - type: 'refresh-cancelled', - layerId, - enabled: entry.enabled, - refreshEpoch, - reason, - }); - } - } - - async _runPeriodicUpdate(layerId, entry, { signal = null } = {}) { - if ( - !entry.enabled - || entry.lifecycleState !== 'enabled' - || entry.destroying - || entry.refreshing - || signal?.aborted - ) return false; - const refreshEpoch = ++entry.refreshEpoch; - entry.refreshing = true; - this._refreshTogglePanel(); - this._notifyListeners({ - type: 'refresh-transition', - layerId, - enabled: true, - refreshEpoch, - }); - - let result; - let failure = null; - try { - result = await entry.module.update(this.viewer, { signal }); - // Poll-tick entity refreshes don't auto-render in idle mode. Fires on - // any non-throwing update — a rejected/partial refresh may still have - // mutated scene state. (perf wave 2; moved into _runPeriodicUpdate - // when main normalized the update loop behind _armUpdateLoop) - governorRequestRender(`layer-tick:${layerId}`); - // A poll tick can REPLACE what a layer exposes as detectable. Detection - // pulls that set per paint but re-solves on a private throttle, so the one - // frame requested above could be spent on a paint that declines to - // re-solve — leaving the previous contact labelled and the new one not, - // with nothing left to ask for another frame. (perf wave 2 follow-up) - markDetectionSourcesChanged(`layer-tick:${layerId}`); - if (result === false) failure = lifecycleRejectedError(layerId, 'refresh'); - if (!failure) failure = refreshFailureFromStats(this._moduleStats(entry), entry.module.name || layerId); - } catch (error) { - failure = error; - } - - if (signal?.aborted) { - if ( - this.layers.get(layerId) === entry - && !entry.destroying - && entry.enabled - && entry.refreshEpoch === refreshEpoch - ) { - entry.refreshing = false; - entry.managerRefreshError = null; - this._refreshTogglePanel(); - this._notifyListeners({ - type: 'refresh-cancelled', - layerId, - enabled: true, - refreshEpoch, - }); - } - return false; - } - - if ( - this.layers.get(layerId) !== entry - || entry.destroying - || !entry.enabled - || entry.refreshEpoch !== refreshEpoch - ) { - return false; - } - - entry.refreshing = false; - entry.managerRefreshError = failure ? String(failure.message || failure) : null; - this._refreshTogglePanel(); - if (failure) { - console.warn(`[Data] ${layerId} refresh error:`, failure); - this._notifyListeners({ - type: 'refresh-failed', - layerId, - enabled: true, - refreshEpoch, - phase: 'refresh', - error: failure, - }); - return false; - } - this._notifyListeners({ - type: 'refresh', - layerId, - enabled: true, - refreshEpoch, - }); - return result !== false; - } - - /** - * Request one fresh update for an already-enabled layer. If the periodic - * loop currently owns a refresh, wait for it to settle and then run a new - * update so viewport-dependent callers do not reuse work started for the - * prior camera location. - * @param {string} layerId Registered layer id. - * @param {object} [options] Refresh authority. - * @param {AbortSignal|null} [options.signal] Caller cancellation authority. - * @returns {Promise} True only when the requested fresh update settles successfully. - */ - async refreshLayer(layerId, { signal = null } = {}) { - const entry = this.layers.get(layerId); - if ( - !entry - || !entry.enabled - || entry.lifecycleState !== 'enabled' - || entry.destroying - || signal?.aborted - ) return false; - - if (entry.refreshing) { - const settled = await new Promise((resolve) => { - let done = false; - const finish = (value) => { - if (done) return; - done = true; - unsubscribe(); - signal?.removeEventListener?.('abort', onAbort); - resolve(value); - }; - const onAbort = () => finish(false); - const unsubscribe = this.subscribe((change) => { - if ( - change?.layerId === layerId - && ['refresh', 'refresh-failed', 'refresh-cancelled'].includes(change.type) - ) { - finish(true); - } - }); - signal?.addEventListener?.('abort', onAbort, { once: true }); - queueMicrotask(() => { - if (!entry.refreshing) finish(true); - }); - }); - if ( - !settled - || signal?.aborted - || this.layers.get(layerId) !== entry - || !entry.enabled - || entry.lifecycleState !== 'enabled' - || entry.destroying - ) return false; - } - - return this._runPeriodicUpdate(layerId, entry, { signal }); - } - - /** - * Refresh one enabled tracked layer at the destination, then let that layer - * decide whether the requested ID was present in an authoritative snapshot. - * Lifecycle success alone is deliberately insufficient for this decision. - */ - async resolveLayerTrackingTarget(layerId, targetId, { - signal = null, - origin = 'share-restore', - } = {}) { - const entry = this.layers.get(layerId); - const base = { - layerId, - targetId, - origin, - refreshSucceeded: false, - }; - if (!entry || !entry.enabled || entry.destroying) { - return { ...base, status: 'unavailable', reason: 'layer-unavailable' }; - } - if (typeof entry.module?.resolveTrackingRestoreTarget !== 'function') { - return { ...base, status: 'unsupported', reason: 'tracking-restore-unsupported' }; - } - if (signal?.aborted) { - return { ...base, status: 'cancelled', reason: String(signal.reason || 'aborted') }; - } - - const refreshSucceeded = await this.refreshLayer(layerId, { signal }); - if (signal?.aborted) { - return { - ...base, - refreshSucceeded, - status: 'cancelled', - reason: String(signal.reason || 'aborted'), - }; - } - if (this.layers.get(layerId) !== entry || entry.destroying || !entry.enabled) { - return { - ...base, - refreshSucceeded, - status: 'destroyed', - reason: 'layer-destroyed', - }; - } - - try { - const resolution = await entry.module.resolveTrackingRestoreTarget(targetId, { - signal, - origin, - refreshSucceeded, - }); - if (signal?.aborted) { - return { - ...base, - refreshSucceeded, - status: 'cancelled', - reason: String(signal.reason || 'aborted'), - }; - } - const status = [ - 'found', 'missing', 'source-unavailable', 'cancelled', 'superseded', 'destroyed', - ].includes(resolution?.status) - ? resolution.status - : 'source-unavailable'; - return { ...base, refreshSucceeded, ...resolution, status }; - } catch (error) { - if (signal?.aborted || isAbortError(error)) { - return { - ...base, - refreshSucceeded, - status: 'cancelled', - reason: String(signal?.reason || error?.message || 'aborted'), - errorClass: 'AbortError', - }; - } - return { - ...base, - refreshSucceeded, - status: 'source-unavailable', - reason: String(error?.message || error), - errorClass: error?.name || 'Error', - }; - } - } - - _armUpdateLoop(layerId, entry) { - const configuredRefreshInterval = Number(entry.module.refreshInterval); - const updateInterval = Number(entry.module.updateInterval); - const refreshInterval = configuredRefreshInterval > 0 - ? configuredRefreshInterval - : (updateInterval > 0 ? updateInterval : 0); - if (refreshInterval > 0) { - entry.intervalId = setInterval(() => { - void this._runPeriodicUpdate(layerId, entry); - }, refreshInterval); - } else if (updateInterval === 0) { - entry.intervalId = setInterval(() => { - if (!entry.enabled) return; - this._refreshTogglePanel(); - }, entry.module.statsRefreshInterval || 1000); - } - } - - toggle(layerId, { origin = 'programmatic', notificationToken = null } = {}) { - const entry = this.layers.get(layerId); - if (!entry || entry.destroying) return Promise.resolve(false); - // A relative user action still needs the same revocable authority as an - // absolute request. Invert the effective (latest-intent) state now, then - // let the absolute-intent lane serialize and cancel obsolete lifecycle - // work. This also makes two rapid toggles deterministically mean ON, OFF. - return this._setEnabledWithIntent(layerId, !this.isEffectivelyEnabled(layerId), { - origin, - notificationToken, - notifyWillChangeBeforeEffective: true, - }).promise; - } - - _enqueueToggle(entry, operation) { - const next = entry.toggleChain - .catch(() => {}) - .then(operation); - entry.toggleChain = next; - return next; - } - - _setLifecycleTransition(entry, requestedChange, lifecycleState) { - entry.lifecycleState = lifecycleState; - this._syncModuleLifecyclePresentation(entry); - this._refreshTogglePanel(); - this._notifyListeners({ - ...requestedChange, - type: 'visibility-transition', - lifecycleState, - settledEnabled: entry.enabled, - }); - } - - _settleLifecycle(entry) { - entry.lifecycleState = entry.enabled ? 'enabled' : 'disabled'; - this._syncModuleLifecyclePresentation(entry); - } - - _syncModuleLifecyclePresentation(entry) { - if (typeof entry.module.setLifecyclePresentation !== 'function') return; - try { - entry.module.setLifecyclePresentation({ - lifecycleState: entry.lifecycleState, - enabled: entry.enabled, - uncertain: entry.lifecycleUncertain, - }); - } catch (error) { - console.warn(`[Data] ${entry.module.id} lifecycle presentation error:`, error); - } - } - - _visibilityCancellationMetadata(entry, intentEpoch, signal, phase, resourceAbort = false) { - const hasSuccessor = Number.isInteger(intentEpoch) && entry.visibilityIntentEpoch > intentEpoch; - const metadata = { - ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), - phase, - cancellationReason: resourceAbort - ? 'resource-abort' - : (hasSuccessor || signal?.reason === SUPERSEDED_VISIBILITY_INTENT - ? 'superseded' - : 'caller-abort'), - ...(hasSuccessor ? { - successorIntentEpoch: entry.visibilityIntentEpoch, - successorEnabled: entry.visibilityIntentEnabled, - successorOrigin: entry.visibilityIntentOrigin, - } : {}), - }; - const record = entry.visibilityIntentRecords.get(intentEpoch); - if (record) Object.assign(record, metadata); - return metadata; - } - - _setVisibilityIntentPhase(entry, intentEpoch, phase) { - if (!Number.isInteger(intentEpoch)) return; - const record = entry.visibilityIntentRecords.get(intentEpoch); - if (record) record.phase = phase; - } - - async _doToggle(entry, layerId, origin, { - signal = null, - targetEnabled = !entry.enabled, - notificationToken = null, - intentEpoch = null, - suppressWillChangeNotification = false, - beforeEnableParams = null, - } = {}) { - const desiredState = Boolean(targetEnabled); - const recordVisibilityFailure = (phase, error) => { - if (!Number.isInteger(intentEpoch)) return; - entry.visibilityIntentFailures.set(intentEpoch, { - phase, - error: error || lifecycleRejectedError(layerId, phase), - }); - }; - const isSuperseded = () => ( - intentEpoch !== null && intentEpoch !== entry.visibilityIntentEpoch - ); - const settleLifecycle = () => { - if (!isSuperseded()) { - entry.pendingVisibilityAdoptionEpoch = 0; - this._settleLifecycle(entry); - // setLifecyclePresentation() is synchronous and may notify a - // subscriber that immediately issues a newer absolute request. Re-read - // the epoch after that callback boundary before treating settlement as - // owned by this transaction. - if (!isSuperseded()) return true; - } - // Cleanup belongs to the obsolete transaction, so retain the actual - // conservative state but keep presentation transitional/hidden. The - // latest queued absolute request will reconcile or adopt it and alone - // publish settled visibility under its own origin. - entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; - entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; - this._syncModuleLifecyclePresentation(entry); - return false; - }; - const requestedChange = { - type: 'visibility-will-change', - layerId, - enabled: desiredState, - origin, - ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), - ...(notificationToken ? { notificationToken } : {}), - }; - if (signal?.aborted) return false; - if (!suppressWillChangeNotification) this._notifyListeners(requestedChange); - const blockReason = await this._visibilityBlockReason(requestedChange); - if (signal?.aborted) { - this._notifyListeners({ - ...requestedChange, - type: 'visibility-cancelled', - ...this._visibilityCancellationMetadata(entry, intentEpoch, signal, 'guard'), - }); - return false; - } - if (blockReason) { - this._refreshTogglePanel(); - this._notifyListeners({ - ...requestedChange, - type: 'visibility-blocked', - reason: blockReason, - }); - return false; - } - this._setLifecycleTransition( - entry, - requestedChange, - desiredState ? 'enabling' : 'disabling', - ); - if (!desiredState) { - // Disable - this._invalidateRefresh(layerId, entry, 'layer-disabled'); - const finishCancelledDisable = async (phase = 'disable', resourceAbort = false) => { - // The module may already have completed its disable work, so compensate - // inside this serialized manager transaction. Restore ON only after a - // successful enable; otherwise remain truthfully OFF and let the next - // setEnabled(true) perform real lifecycle work. - let compensated = false; - let compensationError = null; - try { - compensated = await entry.module.enable(this.viewer) !== false; - if (!compensated) { - compensationError = lifecycleRejectedError(layerId, 'cancel-disable-compensation'); - } - } catch (error) { - compensationError = error; - console.warn(`[Data] ${layerId} cancelled-disable cleanup error:`, error); - } - let cleanupConfirmed = false; - if (!compensated) { - try { - cleanupConfirmed = await entry.module.disable(this.viewer) !== false; - } catch (error) { - console.warn(`[Data] ${layerId} cancelled-disable final cleanup error:`, error); - } - } - // A failed enable may have partially activated the module. Only record - // OFF when a subsequent disable positively confirms cleanup; otherwise - // retain ON as the conservative authoritative state. - entry.enabled = compensated || !cleanupConfirmed; - entry.lifecycleUncertain = !compensated && !cleanupConfirmed; - settleLifecycle(); - if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - this._refreshTogglePanel(); - if (!compensated) { - recordVisibilityFailure( - 'cancel-disable-compensation', - compensationError || lifecycleRejectedError(layerId, 'cancel-disable-compensation'), - ); - } - this._notifyListeners({ - ...requestedChange, - type: compensated ? 'visibility-cancelled' : 'visibility-failed', - ...(compensated - ? this._visibilityCancellationMetadata(entry, intentEpoch, signal, phase, resourceAbort) - : {}), - ...(compensated ? {} : { - phase: 'cancel-disable-compensation', - error: compensationError, - }), - }); - return false; - }; - try { - const disabled = await entry.module.disable(this.viewer, { signal }); - if (disabled === false) throw lifecycleRejectedError(layerId, 'disable'); - } catch (e) { - if (signal?.aborted || isAbortError(e)) { - return finishCancelledDisable('disable', isAbortError(e) && !signal?.aborted); - } - // Fail closed: the module may still be polling or rendering, so keep - // the manager's authoritative state enabled and preserve its interval. - entry.enabled = true; - entry.lifecycleUncertain = true; - settleLifecycle(); - console.warn(`[Data] ${layerId} disable error:`, e); - recordVisibilityFailure('disable', e); - this._refreshTogglePanel(); - this._notifyListeners({ - ...requestedChange, - type: 'visibility-failed', - phase: 'disable', - error: e, - }); - return false; - } - if (signal?.aborted) return finishCancelledDisable('disable'); - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - entry.enabled = false; - entry.lifecycleUncertain = false; - if (!settleLifecycle() || signal?.aborted) return finishCancelledDisable('settle'); - } else { - // Enable - let abortCleanup = null; - const cancelEnable = () => { - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - // Disable immediately so modules with their own AbortController (Radio) - // cancel pending update work at the same turn boundary. A second - // disable after the current lifecycle await settles closes the race - // where an asynchronous enable finishes after this callback. - try { - abortCleanup = Promise.resolve(entry.module.disable(this.viewer)).catch((error) => { - console.warn(`[Data] ${layerId} cancelled-enable cleanup error:`, error); - return false; - }); - } catch (error) { - console.warn(`[Data] ${layerId} cancelled-enable cleanup error:`, error); - abortCleanup = Promise.resolve(false); - } - }; - const finishCancelledEnable = async (phase, resourceAbort = false) => { - // A resource-local AbortError settles this transaction without - // aborting the caller's signal. Release that signal's listener now so - // a later abort cannot revoke a successful retry. - signal?.removeEventListener('abort', cancelEnable); - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - await abortCleanup; - let cleanupConfirmed = false; - try { cleanupConfirmed = await entry.module.disable(this.viewer) !== false; } catch (error) { - console.warn(`[Data] ${layerId} cancelled-enable final cleanup error:`, error); - } - entry.enabled = !cleanupConfirmed; - entry.lifecycleUncertain = !cleanupConfirmed; - settleLifecycle(); - this._refreshTogglePanel(); - if (!cleanupConfirmed) { - recordVisibilityFailure( - 'cancel-enable-cleanup', - lifecycleRejectedError(layerId, 'cancel-enable-cleanup'), - ); - } - this._notifyListeners({ - ...requestedChange, - type: cleanupConfirmed ? 'visibility-cancelled' : 'visibility-failed', - ...(cleanupConfirmed - ? this._visibilityCancellationMetadata(entry, intentEpoch, signal, phase, resourceAbort) - : {}), - ...(cleanupConfirmed ? {} : { phase: 'cancel-enable-cleanup' }), - }); - return false; - }; - const finishFailedEnable = async (phase, error) => { - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - let cleanupConfirmed = false; - try { cleanupConfirmed = await entry.module.disable(this.viewer) !== false; } catch (cleanupError) { - console.warn(`[Data] ${layerId} failed-enable cleanup error:`, cleanupError); - } - entry.enabled = !cleanupConfirmed; - entry.lifecycleUncertain = !cleanupConfirmed; - settleLifecycle(); - console.warn(`[Data] ${layerId} ${phase} error:`, error); - recordVisibilityFailure(phase, error); - this._refreshTogglePanel(); - this._notifyListeners({ - ...requestedChange, - type: 'visibility-failed', - phase, - error, - }); - signal?.removeEventListener('abort', cancelEnable); - return false; - }; - signal?.addEventListener('abort', cancelEnable, { once: true }); - if (!entry.initialized) { - this._setVisibilityIntentPhase(entry, intentEpoch, 'init'); - try { - const initialized = await entry.module.init(this.viewer, { signal }); - if (initialized === false) throw lifecycleRejectedError(layerId, 'init'); - entry.initialized = true; - } catch (e) { - if (signal?.aborted || isAbortError(e)) { - return finishCancelledEnable('init', isAbortError(e) && !signal?.aborted); - } - return finishFailedEnable('init', e); - } - } - if (signal?.aborted) return finishCancelledEnable('init'); - if (beforeEnableParams) { - this._setVisibilityIntentPhase(entry, intentEpoch, 'params'); - const paramsResult = this._applyLayerParamsIntent( - layerId, - beforeEnableParams.params, - { - origin: beforeEnableParams.origin, - paramsIntentEpoch: beforeEnableParams.paramsIntentEpoch, - }, - ); - beforeEnableParams.result = paramsResult; - if (!paramsResult.succeeded) { - if (signal?.aborted || paramsResult.cancellationReason) { - return finishCancelledEnable('params'); - } - return finishFailedEnable('params', paramsResult.error || paramsRejectedError(layerId)); - } - } - if (signal?.aborted) return finishCancelledEnable('params'); - entry.lifecycleUncertain = false; - this._setVisibilityIntentPhase(entry, intentEpoch, 'enable'); - try { - const enabled = await entry.module.enable(this.viewer, { signal }); - if (enabled === false) throw lifecycleRejectedError(layerId, 'enable'); - } catch (e) { - if (signal?.aborted || isAbortError(e)) { - return finishCancelledEnable('enable', isAbortError(e) && !signal?.aborted); - } - return finishFailedEnable('enable', e); - } - if (signal?.aborted) return finishCancelledEnable('enable'); - - // First update immediately - this._setVisibilityIntentPhase(entry, intentEpoch, 'update'); - try { - const updated = await entry.module.update(this.viewer, { signal }); - if (updated === false) throw lifecycleRejectedError(layerId, 'update'); - } catch (e) { - if (signal?.aborted || isAbortError(e)) { - return finishCancelledEnable('update', isAbortError(e) && !signal?.aborted); - } - return finishFailedEnable('update', e); - } - if (signal?.aborted) return finishCancelledEnable('update'); - entry.managerRefreshError = null; - - entry.enabled = true; - entry.lifecycleUncertain = false; - this._setVisibilityIntentPhase(entry, intentEpoch, 'settle'); - if (!settleLifecycle() || signal?.aborted) return finishCancelledEnable('settle'); - - // Always clear any stale interval before assigning a new one, so we never - // orphan a running timer and end up double-polling. - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - - // Manager-owned periodic refresh work has one normalized loading/error - // contract. Camera-driven layers may keep updateInterval=0 and opt into - // a slower data fetch with refreshInterval. - this._armUpdateLoop(layerId, entry); - signal?.removeEventListener('abort', cancelEnable); - } - - this._refreshTogglePanel(); - governorRequestRender('layer-visibility'); - // Same reason as the poll tick: a layer appearing or disappearing changes - // the detectable set wholesale, and the solve behind it has to be re-run. - markDetectionSourcesChanged('layer-visibility'); - this._notifyListeners({ - type: 'visibility', - layerId, - enabled: entry.enabled, - origin, - ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), - ...(notificationToken ? { notificationToken } : {}), - }); - return true; - } - - /** - * Ensure a layer is in the requested enabled/disabled state. - * Deterministic helper for scripted scene playback. - */ - setEnabled(layerId, shouldEnable, { - origin = 'programmatic', - signal = null, - notificationToken = null, - } = {}) { - return this._setEnabledWithIntent(layerId, shouldEnable, { - origin, - signal, - notificationToken, - }).promise; - } - - /** - * Internal absolute-visibility request with an exact intent handle. - * The ordinary setEnabled() promise remains the public control contract. - */ - _setEnabledWithIntent(layerId, shouldEnable, { - origin = 'programmatic', - signal = null, - notificationToken = null, - notifyWillChangeBeforeEffective = false, - beforeEnableParams = null, - } = {}) { - const entry = this.layers.get(layerId); - if (!entry) return { intentEpoch: null, promise: Promise.resolve() }; - const desiredState = Boolean(shouldEnable); - if (entry.destroying) { - return { intentEpoch: null, promise: Promise.resolve(desiredState === false) }; - } - cancelPendingLayerRestore(entry, origin, 'explicit-visibility'); - const intentEpoch = ++entry.visibilityIntentEpoch; - entry.visibilityIntentEnabled = desiredState; - entry.visibilityIntentOrigin = origin; - let resolveIntentRecord; - const settled = new Promise((resolve) => { resolveIntentRecord = resolve; }); - const intentRecord = { - intentEpoch, - enabled: desiredState, - origin, - phase: 'queued', - settled, - resolve: resolveIntentRecord, - }; - entry.visibilityIntentRecords.set(intentEpoch, intentRecord); - for (const [recordEpoch, record] of entry.visibilityIntentRecords) { - if (entry.visibilityIntentRecords.size <= 16) break; - if (recordEpoch !== intentEpoch && record.completed) entry.visibilityIntentRecords.delete(recordEpoch); - } - // Relative toggle historically exposes its will-change edge while the - // settled/effective snapshot is still the pre-click state. Preserve that - // Context capture boundary, but reserve the epoch first so a re-entrant - // listener can still supersede this request authoritatively. - if (notifyWillChangeBeforeEffective) { - this._notifyListeners({ - type: 'visibility-will-change', - layerId, - enabled: desiredState, - origin, - intentEpoch, - ...(notificationToken ? { notificationToken } : {}), - }); - } - // Effective visibility must follow the NEWEST absolute intent from the - // synchronous moment it is requested — the superseded transaction's - // cleanup updates lifecycleState later, and Context capture can run in - // between. Cleared by this request's own queue turn when it finishes. - if (entry.visibilityIntentEpoch === intentEpoch) { - entry.latestQueuedAbsoluteIntent = { intentEpoch, enabled: desiredState }; - } - this._notifyVisibilityRequest({ - type: 'visibility-requested', - layerId, - enabled: desiredState, - origin, - intentEpoch, - ...(notificationToken ? { notificationToken } : {}), - }); - // Advance absolute intent before aborting so the obsolete transaction's - // cleanup can defer settlement to this exact latest epoch. Supersede even - // a same-target request: its newer origin may carry explicit user intent - // that must own the eventual persistence-bearing visibility event. - entry.activeVisibilityIntent?.controller.abort(SUPERSEDED_VISIBILITY_INTENT); - // The idempotency check belongs inside the same per-layer queue as toggle. - // Checking before enqueueing lets two simultaneous setEnabled(true) calls - // both observe OFF and accidentally perform enable-then-disable (M6). - const releaseQueuedIntent = () => { - if (entry.latestQueuedAbsoluteIntent?.intentEpoch === intentEpoch) { - entry.latestQueuedAbsoluteIntent = null; - } - }; - const runIntentTurn = async () => { - const requestedChange = { - type: 'visibility-will-change', - layerId, - enabled: desiredState, - origin, - intentEpoch, - ...(notificationToken ? { notificationToken } : {}), - }; - if (intentEpoch !== entry.visibilityIntentEpoch) { - this._notifyListeners({ - ...requestedChange, - type: 'visibility-cancelled', - ...this._visibilityCancellationMetadata(entry, intentEpoch, null, 'queued'), - }); - return false; - } - if (signal?.aborted) { - if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { - entry.pendingVisibilityAdoptionEpoch = 0; - this._settleLifecycle(entry); - this._refreshTogglePanel(); - } - // Every accepted absolute request has one authoritative terminal - // outcome. Even when it is aborted before its queue turn and no - // adoption is pending, publish cancellation and populate the exact - // intent record used by waiters. - this._notifyListeners({ - ...requestedChange, - type: 'visibility-cancelled', - ...this._visibilityCancellationMetadata(entry, intentEpoch, signal, 'queued'), - }); - return false; - } - if ( - entry.pendingVisibilityAdoptionEpoch === intentEpoch - && entry.enabled === desiredState - && !entry.lifecycleUncertain - ) { - // Adoption publishes a successful settled visibility, so it must pass - // the same guards a fresh transition would — a guard installed after - // the superseded transaction started (e.g. an exclusive Context mode) - // must be able to veto the adopted state, not just future requests. - const adoptionBlockReason = await this._visibilityBlockReason(requestedChange); - if (intentEpoch !== entry.visibilityIntentEpoch) { - // The guard is an async boundary. A newer intent can arrive while it - // is pending, so this adoption needs the same exact terminal envelope - // as every other superseded phase. Context follows these successor - // fields instead of guessing from mutable manager state. - if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { - entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; - } - entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; - this._syncModuleLifecyclePresentation(entry); - this._refreshTogglePanel(); - this._notifyListeners({ - ...requestedChange, - type: 'visibility-cancelled', - ...this._visibilityCancellationMetadata(entry, intentEpoch, null, 'adoption'), - }); - return false; - } - if (signal?.aborted) { - if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { - entry.pendingVisibilityAdoptionEpoch = 0; - this._settleLifecycle(entry); - this._refreshTogglePanel(); - this._notifyListeners({ - ...requestedChange, - type: 'visibility-cancelled', - ...this._visibilityCancellationMetadata(entry, intentEpoch, signal, 'adoption'), - }); - } - return false; - } - if (adoptionBlockReason) { - entry.pendingVisibilityAdoptionEpoch = 0; - this._refreshTogglePanel(); - if (entry.enabled === desiredState) { - // The guard forbids the very state the superseded transaction's - // cleanup left behind. Reconcile through the ordinary lifecycle to - // the guard-respecting opposite — but the CALLER's request stays - // unfulfilled either way. Ordering matters: the compensation - // registers as the active intent AND takes over the queued-intent - // record BEFORE the blocked event is announced, so a listener that - // re-enters setEnabled() during the callback has a live intent to - // abort and observes the reconciliation target as effective - // visibility, not the refused request. - const compensationController = new AbortController(); - const compensationIntent = { - intentEpoch, - enabled: !desiredState, - controller: compensationController, - }; - entry.activeVisibilityIntent = compensationIntent; - entry.latestQueuedAbsoluteIntent = { intentEpoch, enabled: !desiredState }; - this._notifyListeners({ - ...requestedChange, - type: 'visibility-blocked', - reason: adoptionBlockReason, - }); - // The callback may have superseded this turn. The newest intent - // owns reconciliation now — defer exactly like an obsolete - // transaction instead of installing an already-doomed compensation. - if ( - intentEpoch !== entry.visibilityIntentEpoch - || compensationController.signal.aborted - ) { - if (entry.activeVisibilityIntent === compensationIntent) { - entry.activeVisibilityIntent = null; - } - entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; - entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; - this._syncModuleLifecyclePresentation(entry); - return false; - } - try { - await this._doToggle(entry, layerId, origin, { - signal: compensationController.signal, - targetEnabled: !desiredState, - notificationToken, - intentEpoch, - }); - } finally { - if (entry.activeVisibilityIntent === compensationIntent) { - entry.activeVisibilityIntent = null; - } - } - return false; - } - this._notifyListeners({ - ...requestedChange, - type: 'visibility-blocked', - reason: adoptionBlockReason, - }); - this._settleLifecycle(entry); - return false; - } - entry.pendingVisibilityAdoptionEpoch = 0; - this._settleLifecycle(entry); - if (intentEpoch !== entry.visibilityIntentEpoch) { - entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; - entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; - this._syncModuleLifecyclePresentation(entry); - return false; - } - this._refreshTogglePanel(); - this._notifyListeners({ - type: 'visibility', - layerId, - enabled: desiredState, - origin, - intentEpoch, - ...(notificationToken ? { notificationToken } : {}), - }); - return true; - } - if (entry.enabled === desiredState && !entry.lifecycleUncertain) { - // Idempotent exit — but an aborted predecessor (e.g. a compensation - // cancelled before its first transition) may have left a stale - // transitional presentation. State and intent agree here, so settle - // the presentation rather than orphaning ENABLING/DISABLING forever. - // Publish the accepted absolute intent even when lifecycle work is a - // no-op: a newer explicit origin can own Context/persistence behavior - // without redundantly re-enabling the module. - if (entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling') { - this._settleLifecycle(entry); - } - this._refreshTogglePanel(); - this._notifyListeners({ - type: 'visibility', - layerId, - enabled: desiredState, - origin, - intentEpoch, - ...(notificationToken ? { notificationToken } : {}), - }); - return true; - } - - entry.pendingVisibilityAdoptionEpoch = 0; - const controller = new AbortController(); - const forwardCallerAbort = () => controller.abort(signal?.reason); - signal?.addEventListener('abort', forwardCallerAbort, { once: true }); - const activeIntent = { intentEpoch, enabled: desiredState, controller }; - entry.activeVisibilityIntent = activeIntent; - try { - return await this._doToggle(entry, layerId, origin, { - signal: controller.signal, - targetEnabled: desiredState, - notificationToken, - intentEpoch, - suppressWillChangeNotification: notifyWillChangeBeforeEffective, - beforeEnableParams, - }); - } finally { - signal?.removeEventListener('abort', forwardCallerAbort); - if (entry.activeVisibilityIntent === activeIntent) { - entry.activeVisibilityIntent = null; - } - } - }; - const promise = this._enqueueToggle(entry, async () => { - try { - return await runIntentTurn(); - } finally { - // This turn no longer owns queued-intent effective visibility — - // either it settled, or a newer epoch superseded it (that epoch's - // own record already replaced this one). - releaseQueuedIntent(); - } - }); - promise.then((result) => { - const failure = entry.visibilityIntentFailures.get(intentEpoch) || null; - entry.visibilityIntentFailures.delete(intentEpoch); - intentRecord.completed = true; - intentRecord.result = result; - intentRecord.error = failure?.error || null; - intentRecord.settledEnabled = entry.enabled; - intentRecord.uncertain = entry.lifecycleUncertain; - intentRecord.resolve({ - intentEpoch, - enabled: desiredState, - origin, - phase: failure?.phase || intentRecord.phase, - result, - ...(failure?.error ? { error: failure.error } : {}), - settledEnabled: entry.enabled, - uncertain: entry.lifecycleUncertain, - succeeded: result !== false && entry.enabled === desiredState && !entry.lifecycleUncertain, - cancellationReason: intentRecord.cancellationReason || null, - successorIntentEpoch: intentRecord.successorIntentEpoch ?? null, - successorEnabled: intentRecord.successorEnabled ?? null, - successorOrigin: intentRecord.successorOrigin ?? null, - }); - }, (error) => { - entry.visibilityIntentFailures.delete(intentEpoch); - intentRecord.completed = true; - intentRecord.error = error; - intentRecord.resolve({ - intentEpoch, - enabled: desiredState, - origin, - phase: intentRecord.phase, - result: false, - error, - settledEnabled: entry.enabled, - uncertain: entry.lifecycleUncertain, - succeeded: false, - cancellationReason: intentRecord.cancellationReason || null, - successorIntentEpoch: intentRecord.successorIntentEpoch ?? null, - successorEnabled: intentRecord.successorEnabled ?? null, - successorOrigin: intentRecord.successorOrigin ?? null, - }); - }); - return { intentEpoch, promise }; - } - - /** Wait for one exact absolute visibility intent to complete. */ - async _waitForVisibilityIntent(layerId, intentEpoch) { - const record = this.layers.get(layerId)?.visibilityIntentRecords?.get(intentEpoch); - return record ? record.settled : null; - } - - /** - * Follow one restore request through any explicit superseding intent chain. - * The newest named successor must reach a terminal state before restore can - * judge the layer; an obsolete caller boolean is never sufficient. - */ - async _waitForAuthoritativeVisibilityIntent(layerId, intentEpoch) { - const entry = this.layers.get(layerId); - if (!entry || !Number.isInteger(intentEpoch)) return null; - let epoch = intentEpoch; - let outcome = null; - while (Number.isInteger(epoch)) { - outcome = await this._waitForVisibilityIntent(layerId, epoch); - if (!outcome) return null; - const newerEpoch = entry.visibilityIntentEpoch > epoch - ? entry.visibilityIntentEpoch - : outcome.successorIntentEpoch; - if (!Number.isInteger(newerEpoch) || newerEpoch <= epoch) break; - if (outcome.cancellationReason !== 'superseded') return null; - epoch = newerEpoch; - } - return outcome; - } - - /** - * The layer's effective visibility target: settled state, unless awaited - * lifecycle work (or a superseded transaction awaiting adoption) is moving - * it — an ENABLING layer is effectively ON and a DISABLING layer is - * effectively OFF, regardless of which side has settled. - */ - _effectiveEnabled(entry) { - // Newest-intent-wins: an absolute request owns effective visibility from - // the synchronous moment it is made, even while a superseded transaction - // has not yet updated lifecycleState. - if ( - entry.clearVisibilityReservation - && entry.visibilityIntentEpoch === entry.clearVisibilityReservation.intentEpoch - ) return false; - if (entry.latestQueuedAbsoluteIntent) return entry.latestQueuedAbsoluteIntent.enabled; - if (entry.lifecycleState === 'enabling') return true; - if (entry.lifecycleState === 'disabling') return false; - return entry.enabled; - } - - /** - * Whether a layer is effectively enabled, counting in-flight transitions as - * their target state. Context capture/isolation must use THIS (not the - * settled `isEnabled()`) so a layer mid-activation is isolated and - * snapshotted as ON, and a layer honoring a user's in-flight OFF is not - * snapshotted (and later restored) as ON. - * @param {string} layerId Registered layer identifier. - * @returns {boolean} - */ - isEffectivelyEnabled(layerId) { - const entry = this.layers.get(layerId); - return entry ? this._effectiveEnabled(entry) : false; - } - - /** - * Snapshot the exact set of registered layers the user currently intends - * enabled, counting in-flight transitions as their target state. - * @returns {Set} A detached set safe for later restoration. - */ - getEnabledLayerIds() { - return new Set( - [...this.layers] - .filter(([, entry]) => this._effectiveEnabled(entry)) - .map(([layerId]) => layerId), - ); - } - - /** - * Turn off every layer whose latest authoritative intent is currently ON. - * Reverse registration order lets dependents settle their teardown before - * an earlier-registered dependency receives its final OFF intent. Each layer - * keeps its normal latest-intent manager authority, so a newer direct request - * can supersede this batch without being overwritten by a retry loop. - * - * @param {object} [options] Clear transition options. - * @param {string} [options.origin='user'] Visibility-event origin. - * @param {symbol|null} [options.notificationToken] Shared notification owner. - * @returns {Promise<{targetIds:string[],items:object[],clearedIds:string[],notClearedIds:string[]}>} - */ - async clearSelectedLayers({ origin = 'user', notificationToken = null } = {}) { - const clearBatchId = Symbol('clear-selected-layers'); - const targets = [...this.layers] - .filter(([, entry]) => this._effectiveEnabled(entry)) - .map(([layerId, entry]) => ({ - layerId, - intentEpoch: entry.visibilityIntentEpoch, - })) - .reverse(); - // Reserve the whole batch before the first awaited OFF. This makes Clear - // All's global OFF authority observable synchronously while preserving - // reverse-order lifecycle teardown. Any later absolute intent advances - // the epoch and therefore owns that layer instead. - for (const { layerId, intentEpoch } of targets) { - const entry = this.layers.get(layerId); - if (entry) entry.clearVisibilityReservation = { clearBatchId, intentEpoch }; - } - const targetIds = targets.map(({ layerId }) => layerId); - const attempts = new Map(); - try { - for (const { layerId, intentEpoch } of targets) { - const entry = this.layers.get(layerId); - const superseded = entry && entry.visibilityIntentEpoch !== intentEpoch; - if (superseded) { - attempts.set(layerId, { superseded: true }); - continue; - } - try { - const clearIntentEpoch = entry.visibilityIntentEpoch + 1; - const result = await this.setEnabled(layerId, false, { - origin, - ...(notificationToken ? { notificationToken } : {}), - }); - attempts.set(layerId, { - result, - superseded: entry.visibilityIntentEpoch !== clearIntentEpoch, - }); - } catch (error) { - attempts.set(layerId, { error }); - } - } - } finally { - for (const { layerId } of targets) { - const entry = this.layers.get(layerId); - if (entry?.clearVisibilityReservation?.clearBatchId === clearBatchId) { - entry.clearVisibilityReservation = null; - } - } - } - const items = targetIds.map((id) => { - const state = this.getLayerLifecycleState(id) || { - enabled: false, - lifecycleState: 'missing', - uncertain: true, - }; - const attempt = attempts.get(id) || {}; - const cleared = !state.enabled && state.lifecycleState === 'disabled' && !state.uncertain; - return { - id, - requested: false, - cleared, - result: attempt.result, - error: attempt.error || null, - superseded: attempt.superseded === true, - ...state, - }; - }); - return { - targetIds, - items, - clearedIds: items.filter((item) => item.cleared).map((item) => item.id), - notClearedIds: items.filter((item) => !item.cleared).map((item) => item.id), - }; - } - - /** - * Restore the exact enabled set captured before a focused mode took over. - * Each transition uses the normal serialized lifecycle and visibility event - * path so subscribers observe the same state changes as direct operations. - * - * @param {Iterable} enabledLayerIds Exact target enabled layer ids. - * @param {object} [options] Restore notification options. - * @param {string} [options.origin='programmatic'] Visibility event origin. - * @param {symbol|null} [options.notificationToken] Opaque caller token used - * to correlate one transition's user-facing failure notification. - * @param {Iterable} [options.excludeLayerIds] Layers owned by an - * in-flight transition that must not enqueue behind themselves. - * @param {AbortSignal|null} [options.signal] Caller cancellation authority. - * @returns {Promise} Resolves after every registered layer settles. - */ - async restoreEnabledLayerIds( - enabledLayerIds, - { - origin = 'programmatic', - excludeLayerIds = [], - notificationToken = null, - signal = null, - } = {}, - ) { - const target = new Set(enabledLayerIds || []); - const excluded = new Set(excludeLayerIds || []); - const layerIds = [...this.layers.keys()].filter((layerId) => { - if (excluded.has(layerId)) return false; - const entry = this.layers.get(layerId); - // Pre-destroy restoration cannot enqueue onto the layer whose teardown - // already owns OFF. That terminal teardown satisfies an OFF snapshot; - // an impossible ON target remains visible to the failure checks below. - return !(entry?.destroying && !target.has(layerId)); - }); - const transitionOptions = { - origin, - ...(notificationToken ? { notificationToken } : {}), - ...(signal ? { signal } : {}), - }; - const handles = layerIds.map((layerId) => this._setEnabledWithIntent( - layerId, - target.has(layerId), - transitionOptions, - )); - const results = await Promise.allSettled(handles.map((handle, index) => ( - this._waitForAuthoritativeVisibilityIntent(layerIds[index], handle.intentEpoch) - ))); - const failedLayerIds = results.flatMap((result, index) => { - if (result.status === 'rejected') return [layerIds[index]]; - const outcome = result.value; - const layerId = layerIds[index]; - const state = this.getLayerLifecycleState(layerId); - const desiredState = target.has(layerId); - const failed = !outcome - || outcome.error - || !state - || state.enabled !== desiredState - || state.lifecycleState !== (desiredState ? 'enabled' : 'disabled') - || state.uncertain - || this.layers.get(layerId)?.latestQueuedAbsoluteIntent; - return failed ? [layerId] : []; - }); - if (failedLayerIds.length === 0) return; - const failedIndex = layerIds.indexOf(failedLayerIds[0]); - const failed = results[failedIndex]; - const error = failed.status === 'rejected' - ? failed.reason - : new Error(`Failed to restore layer "${layerIds[failedIndex]}" visibility`); - error.failedLayerIds = [...new Set([ - ...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []), - ...failedLayerIds, - ])]; - throw error; - } - - /** - * Wait until lifecycle work already queued for one layer has settled. - * Callers use this outside manager listeners before scheduling reconciliation. - * @param {string} layerId Layer identifier. - * @returns {Promise} Resolves after the captured queue settles. - */ - async waitForLayerSettled(layerId) { - const entry = this.layers.get(layerId); - if (!entry) return; - await entry.toggleChain.catch(() => {}); - } - - _reserveLayerParamsIntent(layerId, params, origin = 'programmatic') { - const entry = this.layers.get(layerId); - if (!entry || entry.destroying || typeof entry.module?.setParams !== 'function') return null; - cancelPendingLayerRestore(entry, origin, 'explicit-params'); - const paramsIntentEpoch = ++entry.paramsIntentEpoch; - entry.paramsIntentOrigin = origin; - this._notifyListeners({ - type: 'params-requested', - layerId, - params: cloneLayerParams(params || {}), - origin, - paramsIntentEpoch, - }); - return paramsIntentEpoch; - } - - _applyLayerParamsIntent(layerId, params, { - origin = 'programmatic', - paramsIntentEpoch = null, - } = {}) { - const entry = this.layers.get(layerId); - if (!entry || !entry.module || typeof entry.module.setParams !== 'function') { - return { succeeded: false, error: paramsRejectedError(layerId), params: null }; - } - if (!Number.isInteger(paramsIntentEpoch) || entry.paramsIntentEpoch !== paramsIntentEpoch) { - const result = { succeeded: false, cancellationReason: 'superseded', params: null }; - this._notifyListeners({ - type: 'params-cancelled', - layerId, - origin, - paramsIntentEpoch, - cancellationReason: result.cancellationReason, - successorParamsIntentEpoch: entry.paramsIntentEpoch, - successorOrigin: entry.paramsIntentOrigin, - }); - return result; - } - try { - const accepted = entry.module.setParams(params || {}, { origin, paramsIntentEpoch }); - if (accepted === false) throw paramsRejectedError(layerId); - if (entry.paramsIntentEpoch !== paramsIntentEpoch) { - const result = { succeeded: false, cancellationReason: 'superseded', params: null }; - this._notifyListeners({ - type: 'params-cancelled', - layerId, - origin, - paramsIntentEpoch, - cancellationReason: result.cancellationReason, - successorParamsIntentEpoch: entry.paramsIntentEpoch, - successorOrigin: entry.paramsIntentOrigin, - }); - return result; - } - const appliedParams = this.getLayerParams(layerId) || cloneLayerParams(params || {}); - this._refreshTogglePanel(); - governorRequestRender(`layer-params:${layerId}`); - this._notifyListeners({ - type: 'params', - layerId, - params: appliedParams, - requestedParams: cloneLayerParams(params || {}), - origin, - paramsIntentEpoch, - }); - return { succeeded: true, params: appliedParams, paramsIntentEpoch }; - } catch (error) { - console.warn(`[Data] ${layerId} setParams error:`, error); - this._notifyListeners({ - type: 'params-failed', - layerId, - params: cloneLayerParams(params || {}), - origin, - paramsIntentEpoch, - error, - }); - return { succeeded: false, error, params: null, paramsIntentEpoch }; - } - } - - /** Apply runtime parameters through an origin-bearing intent lane. */ - setLayerParams(layerId, params, { origin = 'programmatic' } = {}) { - const paramsIntentEpoch = this._reserveLayerParamsIntent(layerId, params, origin); - if (!Number.isInteger(paramsIntentEpoch)) return false; - return this._applyLayerParamsIntent(layerId, params, { origin, paramsIntentEpoch }).succeeded; - } - - /** Cancel a module-owned pending restore without creating a parameter intent. */ - cancelPendingLayerRestore(layerId, { - origin = 'programmatic', - reason = 'cancelled', - } = {}) { - const entry = this.layers.get(layerId); - const cancel = entry?.module?.cancelPendingRestore - || entry?.module?.cancelPendingTrackingRestore; - if (typeof cancel !== 'function') return false; - try { - cancel.call(entry.module, { origin, reason }); - return true; - } catch (error) { - console.warn(`[Data] ${layerId} pending restore cancellation error:`, error); - return false; - } - } - - /** Publish parameters already applied by a layer's direct interaction. */ - adoptLayerParams(layerId, params, { origin = 'programmatic' } = {}) { - const requestedParams = cloneLayerParams(params || {}); - const paramsIntentEpoch = this._reserveLayerParamsIntent(layerId, requestedParams, origin); - if (!Number.isInteger(paramsIntentEpoch)) return false; - const appliedParams = this.getLayerParams(layerId); - const matches = appliedParams && Object.entries(requestedParams) - .every(([key, value]) => Object.is(appliedParams[key], value)); - if (!matches) { - const error = paramsRejectedError(layerId); - this._notifyListeners({ - type: 'params-failed', layerId, params: requestedParams, origin, paramsIntentEpoch, error, - }); - return false; - } - this._refreshTogglePanel(); - governorRequestRender(`layer-params:${layerId}`); - this._notifyListeners({ - type: 'params', layerId, params: appliedParams, requestedParams, origin, paramsIntentEpoch, - }); - return true; - } - - /** - * Publish an explicit owner adoption of an already-settled layer visibility. - * This is used when a direct selection promotes a Context-owned dependency - * into durable user state without redundantly re-running its lifecycle. - */ - adoptLayerVisibility( - layerId, - enabled, - { origin = 'programmatic', adoptedFromSelection = false } = {}, - ) { - const entry = this.layers.get(layerId); - const desiredState = Boolean(enabled); - if (!entry || entry.enabled !== desiredState || entry.lifecycleUncertain) return false; - cancelPendingLayerRestore(entry, origin, 'superseded-by-explicit-visibility-adoption'); - this._refreshTogglePanel(); - this._notifyListeners({ - type: 'visibility', - layerId, - enabled: desiredState, - origin, - intentEpoch: entry.visibilityIntentEpoch, - adopted: true, - adoptedFromSelection: Boolean(adoptedFromSelection), - }); - return true; - } - - /** - * Restore one finalized-registry layer independently. Parameters apply after - * init and before enable, with a terminal envelope that never writes local - * persistence. - */ - async restoreLayerState(layerId, { enabled = false, params = null } = {}, { - origin = 'programmatic', - signal = null, - } = {}) { - if (!this._registrationsFinalized) throw new Error('Layer restore requires finalized registrations'); - const entry = this.layers.get(layerId); - const targetEnabled = Boolean(enabled); - if (!entry) { - return { - layerId, - targetEnabled, - origin, - phase: 'missing', - settledEnabled: false, - lifecycleState: 'missing', - lifecycleUncertain: false, - appliedOptions: {}, - errorClass: 'UnknownLayer', - persistenceWrite: false, - succeeded: false, - }; - } - - const requestedParams = params && typeof params === 'object' && Object.keys(params).length - ? cloneLayerParams(params) - : null; - let paramsEnvelope = null; - let beforeEnableParams = null; - if (requestedParams) { - const paramsIntentEpoch = this._reserveLayerParamsIntent(layerId, requestedParams, origin); - if (Number.isInteger(paramsIntentEpoch)) { - if (targetEnabled && !entry.enabled) { - beforeEnableParams = { params: requestedParams, origin, paramsIntentEpoch, result: null }; - } else { - paramsEnvelope = this._applyLayerParamsIntent(layerId, requestedParams, { - origin, - paramsIntentEpoch, - }); - } - } else { - paramsEnvelope = { succeeded: false, error: paramsRejectedError(layerId) }; - } - } - - const handle = this._setEnabledWithIntent(layerId, targetEnabled, { - origin, - signal, - beforeEnableParams, - }); - const requestedVisibility = await this._waitForVisibilityIntent(layerId, handle.intentEpoch); - const authoritativeVisibility = requestedVisibility?.cancellationReason === 'superseded' - ? await this._waitForAuthoritativeVisibilityIntent(layerId, handle.intentEpoch) - : requestedVisibility; - paramsEnvelope = paramsEnvelope || beforeEnableParams?.result; - const state = this.getLayerLifecycleState(layerId); - const paramsSucceeded = !requestedParams || paramsEnvelope?.succeeded === true; - const visibilitySucceeded = authoritativeVisibility?.succeeded === true - && state?.enabled === targetEnabled - && state?.uncertain === false; - const error = paramsEnvelope?.error || authoritativeVisibility?.error - || requestedVisibility?.error || null; - return { - layerId, - targetEnabled, - origin, - phase: requestedVisibility?.phase || (signal?.aborted ? 'reserved' : 'unknown'), - completionPhase: authoritativeVisibility?.phase || null, - settledEnabled: Boolean(state?.enabled), - lifecycleState: state?.lifecycleState || 'missing', - lifecycleUncertain: Boolean(state?.uncertain), - appliedOptions: paramsSucceeded && requestedParams ? requestedParams : {}, - intentEpoch: handle.intentEpoch, - authoritativeIntentEpoch: authoritativeVisibility?.intentEpoch ?? handle.intentEpoch, - authoritativeEnabled: authoritativeVisibility?.enabled ?? null, - authoritativeOrigin: authoritativeVisibility?.origin ?? null, - paramsIntentEpoch: paramsEnvelope?.paramsIntentEpoch ?? beforeEnableParams?.paramsIntentEpoch ?? null, - cancellationReason: requestedVisibility?.cancellationReason - || paramsEnvelope?.cancellationReason || null, - successorIntentEpoch: requestedVisibility?.successorIntentEpoch ?? null, - successorEnabled: requestedVisibility?.successorEnabled ?? null, - successorOrigin: requestedVisibility?.successorOrigin ?? null, - errorClass: error?.name || (signal?.aborted ? 'AbortError' : null), - ...(error ? { error: String(error.message || error) } : {}), - persistenceWrite: false, - succeeded: paramsSucceeded && visibilitySucceeded, - }; - } - - /** - * Read runtime parameters from a layer if it exposes `getParams()`. - */ - getLayerParams(layerId) { - const entry = this.layers.get(layerId); - if (!entry || !entry.module || typeof entry.module.getParams !== 'function') return null; - try { - const params = entry.module.getParams(); - if (!params || typeof params !== 'object') return null; - return cloneLayerParams(params); - } catch (error) { - console.warn(`[Data] ${layerId} getParams error:`, error); - return null; - } - } - - /** - * Destroy a single layer — calls its destroy() method if it has one, - * then removes it from the manager. This is the proper cleanup path - * that was previously missing. - */ - async destroyLayer(layerId) { - const entry = this.layers.get(layerId); - if (!entry || entry.destroying) return false; - entry.destroying = true; - this._invalidateRefresh(layerId, entry, 'layer-destroyed'); - // Teardown becomes authoritative before the first await. Advancing the - // intent epoch and aborting active work prevents a pending enable/update - // from publishing a settled ON while the layer is being destroyed. The - // synthetic successor metadata also lets Context restore rather than adopt. - const teardownIntentEpoch = ++entry.visibilityIntentEpoch; - entry.visibilityIntentEnabled = false; - entry.visibilityIntentOrigin = 'teardown'; - entry.latestQueuedAbsoluteIntent = { intentEpoch: teardownIntentEpoch, enabled: false }; - entry.clearVisibilityReservation = null; - entry.activeVisibilityIntent?.controller.abort(SUPERSEDED_VISIBILITY_INTENT); - entry.paramsIntentEpoch += 1; - entry.paramsIntentOrigin = 'teardown'; - await entry.toggleChain.catch(() => {}); - // Let focused UI modes restore their exact pre-session state before any - // dependency is irreversibly removed. The intent revocation above makes - // this callback safe to run before the old queue drains. - for (const callback of this._beforeDestroyListeners) { - try { - await callback({ type: 'before-destroy', layerId }); - } catch (error) { - console.warn('[Data] before-destroy listener error:', error); - } - } - await entry.toggleChain.catch(() => {}); - if (this.layers.get(layerId) !== entry) return false; - entry.latestQueuedAbsoluteIntent = null; - if (entry.enabled) { - try { - const disabled = await entry.module.disable(this.viewer); - if (disabled === false) throw lifecycleRejectedError(layerId, 'destroy-disable'); - } catch (e) { - console.warn(`[Data] ${layerId} disable error:`, e); - entry.destroying = false; - entry.visibilityIntentEnabled = entry.enabled; - this._settleLifecycle(entry); - this._refreshTogglePanel(); - return false; - } - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - entry.enabled = false; - } - if (typeof entry.module.destroy === 'function') { - try { - const destroyed = await entry.module.destroy(this.viewer); - if (destroyed === false) throw lifecycleRejectedError(layerId, 'destroy'); - } catch (e) { - console.warn(`[Data] ${layerId} destroy error:`, e); - entry.destroying = false; - entry.visibilityIntentEnabled = entry.enabled; - this._settleLifecycle(entry); - this._refreshTogglePanel(); - return false; - } - } - this.layers.delete(layerId); - this._qaLayerIds.delete(layerId); - return true; - } - - /** - * Destroy all layers and clear the manager. - * Should be called when the viewer is being torn down. - */ - async destroyAll() { - this._layerPanel?.destroy(); - this._layerPanel = null; - this._toggleContainer = null; - for (const layerId of [...this.layers.keys()]) { - await this.destroyLayer(layerId); - } - } - - isEnabled(layerId) { - const entry = this.layers.get(layerId); - return entry ? entry.enabled : false; - } - - /** Return authoritative settled visibility and the current lifecycle phase. */ - getLayerLifecycleState(layerId) { - const entry = this.layers.get(layerId); - if (!entry) return null; - return Object.freeze({ - enabled: entry.enabled, - lifecycleState: entry.lifecycleState, - uncertain: entry.lifecycleUncertain, - }); - } - - getAll() { - const result = []; - for (const [id, entry] of this.layers) { - result.push({ - id, - name: entry.module.name, - icon: entry.module.icon, - source: entry.module.source, - showInTogglePanel: entry.module.showInTogglePanel !== false, - enabled: entry.enabled, - lifecycleState: entry.lifecycleState, - lifecycleUncertain: entry.lifecycleUncertain, - stats: this._normalizedStats(entry), - }); - } - return result; - } - - subscribe(callback) { - if (typeof callback !== 'function') return () => {}; - this._listeners.add(callback); - return () => this._listeners.delete(callback); - } - - /** - * Subscribe to explicit setEnabled intent before it joins the lifecycle queue. - * This lets a newer direct-user OFF request cancel older work before that work - * can publish an intermediate settled visibility state. - * @param {(change:{type:string,layerId:string,enabled:boolean,origin:string}) => void} callback - * @returns {() => void} Unsubscribe function. - */ - subscribeVisibilityRequests(callback) { - if (typeof callback !== 'function') return () => {}; - this._visibilityRequestListeners.add(callback); - return () => this._visibilityRequestListeners.delete(callback); - } - - /** - * Register a visibility guard evaluated before layer lifecycle work. Guards - * may await bounded mode preparation and return a string or `{reason}` to - * refuse a transition. - * @param {(change:{type:string,layerId:string,enabled:boolean,origin:string}) => (string|object|null|void)} callback Guard. - * @returns {() => void} Unsubscribe function. - */ - addVisibilityGuard(callback) { - if (typeof callback !== 'function') return () => {}; - this._visibilityGuards.add(callback); - return () => this._visibilityGuards.delete(callback); - } - - /** - * Subscribe to the awaited pre-destroy lifecycle boundary. - * @param {(change:{type:string,layerId:string}) => (void|Promise)} callback - * @returns {() => void} Unsubscribe function. - */ - subscribeBeforeDestroy(callback) { - if (typeof callback !== 'function') return () => {}; - this._beforeDestroyListeners.add(callback); - return () => this._beforeDestroyListeners.delete(callback); - } - - _notifyListeners(change) { - for (const callback of this._listeners) { - try { - callback(change); - } catch (error) { - console.warn('[Data] listener error:', error); - } - } - } - - _notifyVisibilityRequest(change) { - for (const callback of this._visibilityRequestListeners) { - try { - callback(change); - } catch (error) { - console.warn('[Data] visibility request listener error:', error); - } - } - } - - async _visibilityBlockReason(change) { - for (const callback of this._visibilityGuards) { - try { - const result = await callback(change); - if (typeof result === 'string' && result.trim()) return result.trim(); - if (result && typeof result.reason === 'string' && result.reason.trim()) { - return result.reason.trim(); - } - } catch (error) { - console.warn('[Data] visibility guard error:', error); - } - } - return null; - } - - /** - * Build the toggle panel UI inside the given container element. - */ buildTogglePanel(container) { - this._toggleContainer = container; - this._renderToggles(); + this._presentation.mount(container); } - - _rowControlsFor(layerId) { - const module = this.layers.get(layerId)?.module; - if (typeof module?.getRowControls !== 'function') return null; - try { - return module.getRowControls() || null; - } catch (error) { - console.warn(`[Data] ${layerId} getRowControls error:`, error); - return null; - } + _refreshTogglePanel() { + this._presentation.refresh(); } - - _getLayerPanel() { - if (!this._layerPanel) this._layerPanel = new LayerPanel({ - getLayers: () => this.getAll(), - isEnabled: (id) => this.isEnabled(id), - setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options), - setLayerParams: (id, params, options) => this.setLayerParams(id, params, options), - getRowControls: (id) => this._rowControlsFor(id), - hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function', - subscribeRowControls: (id, listener) => { - const module = this.layers.get(id)?.module; - module?.setRowControlsListener?.(listener); - return () => module?.setRowControlsListener?.(null); - }, - onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; }, - }); - return this._layerPanel; + _buildMetaText(layer) { + return this._presentation.panel._buildMetaText(layer); + } + _syncToggleButton(button, layer) { + return this._presentation.panel._syncToggleButton(button, layer); + } + get _layerPanel() { + return this._presentation._panel; + } + get _panelRefreshPendingOnVisible() { + return this._presentation.pendingVisible; + } + set _panelRefreshPendingOnVisible(value) { + this._presentation.pendingVisible = value; } - _renderToggles() { this._getLayerPanel().mount(this._toggleContainer); } - _refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); } - _buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); } - _syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); } } diff --git a/src/tooling/packageBoundaries.test.mjs b/src/tooling/packageBoundaries.test.mjs index ecb2d3ff8..548cadbf4 100644 --- a/src/tooling/packageBoundaries.test.mjs +++ b/src/tooling/packageBoundaries.test.mjs @@ -107,3 +107,22 @@ test('a Node build export must be explicitly classified and scoped', async (t) = await writeFile(path.join(root, 'startup.js'), 'export const app = 2;'); await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/); }); + +test('a lifecycle owner cannot acquire a layer panel even through an unused import', async (t) => { + const root = await fixture( + t, + "import './panel.js'; export class LayerLifecycle {}", + ); + await writeFile(path.join(root, 'panel.js'), 'export class LayerPanel {}'); + await writeFile( + path.join(root, 'scripts/package-boundaries.json'), + JSON.stringify({ + 'layer-lifecycle': { + exports: ['./feature'], + modules: ['entry.js'], + external: [], + }, + }), + ); + await assert.rejects(checkPackageBoundaries(root), /unowned module.*panel/); +});