Write a check into the list instead of rebuilding it
A check asks each saved search in turn and writes back what it found, and the list was built again from scratch on every one of those writes. So a background check -- which has no business being visible at all beyond a new count and a new time -- replayed the whole list's entry animation, threw away the reader's scroll position, and took the pointer out of whatever row it was over, several times over. Rows are now matched by id and told what changed: the count, the name, the line underneath, nothing else. A row is only built when it is new, gone, or changes shape, and the entry animation belongs to a row that has arrived rather than to every row every time. One mid-rename or mid-confirm is left alone entirely, so a check can no longer type over the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -3279,6 +3279,11 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95);
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Rows animate in when they arrive, and only then: a row already on screen
|
||||
that a background check wrote a new count into has not arrived anywhere. */
|
||||
.saved-row.is-new {
|
||||
animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: calc(var(--i, 0) * 40ms);
|
||||
}
|
||||
|
||||
@@ -609,10 +609,19 @@ App.ui = App.ui || {};
|
||||
return strip;
|
||||
};
|
||||
|
||||
// Which of the three shapes a row is in. A row only has to be built
|
||||
// again when it changes shape; otherwise the one on screen is updated
|
||||
// where it stands (see render).
|
||||
const modeOf = function(entry) {
|
||||
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
|
||||
};
|
||||
|
||||
const buildRow = function(entry, index) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'saved-row';
|
||||
// Only a row that was not on screen a moment ago animates in.
|
||||
row.className = 'saved-row is-new';
|
||||
row.dataset.id = entry.id;
|
||||
row.dataset.mode = modeOf(entry);
|
||||
row.style.setProperty('--i', String(index));
|
||||
if (entry.unreachable) row.classList.add('is-unreachable');
|
||||
|
||||
@@ -674,6 +683,45 @@ App.ui = App.ui || {};
|
||||
tools.appendChild(rename);
|
||||
tools.appendChild(remove);
|
||||
row.appendChild(tools);
|
||||
// What updateRow writes to, so it doesn't have to search the row
|
||||
// again on every check.
|
||||
row._parts = { count: count, name: name, meta: meta, stamp: stamp,
|
||||
rename: rename, remove: remove };
|
||||
return row;
|
||||
};
|
||||
|
||||
// The same row, told what changed. A check writes a new count and a new
|
||||
// "checked" time on every saved search it asks about; rebuilding the
|
||||
// list for that would play the whole list's entry animation again,
|
||||
// drop the reader's scroll position and take the pointer out of
|
||||
// whatever it was over. A background check has no business doing any
|
||||
// of that, so nothing is replaced -- the few strings that differ are
|
||||
// written in place.
|
||||
const updateRow = function(row, entry, index) {
|
||||
const parts = row._parts;
|
||||
if (!parts) return row;
|
||||
row.classList.remove('is-new');
|
||||
row.style.setProperty('--i', String(index));
|
||||
row.classList.toggle('is-unreachable', !!entry.unreachable);
|
||||
|
||||
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||
const countText = label || '·';
|
||||
if (parts.count.textContent !== countText) parts.count.textContent = countText;
|
||||
parts.count.classList.toggle('is-quiet', !label);
|
||||
if (label) parts.count.title = `${entry.newCount} new since you last opened this`;
|
||||
else parts.count.removeAttribute('title');
|
||||
|
||||
if (parts.name.textContent !== entry.name) {
|
||||
parts.name.textContent = entry.name;
|
||||
parts.rename.title = `Rename ${entry.name}`;
|
||||
parts.rename.setAttribute('aria-label', `Rename ${entry.name}`);
|
||||
parts.remove.title = `Remove ${entry.name}`;
|
||||
parts.remove.setAttribute('aria-label', `Remove ${entry.name}`);
|
||||
}
|
||||
const meta = metaLine(entry);
|
||||
if (parts.meta.textContent !== meta) parts.meta.textContent = meta;
|
||||
const stamp = stampLine(entry);
|
||||
if (parts.stamp.textContent !== stamp) parts.stamp.textContent = stamp;
|
||||
return row;
|
||||
};
|
||||
|
||||
@@ -687,8 +735,27 @@ App.ui = App.ui || {};
|
||||
if (!list) return;
|
||||
|
||||
const entries = App.savedSearches.getAll();
|
||||
list.textContent = '';
|
||||
entries.forEach((entry, index) => list.appendChild(buildRow(entry, index)));
|
||||
const onScreen = new Map();
|
||||
Array.from(list.children).forEach((row) => onScreen.set(row.dataset.id, row));
|
||||
entries.forEach((entry, index) => {
|
||||
const had = onScreen.get(entry.id);
|
||||
onScreen.delete(entry.id);
|
||||
// A row mid-rename or mid-confirm is a different row: it has
|
||||
// its own field and buttons, so that one is built.
|
||||
let row;
|
||||
if (had && had.dataset.mode === modeOf(entry)) {
|
||||
row = updateRow(had, entry, index);
|
||||
} else {
|
||||
row = buildRow(entry, index);
|
||||
// A row that changed shape where it stood has not arrived
|
||||
// either -- it is the same row, asking something.
|
||||
if (had) row.classList.remove('is-new');
|
||||
}
|
||||
if (list.children[index] !== row) {
|
||||
list.insertBefore(row, list.children[index] || null);
|
||||
}
|
||||
});
|
||||
onScreen.forEach((row) => row.remove());
|
||||
if (empty) empty.hidden = entries.length > 0;
|
||||
|
||||
if (note) {
|
||||
|
||||
@@ -58,6 +58,20 @@ ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row'))
|
||||
stamp: row.querySelector('.saved-row-stamp').textContent
|
||||
}))"""
|
||||
|
||||
MARK_ROWS = """() => {
|
||||
// A mark on the elements themselves: a rebuilt row cannot carry it.
|
||||
const rows = Array.from(document.querySelectorAll('#saved-panel .saved-row'));
|
||||
rows.forEach((row, i) => { row.dataset.probe = `p${i}`; });
|
||||
return rows.length;
|
||||
}"""
|
||||
|
||||
PROBES = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
|
||||
probe: row.dataset.probe || '',
|
||||
count: row.querySelector('.saved-count').textContent,
|
||||
fresh: row.classList.contains('is-new'),
|
||||
stamp: row.querySelector('.saved-row-stamp').textContent
|
||||
}))"""
|
||||
|
||||
READ_BTN = """() => {
|
||||
const button = document.getElementById('saved-read-btn');
|
||||
return { disabled: button.disabled, text: button.textContent };
|
||||
@@ -378,6 +392,20 @@ def main():
|
||||
c.ok("and the button turns itself off with nothing left to clear",
|
||||
page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN)))
|
||||
|
||||
print("\na check writes into the list without rebuilding it")
|
||||
c.ok("the list is open with a row in it", page.evaluate(MARK_ROWS) == 1)
|
||||
offset["at"] = offset["at"] + 2
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(1000)
|
||||
rows = page.evaluate(PROBES)
|
||||
c.ok("the row is the same element it was before the check",
|
||||
len(rows) == 1 and rows[0]["probe"] == "p0", str(rows))
|
||||
c.ok("with the count written into it where it stood",
|
||||
rows[0]["count"] == "2", str(rows[0]["count"]))
|
||||
c.ok("and the time of the check with it",
|
||||
"checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
|
||||
c.ok("nothing animated itself back in", rows[0]["fresh"] is False, str(rows[0]))
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user