From 5494c93589913adda36118d9195e6a670e77d427 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:58:45 -0400 Subject: [PATCH] Fix: pause widget animations off screen and stop VPN loop stacking Add whileOnScreen to the widget toolbox and gate the Connections map and VPN animation loops on it. Track the VPN frame id so each render keeps one loop. Keep the Analog clock stopped on tab return while it is off screen. --- ui/js/widget-toolbox.js | 31 ++++++++ ui/test/connections-map-frames.test.mjs | 25 +++++++ ui/test/connections-map-mount.mjs | 9 ++- ui/test/widget-toolbox-on-screen.test.mjs | 80 +++++++++++++++++++++ ui/widgets/backup/backup.html | 2 +- ui/widgets/books/index.html | 2 +- ui/widgets/clock/analog.html | 8 ++- ui/widgets/clock/digital.html | 2 +- ui/widgets/connections/connections-map.html | 6 +- ui/widgets/connections/connections-vpn.html | 11 +-- ui/widgets/dashboard-switch/index.html | 2 +- ui/widgets/disk-health/index.html | 2 +- ui/widgets/dns/index.html | 2 +- ui/widgets/github/contributions.html | 2 +- ui/widgets/github/pullrequests.html | 2 +- ui/widgets/nowplaying/index.html | 2 +- ui/widgets/system-summary/index.html | 2 +- ui/widgets/weather/index.html | 2 +- 18 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 ui/test/widget-toolbox-on-screen.test.mjs 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 @@