mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
perf(layers): pause polled refreshes while the tab is hidden
A hidden tab shows nobody the result, so polled layers (flights, quakes,
weather...) skip their refresh while the page is hidden and catch up with
one refresh when it is shown again. The lifecycle takes an optional
pageVisibility service ({ isHidden, subscribe }) so src/data/lifecycle.js
stays free of browser globals; src/app/data.js supplies the document-backed
one. Without the service, refreshes run as before. Every place that stopped
a layer's interval now goes through one helper that also drops the
catch-up listener.
The HUD's 250 ms camera readout also skips hidden tabs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8cdd184a90
commit
952671964d
4 files changed
+111
-30
No files matched your search
@@ -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();
|
||||
|
||||
+52
-29
@@ -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') {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user