From 722df3125fd6a03ac202ad38d5cfe2dae00582b6 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Wed, 7 Oct 2026 03:30:22 +0800 Subject: [PATCH] Add visibility-aware interval helper --- frontend/visible-timer.js | 41 ++++++++++++++++ frontend/visible-timer.test.js | 90 ++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 frontend/visible-timer.js create mode 100644 frontend/visible-timer.test.js diff --git a/frontend/visible-timer.js b/frontend/visible-timer.js new file mode 100644 index 0000000..6e62397 --- /dev/null +++ b/frontend/visible-timer.js @@ -0,0 +1,41 @@ +(function (root) { + 'use strict'; + + function setIntervalWhenVisible(fn, ms, { immediate = false } = {}) { + if (typeof fn !== 'function') throw new TypeError('fn must be a function'); + const doc = root.document; + if (!doc) throw new Error('document is required'); + + let timer = null; + let stopped = false; + const clear = () => { + if (timer !== null) root.clearInterval(timer); + timer = null; + }; + const start = (runNow) => { + if (stopped || doc.hidden) return; + clear(); + if (runNow) fn(); + timer = root.setInterval(fn, ms); + }; + const onVisibilityChange = () => { + if (doc.hidden) clear(); + else start(immediate); + }; + + if (!doc.hidden) start(immediate); + doc.addEventListener('visibilitychange', onVisibilityChange); + return { + clear() { + if (stopped) return; + stopped = true; + clear(); + doc.removeEventListener('visibilitychange', onVisibilityChange); + }, + }; + } + + const api = { setIntervalWhenVisible }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.VisibleTimer = api; +})(globalThis); diff --git a/frontend/visible-timer.test.js b/frontend/visible-timer.test.js new file mode 100644 index 0000000..cbd7a6d --- /dev/null +++ b/frontend/visible-timer.test.js @@ -0,0 +1,90 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { setIntervalWhenVisible } = require('./visible-timer'); + +class FakeDocument { + hidden = false; + listeners = new Set(); + addEventListener(type, listener) { if (type === 'visibilitychange') this.listeners.add(listener); } + removeEventListener(type, listener) { if (type === 'visibilitychange') this.listeners.delete(listener); } + setHidden(hidden) { + this.hidden = hidden; + for (const listener of [...this.listeners]) listener(); + } +} + +function fakeClock(t, hidden = false) { + const doc = new FakeDocument(); + doc.hidden = hidden; + const original = { + document: globalThis.document, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + }; + let nextId = 1; + const timers = new Map(); + globalThis.document = doc; + globalThis.setInterval = (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }; + globalThis.clearInterval = (id) => timers.delete(id); + t.after(() => { + globalThis.document = original.document; + globalThis.setInterval = original.setInterval; + globalThis.clearInterval = original.clearInterval; + }); + return { + doc, + timers, + tick() { for (const timer of [...timers.values()]) timer.fn(); }, + }; +} + +test('runs on a visible page and has one interval until hidden', (t) => { + const clock = fakeClock(t); + let calls = 0; + const handle = setIntervalWhenVisible(() => calls++, 250); + assert.equal(calls, 0); + assert.equal(clock.timers.size, 1); + assert.equal([...clock.timers.values()][0].ms, 250); + clock.tick(); + assert.equal(calls, 1); + clock.doc.setHidden(true); + assert.equal(clock.timers.size, 0); + clock.tick(); + assert.equal(calls, 1); + handle.clear(); + assert.equal(clock.doc.listeners.size, 0); +}); + +test('immediate mode runs once initially and once when becoming visible', (t) => { + const clock = fakeClock(t); + let calls = 0; + const handle = setIntervalWhenVisible(() => calls++, 1000, { immediate: true }); + assert.equal(calls, 1); + assert.equal(clock.timers.size, 1); + clock.doc.setHidden(true); + assert.equal(clock.timers.size, 0); + clock.doc.setHidden(false); + assert.equal(calls, 2); + assert.equal(clock.timers.size, 1); + handle.clear(); + clock.doc.setHidden(true); + clock.doc.setHidden(false); + assert.equal(calls, 2); + assert.equal(clock.timers.size, 0); +}); + +test('a page that starts hidden waits until visible before calling or scheduling', (t) => { + const clock = fakeClock(t, true); + let calls = 0; + const handle = setIntervalWhenVisible(() => calls++, 500, { immediate: true }); + assert.equal(calls, 0); + assert.equal(clock.timers.size, 0); + clock.doc.setHidden(false); + assert.equal(calls, 1); + assert.equal(clock.timers.size, 1); + handle.clear(); +});