From ec0985618c5697776ef9ed1402e868d5e00719cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:25:40 +0100 Subject: [PATCH] fix(layers): pause periodic network updates in hidden tabs --- docs/CURRENT-STATE.md | 2 ++ src/app/data.js | 1 + src/data/lifecycle.js | 57 +++++++++++++++++++++++---------- src/data/manager.js | 2 +- src/data/manager.test.mjs | 31 ++++++++++++++++++ src/layers/transit/lifecycle.js | 1 + src/layers/transit/viewport.js | 5 +++ 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..508b6264d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..e6c7d25e0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + visibilityTarget: document, getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..01aaf6124 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) { /** * 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 } = {}, + { + 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 } @@ -528,6 +535,13 @@ export class LayerLifecycle { } } + _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); @@ -538,9 +552,25 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { - entry.intervalId = setInterval(() => { + const visibilityDocument = this._visibilityTarget; + let dueAt = Date.now() + refreshInterval; + const tick = () => { + if ( + visibilityDocument?.hidden || + visibilityDocument?.visibilityState === 'hidden' || + !entry.enabled || + entry.destroying || + entry.refreshing || + Date.now() < dueAt + ) + return; + dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); - }, refreshInterval); + }; + entry.intervalId = setInterval(tick, refreshInterval); + visibilityDocument?.addEventListener?.('visibilitychange', tick); + entry.removeVisibilityListener = () => + visibilityDocument?.removeEventListener?.('visibilitychange', tick); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -772,8 +802,7 @@ export class LayerLifecycle { entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } this._publishActivity({ type: 'status' }); if (!compensated) { @@ -833,8 +862,7 @@ export class LayerLifecycle { } if (signal?.aborted) return finishCancelledDisable('disable'); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; entry.lifecycleUncertain = false; @@ -845,8 +873,7 @@ export class LayerLifecycle { let abortCleanup = null; const cancelEnable = () => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second @@ -876,8 +903,7 @@ export class LayerLifecycle { // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } await abortCleanup; let cleanupConfirmed = false; @@ -918,8 +944,7 @@ export class LayerLifecycle { }; const finishFailedEnable = async (phase, error) => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } let cleanupConfirmed = false; try { @@ -1029,8 +1054,7 @@ export class LayerLifecycle { // 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; + this._stopUpdateLoop(entry); } // Manager-owned periodic refresh work has one normalized loading/error @@ -2158,8 +2182,7 @@ export class LayerLifecycle { return false; } if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; } diff --git a/src/data/manager.js b/src/data/manager.js index b719b5934..79bb7d9ae 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js'; /** Compatibility facade for callers that construct a manager with its panel. */ export class DataLayerManager extends LayerLifecycle { constructor(viewer, options) { - super(viewer, options); + super(viewer, { visibilityTarget: globalThis.document, ...options }); this._presentation = new LayerPresentation(this); } buildTogglePanel(container) { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..68a0644f1 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3492,3 +3492,34 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async lifecycle.refreshLayerStats(); assert.deepEqual(changes, ['status']); }); + +test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('hidden-poll'); + manager.register(module); + await manager.setEnabled('hidden-poll', true); + assert.equal(calls.update, 1); + doc.hidden = true; + t.mock.timers.tick(10000); + assert.equal(calls.update, 1); + assert.equal(await manager.refreshLayer('hidden-poll'), true); + assert.equal(calls.update, 2, 'explicit refresh remains allowed'); + doc.hidden = false; + for (const fn of listeners) fn(); + for (const fn of listeners) fn(); + assert.equal(calls.update, 3); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(10000); + assert.equal(calls.update, 3); +}); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..90210063a 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) { else if (hidden) { hidden = false; parts.rendering.resumePlayback(); + parts.viewport.runProximityCheck(); } } function bindVisibility() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 641fab5a2..730126f10 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) { } function runProximityCheck() { + if ( + globalThis.document?.hidden || + globalThis.document?.visibilityState === 'hidden' + ) + return; if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude();