Skip to content

Commit b49f88a

Browse files
fix(live): stopped page shows ONLY paused (no data flash); active goes straight to work
Root cause: DOMContentLoaded painted the dataset (rehydrateFromCache rows, seedLiveStateFromRest rows) before resolving broadcast/run state. The paused overlay is opaque + full-screen but only went up AFTER the async broadcast check, so a STOPPED page flashed the last dataset and then covered it. With broadcast left on after a run finished, live-state still returns the last run's results, so seed/rehydrate flashed a stale snapshot behind the overlay. Fix: resolve broadcast/run state BEFORE any paint, and gate every paint path on there being an active run to show. - DOMContentLoaded: await checkBroadcastState() first; all paint paths run only when broadcastLive. Stopped -> overlay only, nothing painted. Active -> paint + show work (overlay stays gated, never flashes). - rehydrateFromCache: repaint cached rows only when the cached snapshot was an active run (running/paused or a live batchId). - seedLiveStateFromRest: paint rows only when the seeded status is active. Adds test/live-load-gating.test.js (12 assertions, real extracted functions; fails 3/12 without the gates).
1 parent 5772872 commit b49f88a

3 files changed

Lines changed: 242 additions & 18 deletions

File tree

live.html

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -924,30 +924,37 @@ <h1 class="live-page-title">
924924
const urlEl = document.getElementById('mobileGateUrl');
925925
if (urlEl) urlEl.textContent = location.host + '/live';
926926
applyThemeIcons();
927-
// Paint cached snapshot first so a refresh shows data instantly,
928-
// then resolve broadcast state and reconcile with server.
929-
rehydrateFromCache();
927+
// Wire the table controls up-front — cheap, and needed whenever data later
928+
// appears (including after a resume flips broadcast on).
930929
const _liveResultsPrev = document.getElementById('liveResultsPrev');
931930
const _liveResultsNext = document.getElementById('liveResultsNext');
932931
if (_liveResultsPrev) _liveResultsPrev.addEventListener('click', () => liveGoPage(-1));
933932
if (_liveResultsNext) _liveResultsNext.addEventListener('click', () => liveGoPage(1));
934933
const _liveSearchInput = document.getElementById('liveSearchInput');
935934
if (_liveSearchInput) _liveSearchInput.addEventListener('input', (e) => setLiveSearch(e.target.value));
936-
renderTable();
937-
renderLiveStats();
938-
// Resolve broadcast state BEFORE loading the current batch — otherwise
939-
// loadCurrentBatch races and may un-pause the overlay using the default
940-
// (broadcastLive=false) before we know the real state.
935+
// Resolve broadcast/run state BEFORE painting ANY data. When stopped
936+
// (broadcast off) checkBroadcastState raises the opaque paused overlay
937+
// immediately and we deliberately skip every paint path below — the page
938+
// shows ONLY the overlay, never a flash of the last dataset behind it.
939+
// When live we paint the cached snapshot, seed the real data, and show the
940+
// work; the overlay never appears for an active run because
941+
// applyPauseFromState stays gated until the authoritative status lands.
941942
await checkBroadcastState();
942-
// Seed full live state + results via REST so refresh paints the same
943-
// dashboard the operator sees, even before the SSE init message lands.
944-
if (_broadcastLive) await seedLiveStateFromRest();
945-
// The authoritative REST round-trip is done (or broadcast is off). Even if
946-
// it carried no state (genuinely no active run), let the final reconcile
943+
if (_broadcastLive) {
944+
// Cached snapshot first for an instant refresh, then authoritative REST.
945+
rehydrateFromCache();
946+
renderTable();
947+
renderLiveStats();
948+
// Seeds full live state + results so refresh paints the same dashboard
949+
// the operator sees, even before the SSE init message lands.
950+
await seedLiveStateFromRest();
951+
loadCurrentBatch();
952+
loadHeaderStats();
953+
}
954+
// The authoritative round-trip is done (or broadcast is off). Even if it
955+
// carried no state (genuinely no active run), let the final reconcile
947956
// below decide the overlay rather than leaving it pending forever.
948957
_liveStateResolved = true;
949-
loadCurrentBatch();
950-
loadHeaderStats();
951958
applyPauseFromState();
952959
setInterval(checkBroadcastState, 30000);
953960
document.addEventListener('visibilitychange', () => {
@@ -1021,6 +1028,14 @@ <h1 class="live-page-title">
10211028
const j = await r.json();
10221029
if (!j || !j.broadcastLive) return;
10231030
if (j.state) mergeLiveState(j.state);
1031+
// Only paint a dataset when the run is actually ACTIVE. With broadcast
1032+
// left on after a run completes, live-state still returns the LAST run's
1033+
// results — painting them here flashes a stale snapshot behind the
1034+
// "paused" overlay. For idle/done, applyPauseFromState shows the overlay
1035+
// and SSE repaints if a new run starts.
1036+
const _st = j.state && j.state.status;
1037+
const _active = _st === 'running' || _st === 'paused' || j.activeBatchId != null;
1038+
if (!_active) return;
10241039
// Pin the active batch id BEFORE upserting rows so subsequent SSE
10251040
// events (batch:start in particular) don't see `_cb.batchId == null`
10261041
// alongside painted rows and treat the seeded data as stale.
@@ -1939,13 +1954,20 @@ <h1 class="live-page-title">
19391954
// state and discard rows below when they're from a previous run.
19401955
if (snap.activeRunNumber != null) _prevActiveRunNumber = snap.activeRunNumber;
19411956
if (snap.state && typeof snap.state === 'object') _liveState = snap.state;
1942-
if (Array.isArray(snap.results) && snap.results.length && resultsArr.length === 0) {
1957+
// Only repaint cached rows when the snapshot captured an ACTIVE run.
1958+
// A stopped/idle cache must not flash last-run rows: applyPauseFromState
1959+
// wins for idle/done, and SSE/seed repaints when a run is (re)started.
1960+
// Without this guard, a refresh of a just-finished run flashes the old
1961+
// table for a beat before the paused overlay covers it.
1962+
const _cs = snap.state && snap.state.status;
1963+
const _cachedActive = _cs === 'running' || _cs === 'paused' || !!(snap.cb && snap.cb.batchId);
1964+
if (_cachedActive && Array.isArray(snap.results) && snap.results.length && resultsArr.length === 0) {
19431965
for (const r of snap.results) {
19441966
resultsArr.push(r);
19451967
addSingleRow(r);
19461968
}
19471969
}
1948-
if (snap.cb && typeof snap.cb === 'object') {
1970+
if (_cachedActive && snap.cb && typeof snap.cb === 'object') {
19491971
// Restore counters so the bar paints immediately
19501972
Object.assign(_cb, snap.cb);
19511973
cbRender();

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

test/live-load-gating.test.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* /live load-sequence gating — "stopped flashes data then paused" regression
3+
*
4+
* Bug: on load, /live painted the dataset (rehydrateFromCache → table rows, and
5+
* seedLiveStateFromRest → rows) BEFORE/regardless of resolving the run state.
6+
* The paused overlay is opaque + full-screen but only went up AFTER the async
7+
* broadcast check, so a STOPPED page showed the last dataset for a beat and then
8+
* covered it: "first loading the data and showing paused." A second leak: with
9+
* broadcast left on after a run finished, live-state still returns the last
10+
* run's results, so seed/rehydrate flashed a stale snapshot behind the overlay.
11+
*
12+
* Fix: gate every paint path on there being an ACTIVE run to show —
13+
* - rehydrateFromCache repaints cached rows ONLY when the cached snapshot was
14+
* an active run (status running/paused or a live batchId).
15+
* - seedLiveStateFromRest paints rows ONLY when the seeded status is active.
16+
* - (DOMContentLoaded resolves broadcast before any paint — covered by the
17+
* pause-overlay suite; this suite locks the two paint-gate functions.)
18+
*
19+
* This runs the REAL rehydrateFromCache + seedLiveStateFromRest extracted from
20+
* live.html against a fake localStorage / fetch / DOM and counts paints.
21+
*
22+
* Run: node test/live-load-gating.test.js
23+
*/
24+
25+
import { readFileSync } from 'node:fs';
26+
import { fileURLToPath } from 'node:url';
27+
import { dirname, join } from 'node:path';
28+
import vm from 'node:vm';
29+
30+
const out = { pass: 0, fail: 0, errors: [] };
31+
function ok(cond, name) {
32+
if (cond) { out.pass++; console.log(` PASS ${name}`); }
33+
else { out.fail++; out.errors.push(name); console.log(` FAIL ${name}`); }
34+
}
35+
36+
const __dirname = dirname(fileURLToPath(import.meta.url));
37+
const html = readFileSync(join(__dirname, '..', 'live.html'), 'utf8');
38+
39+
function extractFn(src, name) {
40+
const m = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`).exec(src);
41+
if (!m) throw new Error(`function ${name} not found in live.html`);
42+
let depth = 0, started = false, j = m.index;
43+
for (; j < src.length; j++) {
44+
const c = src[j];
45+
if (c === '{') { depth++; started = true; }
46+
else if (c === '}') { depth--; if (started && depth === 0) { j++; break; } }
47+
}
48+
return src.slice(m.index, j);
49+
}
50+
51+
const extracted = ['rehydrateFromCache', 'seedLiveStateFromRest']
52+
.map(n => extractFn(html, n)).join('\n\n');
53+
54+
// ─── Fake environment ────────────────────────────────────────────────────────
55+
let addSingleRowCount = 0; // rehydrate row paints
56+
let upsertCount = 0; // seed row paints
57+
58+
function makeSandbox() {
59+
addSingleRowCount = 0;
60+
upsertCount = 0;
61+
const sandbox = {
62+
// tunables / state the functions close over
63+
LIVE_CACHE_KEY: 'live:snapshot:v1',
64+
LIVE_CACHE_TTL_MS: 60 * 60 * 1000,
65+
_prevActiveRunNumber: null,
66+
_liveState: {},
67+
resultsArr: [],
68+
_cb: { batchId: null, snapshotSize: 0, tested: 0 },
69+
Date, Object, Number, Array, console,
70+
// localStorage + fetch injected per-case below
71+
localStorage: null,
72+
fetch: null,
73+
// painted-row spies
74+
addSingleRow: () => { addSingleRowCount++; },
75+
upsert: () => { upsertCount++; },
76+
// harmless stubs
77+
cbRender: () => {},
78+
applyHeaderStatsFromState: () => {},
79+
renderLiveStats: () => {},
80+
mergeLiveState: (s) => { sandbox._liveState = { ...sandbox._liveState, ...(s || {}) }; },
81+
};
82+
vm.createContext(sandbox);
83+
vm.runInContext(extracted, sandbox);
84+
return sandbox;
85+
}
86+
87+
function fakeLocalStorage(snapObj) {
88+
const store = snapObj == null ? {} : { 'live:snapshot:v1': JSON.stringify(snapObj) };
89+
return {
90+
getItem: (k) => (k in store ? store[k] : null),
91+
setItem: (k, v) => { store[k] = v; },
92+
removeItem: (k) => { delete store[k]; },
93+
};
94+
}
95+
96+
function fakeFetch(jsonBody, okFlag = true) {
97+
return () => Promise.resolve({ ok: okFlag, json: () => Promise.resolve(jsonBody) });
98+
}
99+
100+
const NOW = Date.now();
101+
const activeSnap = (status) => ({
102+
ts: NOW, broadcastLive: true,
103+
state: { status }, cb: { batchId: status ? 7 : null },
104+
results: [{ address: 'sent1a' }, { address: 'sent1b' }],
105+
});
106+
const idleSnap = {
107+
ts: NOW, broadcastLive: true,
108+
state: { status: 'done' }, cb: { batchId: null },
109+
results: [{ address: 'sent1a' }, { address: 'sent1b' }, { address: 'sent1c' }],
110+
};
111+
112+
console.log('\n/live load-sequence gating — stopped-flash regression\n');
113+
114+
// ─── 1. rehydrateFromCache: active cache repaints, idle cache does NOT ────────
115+
console.log('[1] rehydrateFromCache gates row paint on a cached ACTIVE run');
116+
{
117+
const sb = makeSandbox();
118+
sb.localStorage = fakeLocalStorage(activeSnap('running'));
119+
vm.runInContext('rehydrateFromCache()', sb);
120+
ok(addSingleRowCount === 2, `running cache repaints its 2 rows (got ${addSingleRowCount})`);
121+
}
122+
{
123+
const sb = makeSandbox();
124+
sb.localStorage = fakeLocalStorage(idleSnap);
125+
vm.runInContext('rehydrateFromCache()', sb);
126+
ok(addSingleRowCount === 0, `idle/done cache paints NOTHING (got ${addSingleRowCount})`);
127+
}
128+
{
129+
// A cache with no status but a live batchId is still "active" (mid-run).
130+
const sb = makeSandbox();
131+
sb.localStorage = fakeLocalStorage({
132+
ts: NOW, broadcastLive: true, state: {}, cb: { batchId: 42 },
133+
results: [{ address: 'sent1x' }],
134+
});
135+
vm.runInContext('rehydrateFromCache()', sb);
136+
ok(addSingleRowCount === 1, `cache with a live batchId repaints (got ${addSingleRowCount})`);
137+
}
138+
{
139+
// No cache at all → no paint, no throw.
140+
const sb = makeSandbox();
141+
sb.localStorage = fakeLocalStorage(null);
142+
vm.runInContext('rehydrateFromCache()', sb);
143+
ok(addSingleRowCount === 0, 'absent cache paints nothing');
144+
}
145+
{
146+
// Expired cache (older than TTL) → discarded, no paint.
147+
const sb = makeSandbox();
148+
const stale = activeSnap('running'); stale.ts = NOW - (2 * 60 * 60 * 1000);
149+
sb.localStorage = fakeLocalStorage(stale);
150+
vm.runInContext('rehydrateFromCache()', sb);
151+
ok(addSingleRowCount === 0, 'expired cache (past TTL) paints nothing');
152+
}
153+
154+
// ─── 2. seedLiveStateFromRest: active state paints, idle/done does NOT ────────
155+
console.log('[2] seedLiveStateFromRest gates row paint on an ACTIVE run status');
156+
async function seedCase(body) {
157+
const sb = makeSandbox();
158+
sb.fetch = fakeFetch(body);
159+
await vm.runInContext('seedLiveStateFromRest()', sb);
160+
return sb;
161+
}
162+
{
163+
const sb = await seedCase({
164+
broadcastLive: true, state: { status: 'running' }, activeBatchId: 9, snapshotSize: 100,
165+
results: [{ address: 'sent1a' }, { address: 'sent1b' }],
166+
});
167+
ok(upsertCount === 2, `running state paints its 2 rows (got ${upsertCount})`);
168+
ok(sb._cb.batchId === 9, 'running state pins the active batch id');
169+
}
170+
{
171+
await seedCase({
172+
broadcastLive: true, state: { status: 'done' }, activeBatchId: null,
173+
results: [{ address: 'sent1a' }, { address: 'sent1b' }, { address: 'sent1c' }],
174+
});
175+
ok(upsertCount === 0, `done state (broadcast still on) paints NOTHING (got ${upsertCount})`);
176+
}
177+
{
178+
await seedCase({
179+
broadcastLive: true, state: { status: 'idle' }, activeBatchId: null,
180+
results: [{ address: 'sent1a' }],
181+
});
182+
ok(upsertCount === 0, 'idle state paints nothing');
183+
}
184+
{
185+
// paused_balance / paused_internet mid-run: a live batchId keeps it active.
186+
const sb = await seedCase({
187+
broadcastLive: true, state: { status: 'paused_balance' }, activeBatchId: 5,
188+
results: [{ address: 'sent1a' }],
189+
});
190+
ok(upsertCount === 1, `paused_balance + live batchId still paints (got ${upsertCount})`);
191+
ok(sb._cb.batchId === 5, 'paused_balance mid-run pins the batch id');
192+
}
193+
{
194+
// broadcast off → seed bails before any paint (overlay path owns this).
195+
await seedCase({ broadcastLive: false, state: {}, results: [] });
196+
ok(upsertCount === 0, 'broadcast off → seed paints nothing');
197+
}
198+
199+
console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`);
200+
if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`);
201+
console.log('='.repeat(60));
202+
process.exit(out.fail ? 1 : 0);

0 commit comments

Comments
 (0)