mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
* fix(layers): make Clear All clear the saved and shared layer selection After Clear All every layer control was off, but the share URL could keep listing layers (for example l=e.f) and a reload switched them back on. The URL and local preferences are written from the committed layer state, which only explicit visibility changes update. Clear All issued an explicit OFF only for layers it found on at the start of the batch and whose turn was not superseded. A layer already off for a passive reason (isolated by Contacts or another context mode, or released by Contacts' own teardown while the batch ran) never received one, so it stayed in the committed state. Clear All also discards the context snapshot that would have restored those layers, leaving the screen and the URL out of step. Clear All now publishes the set of layers still on once its batch settles, and the layer-state coordinator commits that selection: every saved layer not in it is dropped and its passive restore is cancelled, while a layer a newer explicit request turned back on is kept. A context restore interrupted by Clear All no longer brings its snapshot back after Contacts settles. * docs: changelog for #1417
2403 lines
82 KiB
JavaScript
2403 lines
82 KiB
JavaScript
import { cancelCameraArrival } from './cameraArrival.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),
|
||
]),
|
||
);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
||
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||
'enabled-only',
|
||
'enabled+options',
|
||
'enabled+mirrored-options',
|
||
// Registered but never serialized (for example hardware-local layers).
|
||
'local-only',
|
||
]);
|
||
|
||
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.
|
||
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
||
* listener methods. Without one, periodic work retains its headless behavior.
|
||
*/
|
||
export class LayerLifecycle {
|
||
constructor(
|
||
viewer,
|
||
{
|
||
allowQaRegistration = false,
|
||
getSourceAvailability,
|
||
visibilityTarget = null,
|
||
} = {},
|
||
) {
|
||
this._catalogSourceAvailability = getSourceAvailability;
|
||
this._visibilityTarget = visibilityTarget;
|
||
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 availability = this._sourceAvailability(entry.module.id);
|
||
const lifecycleLoading =
|
||
entry.lifecycleState === 'enabling' ||
|
||
entry.lifecycleState === 'disabling';
|
||
return {
|
||
count: 0,
|
||
lastUpdate: null,
|
||
...moduleStats,
|
||
...(availability?.available === false
|
||
? {
|
||
status: 'unavailable',
|
||
sourceUnavailable: true,
|
||
error: availability.reason,
|
||
}
|
||
: {}),
|
||
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<boolean>} 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',
|
||
};
|
||
}
|
||
}
|
||
|
||
_stopUpdateLoop(entry) {
|
||
clearInterval(entry.intervalId);
|
||
entry.intervalId = null;
|
||
entry.removeVisibilityListener?.();
|
||
entry.removeVisibilityListener = null;
|
||
}
|
||
|
||
_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) {
|
||
const visibilityDocument = this._visibilityTarget;
|
||
let intervalId = null;
|
||
let dueAt = 0;
|
||
const canPoll = () =>
|
||
!visibilityDocument?.hidden &&
|
||
visibilityDocument?.visibilityState !== 'hidden' &&
|
||
entry.enabled &&
|
||
!entry.destroying &&
|
||
!entry.refreshing &&
|
||
entry.intervalId === intervalId;
|
||
// dueAt and the interval phase share one origin, so every interval tick
|
||
// is a due poll. A visibility catch-up re-arms the interval at the new
|
||
// deadline; otherwise the old phase would skip its next tick.
|
||
const schedule = () => {
|
||
clearInterval(intervalId);
|
||
dueAt = Date.now() + refreshInterval;
|
||
intervalId = setInterval(onInterval, refreshInterval);
|
||
entry.intervalId = intervalId;
|
||
};
|
||
const onInterval = () => {
|
||
if (!canPoll()) return;
|
||
dueAt = Date.now() + refreshInterval;
|
||
void this._runPeriodicUpdate(layerId, entry);
|
||
};
|
||
const onVisibilityChange = () => {
|
||
if (!canPoll() || Date.now() < dueAt) return;
|
||
schedule();
|
||
void this._runPeriodicUpdate(layerId, entry);
|
||
};
|
||
schedule();
|
||
visibilityDocument?.addEventListener?.(
|
||
'visibilitychange',
|
||
onVisibilityChange,
|
||
);
|
||
entry.removeVisibilityListener = () =>
|
||
visibilityDocument?.removeEventListener?.(
|
||
'visibilitychange',
|
||
onVisibilityChange,
|
||
);
|
||
} 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) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
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) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
entry.enabled = false;
|
||
entry.lifecycleUncertain = false;
|
||
if (!settleLifecycle() || signal?.aborted)
|
||
return finishCancelledDisable('settle');
|
||
} else {
|
||
// Enable
|
||
let abortCleanup = null;
|
||
const cancelEnable = () => {
|
||
if (entry.intervalId) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
// 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) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
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) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
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) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
|
||
// 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 (!desiredState) cancelCameraArrival(this.viewer);
|
||
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<string>} 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;
|
||
}
|
||
}
|
||
}
|
||
// Clear All owns the whole selection, not only the layers it switched off
|
||
// itself. A layer already off for a passive reason (isolated by a context
|
||
// mode, released by a dependency owner, or superseded mid-batch) gets no
|
||
// OFF of its own here, so publish the set that is still on afterwards.
|
||
this._notifyListeners({
|
||
type: 'selection-cleared',
|
||
origin,
|
||
enabledLayerIds: [...this.getEnabledLayerIds()],
|
||
});
|
||
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<string>} 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<string>} [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<void>} 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<void>} 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;
|
||
cancelCameraArrival(this.viewer);
|
||
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) {
|
||
this._stopUpdateLoop(entry);
|
||
}
|
||
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,
|
||
// Registry id of the provider key this layer needs, if any (mirrors
|
||
// showInTogglePanel). The layer reports stats.keyRequired while that
|
||
// key is absent; the panel builds the guidance text from the pair.
|
||
requiresKeyId: entry.module.requiresKeyId || null,
|
||
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<void>)} 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
_sourceAvailability(layerId) {
|
||
return (
|
||
this._catalogSourceAvailability?.(layerId) ??
|
||
this.layers.get(layerId)?.module.getSourceAvailability?.()
|
||
);
|
||
}
|
||
|
||
async _visibilityBlockReason(change) {
|
||
const availability = this._sourceAvailability(change.layerId);
|
||
if (change.enabled && availability?.available === false)
|
||
return availability.reason || 'Data source unavailable';
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Ask presentation to repaint layer rows now, for data that lands outside
|
||
* the manager tick (Transit's proximity polls, Directions' route steps).
|
||
*/
|
||
refreshLayerStats() {
|
||
this._publishActivity({ type: 'status' });
|
||
}
|
||
|
||
_publishActivity(change) {
|
||
for (const callback of this._activityListeners) {
|
||
try {
|
||
callback(change);
|
||
} catch (error) {
|
||
console.warn('[Data] activity listener error:', error);
|
||
}
|
||
}
|
||
}
|
||
}
|