dashboard: atomic-swap paint for schedules + reminders (#464)
argus flagged + mara confirmed: operators see a brief "blink" on every poll cycle when refreshState fires. Root cause for the async-fetch sections (refreshReminders, refreshSchedules): the `await resp.json()` yield is a paint opportunity the browser can take BEFORE the renderer's `root.innerHTML = ''` + `root.append` land. The "loading…" placeholder (or the previous render's stale content) may briefly show through. Fix: render off-DOM into a `DocumentFragment`, then atomically swap into the live root with `replaceChildren`. The browser only sees the new content; no intermediate empty state is reachable. Added `paintAtomic(liveRoot, build)` helper near the top of the IIFE — minimal-churn pattern where each renderer's existing `root.append(...)` body carries over unchanged, just wrapped in a builder callback that receives the fragment as its `root` parameter. Applied to the async-fetch sections argus's note + mara's report specifically called out: - `refreshReminders` (both http-error + catch paths) - `renderReminders` - `refreshSchedules` (both error paths) - `renderScheduleNewForm` (carry read still happens against the live root BEFORE the swap so mid-typing values are preserved) - `renderSchedulesList` Kept scope tight to the async paths. The sync renderers (renderContainers / renderTombstones / etc.) run inside the same JS turn as refreshState's other sync work, so the browser can't paint between their clear+populate steps — no flash to fix there. If mara still sees blink on those sections after this lands, extending the pattern is a clean follow-up. docs/web-ui.md no change needed; this is an implementation detail of the existing managed-section render machinery.
This commit is contained in:
parent
a9af7adf5c
commit
d4a53d83d3
1 changed files with 68 additions and 37 deletions
|
|
@ -41,6 +41,28 @@ window.marked = marked;
|
|||
|
||||
// Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406).
|
||||
|
||||
// #464 — atomic-swap render helper. Each managed section's render
|
||||
// function used to do `root.innerHTML = ''; root.append(...);` in
|
||||
// sequence; even though both operations sit in the same JS turn,
|
||||
// operators could still see a "blink" on every poll cycle because
|
||||
// (a) on async paths the await yield gave the browser a paint
|
||||
// opportunity, and (b) complex builds with many `el()` allocations
|
||||
// can blow the browser's per-task budget enough for layout to flash
|
||||
// empty before the new children land.
|
||||
//
|
||||
// The fix: build the new content off-DOM into a `DocumentFragment`,
|
||||
// then move it into the live root in a single `replaceChildren`
|
||||
// call. The browser never sees an intermediate empty state. Builder
|
||||
// callbacks receive the fragment as their `root` argument, so each
|
||||
// renderer's existing `root.append(...)` code carries over with
|
||||
// zero internal changes. Early-return inside the builder is fine —
|
||||
// the commit still happens with whatever the builder appended.
|
||||
function paintAtomic(liveRoot, build) {
|
||||
const buf = document.createDocumentFragment();
|
||||
build(buf);
|
||||
liveRoot.replaceChildren(buf);
|
||||
}
|
||||
|
||||
// Side panel singleton (Panel) moved to ./common.js (#406).
|
||||
|
||||
// Path linkification + file-preview side panel (openFilePanel,
|
||||
|
|
@ -1827,30 +1849,32 @@ window.marked = marked;
|
|||
// agents themselves and the operator already sees them next time
|
||||
// they interact with the page.
|
||||
async function refreshReminders() {
|
||||
const root = $('reminders-section');
|
||||
if (!root) return;
|
||||
const liveRoot = $('reminders-section');
|
||||
if (!liveRoot) return;
|
||||
try {
|
||||
const resp = await fetch('/api/reminders');
|
||||
if (!resp.ok) {
|
||||
root.innerHTML = '';
|
||||
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rows = await resp.json();
|
||||
renderReminders(rows);
|
||||
} catch (err) {
|
||||
root.innerHTML = '';
|
||||
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
|
||||
});
|
||||
}
|
||||
}
|
||||
function renderReminders(rows) {
|
||||
const root = $('reminders-section');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
|
||||
return;
|
||||
}
|
||||
const liveRoot = $('reminders-section');
|
||||
if (!liveRoot) return;
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
if (!rows.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'reminders' });
|
||||
for (const r of rows) {
|
||||
const failed = (r.attempt_count || 0) > 0;
|
||||
|
|
@ -1910,6 +1934,7 @@ window.marked = marked;
|
|||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}); // paintAtomic
|
||||
}
|
||||
function fmtDuration(secs) {
|
||||
if (secs < 60) return secs + 's';
|
||||
|
|
@ -1939,14 +1964,16 @@ window.marked = marked;
|
|||
try {
|
||||
const resp = await fetch('/api/schedules');
|
||||
if (!resp.ok) {
|
||||
listRoot.innerHTML = '';
|
||||
listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||||
paintAtomic(listRoot, (root) => {
|
||||
root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||||
});
|
||||
return;
|
||||
}
|
||||
schedulesState = await resp.json();
|
||||
} catch (err) {
|
||||
listRoot.innerHTML = '';
|
||||
listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||||
paintAtomic(listRoot, (root) => {
|
||||
root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderScheduleNewForm();
|
||||
|
|
@ -2063,13 +2090,15 @@ window.marked = marked;
|
|||
}
|
||||
|
||||
function renderScheduleNewForm() {
|
||||
const root = $('schedule-new-section');
|
||||
if (!root) return;
|
||||
const liveRoot = $('schedule-new-section');
|
||||
if (!liveRoot) return;
|
||||
// Preserve any field the operator was mid-typing in by reading
|
||||
// current values before we re-render. Only `body` + `description`
|
||||
// are big enough to feel — the others are toggle/datetime/number.
|
||||
const carry = readScheduleFormCarry(root);
|
||||
root.innerHTML = '';
|
||||
// Read from the live root BEFORE the swap so we see current
|
||||
// values (the fragment we build into is fresh).
|
||||
const carry = readScheduleFormCarry(liveRoot);
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
|
||||
// Targets multi-select pulls from the live containers list. The
|
||||
// backend accepts any string (forward-compat for richer recipients),
|
||||
|
|
@ -2165,6 +2194,7 @@ window.marked = marked;
|
|||
form_.append(actions);
|
||||
|
||||
root.append(form_);
|
||||
}); // paintAtomic
|
||||
}
|
||||
function readScheduleFormCarry(root) {
|
||||
return {
|
||||
|
|
@ -2249,23 +2279,24 @@ window.marked = marked;
|
|||
}
|
||||
}
|
||||
function renderSchedulesList() {
|
||||
const root = $('schedules-section');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
if (!schedulesState.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'schedules' });
|
||||
// Active schedules first (still firing), then cancelled tail.
|
||||
const sorted = schedulesState.slice().sort((a, b) => {
|
||||
const aDone = a.cancelled_at_unix ? 1 : 0;
|
||||
const bDone = b.cancelled_at_unix ? 1 : 0;
|
||||
if (aDone !== bDone) return aDone - bDone;
|
||||
return a.next_fire_at_unix - b.next_fire_at_unix;
|
||||
const liveRoot = $('schedules-section');
|
||||
if (!liveRoot) return;
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
if (!schedulesState.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'schedules' });
|
||||
// Active schedules first (still firing), then cancelled tail.
|
||||
const sorted = schedulesState.slice().sort((a, b) => {
|
||||
const aDone = a.cancelled_at_unix ? 1 : 0;
|
||||
const bDone = b.cancelled_at_unix ? 1 : 0;
|
||||
if (aDone !== bDone) return aDone - bDone;
|
||||
return a.next_fire_at_unix - b.next_fire_at_unix;
|
||||
});
|
||||
for (const s of sorted) ul.append(renderScheduleCard(s));
|
||||
root.append(ul);
|
||||
});
|
||||
for (const s of sorted) ul.append(renderScheduleCard(s));
|
||||
root.append(ul);
|
||||
}
|
||||
function renderScheduleCard(s) {
|
||||
const cancelled = !!s.cancelled_at_unix;
|
||||
|
|
|
|||
Loading…
Reference in a new issue