diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index 01e8bcd6..f25a8911 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -586,6 +586,37 @@ export function errorLine(err) { return _t(key, text); } +/** Calls onChange(true) while el is on screen in a visible tab and + onChange(false) when it leaves. + @param {Element} el @param {(shown: boolean) => void} onChange + @returns {{ stop(): void }} */ +export function whileOnScreen(el, onChange) { + const observable = typeof IntersectionObserver === 'function'; + let inView = !observable; + let shown = null; + const update = () => { + const now = inView && !document.hidden; + if (now === shown) return; + shown = now; + onChange(now); + }; + const io = observable + ? new IntersectionObserver(entries => { + inView = entries[entries.length - 1].isIntersecting; + update(); + }) + : null; + if (io) io.observe(el); + else update(); + document.addEventListener('visibilitychange', update); + return { + stop() { + if (io) io.disconnect(); + document.removeEventListener('visibilitychange', update); + }, + }; +} + /* Widgets on a page the user has swiped away from keep running: the dashboard mounts every page at once. It multiplies their poll interval through window.__setPollRate, which the dashboard calls on each frame. */ diff --git a/ui/test/connections-map-frames.test.mjs b/ui/test/connections-map-frames.test.mjs index a3be576a..90a941e9 100644 --- a/ui/test/connections-map-frames.test.mjs +++ b/ui/test/connections-map-frames.test.mjs @@ -34,3 +34,28 @@ test('the pulse keeps animating after a refresh', async () => { map.restore(); } }); + +test('off screen, a refresh paints once and starts no pulse loop', async () => { + const map = await mount({ onScreen: false }); + try { + map.render(PULSING); + map.runFrames(); + assert.ok(map.painted().length > 0, 'the refresh still paints'); + assert.equal(map.frames.size, 0); + } finally { + map.restore(); + } +}); + +test('the pulse resumes when the map comes back on screen', async () => { + const map = await mount({ onScreen: false }); + try { + map.render(PULSING); + map.runFrames(); + map.setOnScreen(true); + map.runFrames(); + assert.equal(map.frames.size, 1); + } finally { + map.restore(); + } +}); diff --git a/ui/test/connections-map-mount.mjs b/ui/test/connections-map-mount.mjs index 7c6a5d39..dee71cb1 100644 --- a/ui/test/connections-map-mount.mjs +++ b/ui/test/connections-map-mount.mjs @@ -105,11 +105,12 @@ function element(tag = 'div') { return el; } -export async function mount({ theme = 'dark' } = {}) { +export async function mount({ theme = 'dark', onScreen = true } = {}) { const frames = new Map(); let nextFrame = 1; const resizeCallbacks = []; let render = null; + let screenChanged = null; const byId = { c: element('canvas'), key: element(), sr: element('p') }; const saved = {}; @@ -159,6 +160,11 @@ export async function mount({ theme = 'dark' } = {}) { formatNumber: n => String(n), formatList: items => items.join(', '), cssZoom: () => 1, + whileOnScreen: (_, fn) => { + screenChanged = fn; + fn(onScreen); + return { stop() {} }; + }, }; new Function(dataScript)(); new Function(placeScript)(); @@ -172,6 +178,7 @@ export async function mount({ theme = 'dark' } = {}) { sr: byId.sr, render: data => render(data), resize: () => resizeCallbacks.forEach(fn => fn()), + setOnScreen: v => screenChanged(v), runFrames() { const due = [...frames.entries()]; frames.clear(); diff --git a/ui/test/widget-toolbox-on-screen.test.mjs b/ui/test/widget-toolbox-on-screen.test.mjs new file mode 100644 index 00000000..15253a8a --- /dev/null +++ b/ui/test/widget-toolbox-on-screen.test.mjs @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); +globalThis.location = { search: '?id=test' }; + +const listeners = new Set(); +globalThis.document = { + hidden: false, + addEventListener: (type, fn) => type === 'visibilitychange' && listeners.add(fn), + removeEventListener: (type, fn) => type === 'visibilitychange' && listeners.delete(fn), +}; +let observer = null; +globalThis.IntersectionObserver = class { + constructor(fn) { + this.fn = fn; + this.disconnected = false; + observer = this; + } + observe() {} + disconnect() { + this.disconnected = true; + } +}; + +const { whileOnScreen } = await import('../js/widget-toolbox.js'); + +const setHidden = hidden => { + document.hidden = hidden; + for (const fn of listeners) fn(); +}; +const intersect = isIntersecting => observer.fn([{ isIntersecting }]); + +test('reports on screen only while the element intersects and the tab is visible', () => { + const seen = []; + const watch = whileOnScreen({}, v => seen.push(v)); + assert.deepEqual(seen, [], 'nothing is reported before the first intersection entry'); + intersect(true); + setHidden(true); + setHidden(false); + intersect(false); + setHidden(true); + setHidden(false); + assert.deepEqual(seen, [true, false, true, false]); + watch.stop(); +}); + +test('a tab returning while the element is off screen reports nothing', () => { + const seen = []; + const watch = whileOnScreen({}, v => seen.push(v)); + intersect(false); + setHidden(true); + setHidden(false); + assert.deepEqual(seen, [false]); + watch.stop(); +}); + +test('stop disconnects the observer and the visibility listener', () => { + const watch = whileOnScreen({}, () => {}); + const before = listeners.size; + watch.stop(); + assert.equal(observer.disconnected, true); + assert.equal(listeners.size, before - 1); +}); + +test('without IntersectionObserver, visibility alone decides', () => { + const saved = globalThis.IntersectionObserver; + delete globalThis.IntersectionObserver; + try { + const seen = []; + const watch = whileOnScreen({}, v => seen.push(v)); + setHidden(true); + setHidden(false); + assert.deepEqual(seen, [true, false, true]); + watch.stop(); + } finally { + globalThis.IntersectionObserver = saved; + } +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index bd685d2f..f07f07ae 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -122,7 +122,7 @@