From a20e211c1bb1328dc5ac86415ffa591c2770a7e7 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 7 Sep 2026 13:04:38 -0700 Subject: [PATCH] Refresh site data when a stale browser tab becomes visible --- src/web/store/splatoon/data.js | 19 +++++++++ test/web/data.test.js | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/web/store/splatoon/data.js b/src/web/store/splatoon/data.js index d80e0d3..eadad73 100644 --- a/src/web/store/splatoon/data.js +++ b/src/web/store/splatoon/data.js @@ -3,6 +3,7 @@ import { nextDataRefreshAt } from '../../../common/dataRefresh.js'; let updateDataTimer; let updatingData = false; let refreshGeneration = 0; +let removeVisibilityListener; export const namespaced = true; @@ -65,21 +66,39 @@ export const actions = { return; updatingData = true; let generation = ++refreshGeneration; + let refreshing = false; + let lastRefresh = -Infinity; async function refresh() { + if (!updatingData || generation !== refreshGeneration || refreshing) + return; + refreshing = true; + lastRefresh = Date.now(); + clearTimeout(updateDataTimer); + updateDataTimer = null; try { await dispatch('updateAll'); } catch (error) { console.error('Could not refresh site data', error); } finally { + refreshing = false; if (updatingData && generation === refreshGeneration) updateDataTimer = setTimeout(refresh, nextDataRefreshAt() - Date.now()); } } + function onVisibilityChange() { + // Match Splatoon3's activation cooldown, without delaying the hourly retries. + if (document.visibilityState === 'visible' && Date.now() - lastRefresh >= 60_000) + void refresh(); + } + document.addEventListener('visibilitychange', onVisibilityChange); + removeVisibilityListener = () => document.removeEventListener('visibilitychange', onVisibilityChange); return refresh(); }, stopUpdatingData() { updatingData = false; + removeVisibilityListener?.(); + removeVisibilityListener = null; clearTimeout(updateDataTimer); updateDataTimer = null; }, diff --git a/test/web/data.test.js b/test/web/data.test.js index 7f0464d..67beb12 100644 --- a/test/web/data.test.js +++ b/test/web/data.test.js @@ -1,9 +1,12 @@ import { afterEach, beforeEach, test, mock } from 'node:test'; import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; import { actions } from '../../src/web/store/splatoon/data.js'; let timers; let commits; +let page; +let originalDocument; const context = { rootGetters: { 'splatoon/languages/selectedLanguage': null }, dispatch(name) { return actions[name](context); }, @@ -11,6 +14,10 @@ const context = { }; beforeEach(() => { + originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + page = new EventTarget; + page.visibilityState = 'visible'; + Object.defineProperty(globalThis, 'document', { configurable: true, value: page }); timers = new Map; commits = []; let id = 0; @@ -20,6 +27,10 @@ beforeEach(() => { afterEach(() => { actions.stopUpdatingData(); mock.restoreAll(); + if (originalDocument) + Object.defineProperty(globalThis, 'document', originalDocument); + else + delete globalThis.document; }); test('a stalled body times out and leaves a future refresh scheduled', async () => { @@ -62,3 +73,63 @@ test('stop and restart while an old refresh finishes creates only one timer', as await actions.startUpdatingData(context); // Already running: no extra fetch or timer assert.equal(timers.size, 1); }); + +function visibility(state) { + page.visibilityState = state; + page.dispatchEvent(new Event('visibilitychange')); +} + +test('returning to an old tab refreshes immediately, with a cooldown and one timer', async () => { + let now = Date.parse('2026-09-07T10:10:00Z'); + mock.method(Date, 'now', () => now); + const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true })); + await actions.startUpdatingData(context); + assert.equal(fetching.mock.callCount(), 5); + now += 60_000; + visibility('hidden'); + await setImmediate(); + assert.equal(fetching.mock.callCount(), 5); + visibility('visible'); + await setImmediate(); + assert.equal(fetching.mock.callCount(), 10); + assert.equal(timers.size, 1); + visibility('hidden'); + visibility('visible'); + await setImmediate(); + assert.equal(fetching.mock.callCount(), 10); + actions.stopUpdatingData(); + now += 60_000; + visibility('visible'); + await setImmediate(); + assert.equal(fetching.mock.callCount(), 10); + assert.equal(timers.size, 0); +}); + +test('activation does not overlap an in-flight refresh or leave a timer after stopping', async () => { + let now = Date.now(); + mock.method(Date, 'now', () => now); + let release; + let pending = new Promise(resolve => { release = resolve; }); + const fetching = mock.method(globalThis, 'fetch', async () => { await pending; return Response.json({ current: true }); }); + let refreshing = actions.startUpdatingData(context); + now += 60_000; + visibility('visible'); + assert.equal(fetching.mock.callCount(), 5); + actions.stopUpdatingData(); + release(); + await refreshing; + assert.equal(timers.size, 0); +}); + +test('activation cooldown does not suppress the scheduled top-of-hour refresh', async () => { + let now = Date.parse('2026-09-07T10:59:50Z'); + mock.method(Date, 'now', () => now); + const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true })); + await actions.startUpdatingData(context); + const [id, scheduled] = [...timers.entries()][0]; + timers.delete(id); // A real timeout is removed before its callback runs. + now += 40_000; + await scheduled.callback(); + assert.equal(fetching.mock.callCount(), 10); + assert.equal(timers.size, 1); +});