Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions ui/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
25 changes: 25 additions & 0 deletions ui/test/connections-map-frames.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
9 changes: 8 additions & 1 deletion ui/test/connections-map-mount.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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)();
Expand All @@ -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();
Expand Down
80 changes: 80 additions & 0 deletions ui/test/widget-toolbox-on-screen.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
2 changes: 1 addition & 1 deletion ui/widgets/backup/backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
<body>
<div class="widget" id="widget" role="group" aria-label="Backup status"></div>
<script type="module">
import { poll, fetchData, openUrl, linkTo, html, setHtml, loadStrings, wt, errorState, errorLine, formatList, relativeTime, cssZoom } from '/js/widget-toolbox.js?v=29dcd8c6';
import { poll, fetchData, openUrl, linkTo, html, setHtml, loadStrings, wt, errorState, errorLine, formatList, relativeTime, cssZoom } from '/js/widget-toolbox.js?v=85032a30';
await loadStrings();
document.getElementById('widget').setAttribute('aria-label', wt('ui.backupStatus', 'Backup status'));
const id = new URLSearchParams(location.search).get('id') || '';
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/books/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
<span class="sr" id="sr" aria-live="polite"></span>
</div>
<script type="module">
import { poll, linkTo, loadStrings, wt, formatNumber, errorState, placeholder } from '/js/widget-toolbox.js?v=29dcd8c6';
import { poll, linkTo, loadStrings, wt, formatNumber, errorState, placeholder } from '/js/widget-toolbox.js?v=85032a30';
await loadStrings();
document.getElementById('card').setAttribute('aria-label', wt('widget.label', 'Books'));
const API='/api';
Expand Down
8 changes: 5 additions & 3 deletions ui/widgets/clock/analog.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
async function showZoneError(){
const w=document.getElementById('widget');
w.className='widget mode-'+_mode;
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=29dcd8c6');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=85032a30');
await loadStrings();
const line=wt('ui.unknownZone','Unknown timezone');
errorState({ root: w, place: 'center' }).empty(line);
Expand Down Expand Up @@ -262,9 +262,10 @@
if(_stepId){ clearTimeout(_stepId); _stepId=null; }
}

let _inView=true;
document.addEventListener('visibilitychange', ()=>{
if(_zoneBad) return;
if(document.hidden) stop();
if(document.hidden || !_inView) stop();
else { render(!_reduceMotion); start(); }
});

Expand All @@ -273,7 +274,8 @@
if(!('IntersectionObserver' in window)) return;
_io=new IntersectionObserver(entries=>{
for(const e of entries){
if(e.isIntersecting && !document.hidden) start();
_inView=e.isIntersecting;
if(_inView && !document.hidden) start();
else stop();
}
}, { threshold: 0 });
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/clock/digital.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
}
async function showZoneError() {
const w = document.getElementById('widget');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=29dcd8c6');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=85032a30');
await loadStrings();
const line = wt('ui.unknownZone', 'Unknown timezone');
errorState({ root: w, place: 'center' }).empty(line);
Expand Down
6 changes: 4 additions & 2 deletions ui/widgets/connections/connections-map.html
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
})(window.__DOTS);
</script>
<script type="module">
import { poll, fetchData, openUrl, html, setHtml, colorOrFallback, loadStrings, wt, errorState, theme, formatNumber, formatList, cssZoom, linkTo } from '/js/widget-toolbox.js?v=29dcd8c6';
import { poll, fetchData, openUrl, html, setHtml, colorOrFallback, loadStrings, wt, errorState, theme, formatNumber, formatList, cssZoom, linkTo, whileOnScreen } from '/js/widget-toolbox.js?v=85032a30';
await loadStrings();
document.getElementById('c').setAttribute('aria-label', wt('ui.worldMap', 'World map of connections'));
(function(){
Expand Down Expand Up @@ -211,6 +211,8 @@
let raf=0;
function kick(){if(!raf)raf=requestAnimationFrame(frame);}
function frame(now){raf=0;draw(now);}
let onScreen=false;
whileOnScreen(cv,v=>{onScreen=v;if(v)kick();});

function computeLayers(data){
lit=new Map();points=[];legend=[];
Expand Down Expand Up @@ -352,7 +354,7 @@
ctx.fillStyle=rgbCss(pt.col);
disc(x,y,p*2/3*pulse);
}
if(!reduce&&points.length)kick();
if(!reduce&&points.length&&onScreen)kick();
}
function redraw(){relayout();kick();}

Expand Down
Loading
Loading