From 10969f587be6f5cf4f67147985899f5e07fb9991 Mon Sep 17 00:00:00 2001 From: L4XB Date: Fri, 25 Sep 2026 00:07:08 +0200 Subject: [PATCH] fix(control-pane): keep a superseded refresh failure off the board A live refresh that fails after a newer load has already replaced the board no longer describes what is shown, so it is only reported while the data it was meant to replace is still on screen. The time of that data now includes its date, for a pane left open overnight, and it is kept in its own binding instead of on the shared state object. --- scripts/lib/control-pane/ui.js | 14 ++++++++---- tests/lib/control-pane-ui.test.js | 38 ++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/scripts/lib/control-pane/ui.js b/scripts/lib/control-pane/ui.js index eb4b0ec0c..2d7db4ae2 100644 --- a/scripts/lib/control-pane/ui.js +++ b/scripts/lib/control-pane/ui.js @@ -438,9 +438,10 @@ function renderControlPaneHtml() { // The board keeps the last snapshot on screen, so a failed refresh has to // say that the data is no longer live, and since when. + let loadedAt = null; function showRefreshFailure(error) { - const since = state.loadedAt - ? ' The data below is from ' + state.loadedAt.toLocaleTimeString() + '.' + const since = loadedAt + ? ' The data below is from ' + loadedAt.toLocaleString() + '.' : ''; showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error)); } @@ -651,7 +652,7 @@ function renderControlPaneHtml() { ...action, executable: snapshot.execution.allowActions && action.executable }))); - state.loadedAt = new Date(); + loadedAt = new Date(); clearError('#app'); } @@ -692,7 +693,12 @@ function renderControlPaneHtml() { // Live board: refresh on a gentle interval; pause while a prompt/tab is hidden. setInterval(() => { if (document.hidden) return; - load().catch(showRefreshFailure); + const shownSince = loadedAt; + load().catch(error => { + // A load that finished in the meantime already replaced the data this + // refresh was for, so its failure no longer describes the board. + if (loadedAt === shownSince) showRefreshFailure(error); + }); }, 15000); load().catch(error => showError('#app', error)); diff --git a/tests/lib/control-pane-ui.test.js b/tests/lib/control-pane-ui.test.js index 747228988..6e883c3ad 100644 --- a/tests/lib/control-pane-ui.test.js +++ b/tests/lib/control-pane-ui.test.js @@ -28,7 +28,8 @@ function inlineScript(html) { // Runs the page script against a stand-in for the few browser APIs it uses: // elements looked up by selector, fetch, and a setInterval whose callback the -// test fires itself. +// test fires itself. While `hold` is set, a fetch waits in `pending` until the +// test settles it. function openPage(snapshot) { const elements = new Map(); const element = selector => { @@ -44,17 +45,23 @@ function openPage(snapshot) { } return elements.get(selector); }; - const page = { online: true, refresh: null, element }; + const page = { online: true, hold: false, pending: [], refresh: null, element }; vm.runInNewContext(inlineScript(renderControlPaneHtml()), { document: { hidden: false, querySelector: element, querySelectorAll: () => [] }, window: { location: { href: 'http://127.0.0.1:8765/' } }, URL, Intl, console, - fetch: async () => { - if (!page.online) throw new TypeError('Failed to fetch'); - return { ok: true, status: 200, statusText: 'OK', json: async () => snapshot }; - }, + fetch: () => + new Promise((resolve, reject) => { + const reply = { + succeed: () => resolve({ ok: true, status: 200, statusText: 'OK', json: async () => snapshot }), + fail: () => reject(new TypeError('Failed to fetch')) + }; + if (page.hold) page.pending.push(reply); + else if (page.online) reply.succeed(); + else reply.fail(); + }), setInterval: callback => { page.refresh = callback; } @@ -92,6 +99,7 @@ async function runTests() { const box = page.element('#app'); assert.strictEqual(box.hidden, false, 'the failure is shown'); assert.match(box.textContent, /Live refresh failed\. The data below is from /); + assert.ok(box.textContent.includes(String(new Date().getFullYear())), 'the time of the data includes its date'); assert.match(box.textContent, /Failed to fetch/); page.online = true; @@ -103,6 +111,24 @@ async function runTests() { passed++; else failed++; + if ( + await test('a refresh that fails after a newer load succeeded is not reported', async () => { + const page = openPage(snapshot); + await settle(); + + page.hold = true; + page.refresh(); + page.hold = false; + page.refresh(); + await settle(); + page.pending[0].fail(); + await settle(); + assert.strictEqual(page.element('#app').hidden, true, 'the newer data is not marked as stale'); + }) + ) + passed++; + else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }