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);