From 9ea3510a97589bcdaf0a4d43b863d4e9db4537b2 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 06:11:30 -0700 Subject: [PATCH] perf(images): repaint one card on a favorite toggle instead of the collection stream.updateItem tells onChange which item it patched, and the Images page then skips the full layout pass (computed style, grid metrics and layout over every loaded item) because no card changed size or place. --- tests/js/workspace-images-state.test.js | 6 +++++- web/js/workspace/images-state.mjs | 3 ++- web/js/workspace/images.mjs | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index e30303f..efe3309 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -277,12 +277,16 @@ test('updating one item replaces it immutably and later pages append after it', const { createImageStream, imageKey } = await source; const pages = [page([{...image('a'), favorite:false}, {...image('b'), favorite:true}], 3, 'next'), page([image('c')], 3)]; const states = []; - const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:s => states.push(s), batchSize:2}); + const hints = []; + const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:(s, hint) => { states.push(s); hints.push(hint); }, batchSize:2}); await stream.reset({}); const before = stream.state, notified = states.length; + // Page loads carry no hint; a patch says so, so the page can repaint one card instead of laying out the collection. + assert.ok(hints.every(hint => hint === undefined)); assert.equal(stream.updateItem('0:a', {favorite:true}), true); const after = stream.state; assert.equal(states.length, notified + 1); assert.equal(states.at(-1), after); + assert.deepEqual(hints.at(-1), {patch:'0:a'}); assert.notEqual(after.items, before.items); assert.ok(Object.isFrozen(after.items)); assert.ok(Object.isFrozen(after.items[0])); assert.equal(after.items[0].favorite, true); assert.equal(before.items[0].favorite, false); assert.equal(after.items[1], before.items[1]); diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index a0faa64..071ba63 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -165,7 +165,8 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re const next = withItem(state, key, patch); if (disposed || next === state) return false; state = next; - onChange(state); + // The hint names the one patched item, so a page can repaint it without laying out the collection again. + onChange(state, {patch:key}); return true; }, dispose() { diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 14f3e9e..1900f1d 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -435,9 +435,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // The concept's end of the collection (app.js updateImageContinuation). return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`; } - function renderState(next) { + function renderState(next, {patch} = {}) { const previous = state; - const changed = state?.items !== next.items; + // A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card. + const changed = !patch && state?.items !== next.items; if (changed) { if (!next.items.length) itemIndexes.clear(); for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);