mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-10-01 13:47:07 -05:00
Refresh site data when a stale browser tab becomes visible
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user