diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..2a7f820b0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -14,6 +14,15 @@ export function createApplicationData({ const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, getSourceAvailability: catalog?.getSourceAvailability, + // Polled layers skip refreshes while the tab is hidden and catch up + // once when it is shown again. + pageVisibility: { + isHidden: () => document.visibilityState === 'hidden', + subscribe(callback) { + document.addEventListener('visibilitychange', callback); + return () => document.removeEventListener('visibilitychange', callback); + }, + }, }); defer(async () => { await dataManager.destroyAll(); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..d9005422d 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -74,11 +74,25 @@ function refreshFailureFromStats(stats, label) { * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { + /** + * @param {object} viewer + * @param {object} [options] + * @param {boolean} [options.allowQaRegistration] + * @param {Function} [options.getSourceAvailability] + * @param {{isHidden(): boolean, subscribe(callback: Function): Function}|null} [options.pageVisibility] + * Whether the page is hidden, and a visibilitychange subscription. With + * it, polled refreshes pause in a hidden page; without it they always run. + */ constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { + allowQaRegistration = false, + getSourceAvailability, + pageVisibility = null, + } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; + this._pageVisibility = pageVisibility; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -130,6 +144,10 @@ export class LayerLifecycle { enabled: false, initialized: false, intervalId: null, + // A periodic refresh that fell due while the page was hidden; it runs + // once when the page is visible again (see _armUpdateLoop). + updateDue: false, + pageResume: 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 @@ -538,9 +556,24 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { + // A hidden tab shows nobody the result, so polled feeds (flights, + // quakes, weather...) skip their refresh while hidden and catch up + // with one refresh when the page is shown again. + const page = this._pageVisibility; + const pageHidden = () => page?.isHidden?.() === true; entry.intervalId = setInterval(() => { + if (pageHidden()) { + entry.updateDue = true; + return; + } void this._runPeriodicUpdate(layerId, entry); }, refreshInterval); + if (typeof page?.subscribe === 'function') + entry.pageResume = page.subscribe(() => { + if (pageHidden() || !entry.updateDue || !entry.intervalId) return; + entry.updateDue = false; + void this._runPeriodicUpdate(layerId, entry); + }); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -549,6 +582,17 @@ export class LayerLifecycle { } } + /** Stop a layer's update loop and its page-visibility catch-up. */ + _clearUpdateLoop(entry) { + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + entry.updateDue = false; + if (typeof entry.pageResume === 'function') entry.pageResume(); + entry.pageResume = null; + } + toggle(layerId, { origin = 'programmatic', notificationToken = null } = {}) { const entry = this.layers.get(layerId); if (!entry || entry.destroying) return Promise.resolve(false); @@ -771,10 +815,7 @@ export class LayerLifecycle { entry.enabled = compensated || !cleanupConfirmed; entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); - if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + if (!entry.enabled) this._clearUpdateLoop(entry); this._publishActivity({ type: 'status' }); if (!compensated) { recordVisibilityFailure( @@ -832,10 +873,7 @@ export class LayerLifecycle { return false; } if (signal?.aborted) return finishCancelledDisable('disable'); - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); entry.enabled = false; entry.lifecycleUncertain = false; if (!settleLifecycle() || signal?.aborted) @@ -844,10 +882,7 @@ export class LayerLifecycle { // Enable let abortCleanup = null; const cancelEnable = () => { - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(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 @@ -875,10 +910,7 @@ export class LayerLifecycle { // 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; - } + this._clearUpdateLoop(entry); await abortCleanup; let cleanupConfirmed = false; try { @@ -917,10 +949,7 @@ export class LayerLifecycle { return false; }; const finishFailedEnable = async (phase, error) => { - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); let cleanupConfirmed = false; try { cleanupConfirmed = @@ -1028,10 +1057,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._clearUpdateLoop(entry); // Manager-owned periodic refresh work has one normalized loading/error // contract. Camera-driven layers may keep updateInterval=0 and opt into @@ -2157,10 +2183,7 @@ export class LayerLifecycle { this._publishActivity({ type: 'status' }); return false; } - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); entry.enabled = false; } if (typeof entry.module.destroy === 'function') { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..40c1c4f2f 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -568,6 +568,55 @@ test('setEnabled is idempotent and serializes with toggle', async () => { assert.equal(entry.intervalId, null, 'stats interval cleared on disable'); }); +test('polled layers skip refreshes in a hidden tab and catch up once when shown', async () => { + const listeners = new Set(); + let state = 'visible'; + const pageVisibility = { + isHidden: () => state === 'hidden', + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; + const setVisibility = (next) => { + state = next; + for (const fn of [...listeners]) fn(); + }; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + { + const mgr = new DataLayerManager({}, { pageVisibility }); + const layer = makeSlowLayer('flights', { updateInterval: 40 }); + mgr.register(layer.module); + await mgr.setEnabled('flights', true); + assert.equal(listeners.size, 1, 'one catch-up listener while polling'); + + setVisibility('hidden'); + const hiddenStart = layer.calls.update; + await sleep(130); + assert.equal(layer.calls.update, hiddenStart, 'no refresh while hidden'); + assert.equal(mgr.layers.get('flights').updateDue, true); + + // Each listener call while still hidden must not refresh either. + setVisibility('hidden'); + assert.equal(layer.calls.update, hiddenStart); + + setVisibility('visible'); + assert.equal(layer.calls.update, hiddenStart + 1, 'one catch-up refresh on show'); + assert.equal(mgr.layers.get('flights').updateDue, false); + setVisibility('visible'); + assert.equal(layer.calls.update, hiddenStart + 1, 'no second catch-up'); + + await mgr.setEnabled('flights', false); + assert.equal(mgr.layers.get('flights').intervalId, null); + assert.equal(listeners.size, 0, 'disable removes the catch-up listener'); + + await mgr.setEnabled('flights', true); + assert.equal(listeners.size, 1); + await mgr.destroyLayer('flights'); + assert.equal(listeners.size, 0, 'destroy removes the catch-up listener'); + } +}); + test('idempotent absolute intent publishes its newer origin without rerunning lifecycle', async () => { const mgr = new DataLayerManager({}); const layer = makeSlowLayer('rocket-launches', { updateInterval: -1 }); diff --git a/src/hud.js b/src/hud.js index 8190f4b1b..cca07b450 100644 --- a/src/hud.js +++ b/src/hud.js @@ -350,7 +350,7 @@ export class IntelHUD { // Camera-derived data — 4 updates/second (250ms) this._updateInterval = setInterval(() => { - if (!this._visible) return; + if (!this._visible || document.hidden) return; this._updateCameraData(); }, 250);