diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 441ba86..fee905c 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { Window } from 'happy-dom'; import { html, normalizePathIdentity, @@ -2173,6 +2174,10 @@ test('content-preserving Missing Models refresh patches the browser instead of c missingBrowserMethodsSource, 'patchMissingModelRowElement' ); + const reconcileMissingModelRows = extractMethod( + missingBrowserMethodsSource, + 'reconcileMissingModelRows' + ); const setupMissingModelsVirtualizer = extractMethod( missingBrowserMethodsSource, 'setupMissingModelsVirtualizer' @@ -2188,6 +2193,10 @@ test('content-preserving Missing Models refresh patches the browser instead of c assert.match( patchMissingModelsBrowserElement, + /reconcileMissingModelRows\(/ + ); + assert.match( + reconcileMissingModelRows, /patchMissingModelRowElement\(currentRow, nextRow\)/ ); assert.match( @@ -2200,7 +2209,7 @@ test('content-preserving Missing Models refresh patches the browser instead of c ); assert.match( setupMissingModelsVirtualizer, - /patchMissingModelRowElement\(currentRow, nextRow\)/ + /reconcileMissingModelRows\(rowsHost, nextRows\)/ ); assert.doesNotMatch( setupMissingModelsVirtualizer, @@ -2224,7 +2233,7 @@ test('content-preserving Missing Models refresh patches the browser instead of c ); assert.match( patchMissingModelsBrowserElement, - /currentList\.appendChild\(row\)/ + /currentRowsHost === currentList \? currentList : currentRowsHost/ ); assert.match( patchMissingModelsBrowserElement, @@ -2244,6 +2253,77 @@ test('content-preserving Missing Models refresh patches the browser instead of c ); }); +test('Missing Models browser reuses rows by key, slot, and index while removing stale rows', () => { + const patchMissingModelsBrowserElement = eval( + `(${extractMethod(missingBrowserMethodsSource, 'patchMissingModelsBrowserElement')})` + ); + const patchMissingModelRowElement = eval( + `(${extractMethod(missingBrowserMethodsSource, 'patchMissingModelRowElement')})` + ); + const reconcileMissingModelRows = eval( + `(${extractMethod(missingBrowserMethodsSource, 'reconcileMissingModelRows')})` + ); + const window = new Window(); + const previousDocument = globalThis.document; + const container = window.document.createElement('div'); + globalThis.document = window.document; + + try { + container.innerHTML = ` +
+
+
+
+
old-a
+
old-b
+
old-c
+
stale
+
+
+
+
+ `; + const keyRow = container.querySelector('[data-missing-key="key-a"]'); + const slotRow = container.querySelector('[data-missing-key="key-b"]'); + const indexRow = container.querySelector('[data-missing-key="key-c"]'); + const dialog = { + _wiredMissingModelRows: new Set(), + patchMissingModelRowElement, + reconcileMissingModelRows, + }; + + const patched = patchMissingModelsBrowserElement.call(dialog, container, ` +
+
+
+
+
new-a
+
new-b
+
new-c
+
new-d
+
+
+
+
+ `); + + assert.equal(patched, true); + const rows = Array.from(container.querySelectorAll('.mr-missing-list-row')); + assert.deepEqual( + rows.map(row => row.dataset.missingKey), + ['key-a', 'new-b', 'new-c', 'new-d'] + ); + assert.equal(container.querySelector('[data-missing-key="key-a"]'), keyRow); + assert.equal(container.querySelector('[data-missing-key="new-b"]'), slotRow); + assert.equal(container.querySelector('[data-missing-key="new-c"]'), indexRow); + assert.equal(keyRow.textContent, 'new-a'); + assert.equal(slotRow.textContent, 'new-b'); + assert.equal(indexRow.textContent, 'new-c'); + } finally { + globalThis.document = previousDocument; + } +}); + test('cached Missing Models refresh preserves browser geometry after linking a local match', () => { const refreshMissingModelsBrowserFromCache = eval( `(${extractMethod(missingBrowserMethodsSource, 'refreshMissingModelsBrowserFromCache')})` diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index a2ef765..acc7ffe 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -682,37 +682,6 @@ export const missingBrowserMethods = { state.start = nextStart; state.end = nextEnd; rowsHost.style.transform = `translateY(${Math.round(nextStart * rowHeight)}px)`; - const currentRows = new Map( - Array.from(rowsHost.querySelectorAll('.mr-missing-list-row')) - .map(row => [row.dataset.missingKey || '', row]) - .filter(([key]) => key) - ); - const currentRowsBySlot = new Map(); - const currentRowsByIndex = new Map(); - currentRows.forEach((row) => { - const index = row.dataset.missingIndex || ''; - if (index && !currentRowsByIndex.has(index)) currentRowsByIndex.set(index, row); - String(row.dataset.missingSlotKeys || '') - .split('|') - .filter(Boolean) - .forEach(slotKey => { - if (!currentRowsBySlot.has(slotKey)) currentRowsBySlot.set(slotKey, row); - }); - }); - const retainedRows = new Set(); - const findCurrentRow = (nextRow) => { - const byKey = currentRows.get(nextRow.dataset.missingKey || ''); - if (byKey && !retainedRows.has(byKey)) return byKey; - - const bySlot = String(nextRow.dataset.missingSlotKeys || '') - .split('|') - .map(slotKey => currentRowsBySlot.get(slotKey)) - .find(row => row && !retainedRows.has(row)); - if (bySlot) return bySlot; - - const byIndex = currentRowsByIndex.get(nextRow.dataset.missingIndex || ''); - return byIndex && !retainedRows.has(byIndex) ? byIndex : null; - }; const nextRowsTemplate = document.createElement('template'); nextRowsTemplate.innerHTML = state.models .slice(nextStart, nextEnd) @@ -727,17 +696,7 @@ export const missingBrowserMethods = { nextRowsTemplate.content.querySelectorAll('.mr-missing-list-row') ); - nextRows.forEach((nextRow) => { - const currentRow = findCurrentRow(nextRow); - const row = currentRow - ? this.patchMissingModelRowElement(currentRow, nextRow) - : nextRow; - retainedRows.add(row); - rowsHost.appendChild(row); - }); - currentRows.forEach((row) => { - if (!retainedRows.has(row)) row.remove(); - }); + this.reconcileMissingModelRows(rowsHost, nextRows); this.wireVisibleMissingModelRows(container, state.data, state.models); }; @@ -919,37 +878,7 @@ export const missingBrowserMethods = { return html; }, - patchMissingModelsBrowserElement(container, html) { - const currentBrowser = container?.querySelector?.('.mr-missing-browser'); - if (!currentBrowser || typeof document === 'undefined') return false; - - const template = document.createElement('template'); - template.innerHTML = String(html || '').trim(); - const nextBrowser = template.content.firstElementChild; - const currentList = currentBrowser.querySelector('.mr-missing-list'); - const nextList = nextBrowser?.querySelector?.('.mr-missing-list'); - if (!nextBrowser || !currentList || !nextList) return false; - - currentBrowser.style.cssText = nextBrowser.style.cssText; - const currentToolbar = currentBrowser.querySelector('.mr-missing-list-toolbar'); - const nextToolbar = nextBrowser.querySelector('.mr-missing-list-toolbar'); - if (currentToolbar && nextToolbar) { - currentToolbar.replaceWith(nextToolbar); - } - - const currentHead = currentList.querySelector('.mr-missing-list-head'); - const nextHead = nextList.querySelector('.mr-missing-list-head'); - if (currentHead && nextHead) { - currentHead.replaceWith(nextHead); - } - - const currentVirtualScroll = currentList.querySelector('.mr-missing-list-virtual-scroll'); - const nextVirtualScroll = nextList.querySelector('.mr-missing-list-virtual-scroll'); - if (currentVirtualScroll && nextVirtualScroll) { - currentVirtualScroll.style.height = nextVirtualScroll.style.height; - } - const currentRowsHost = currentVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || currentList; - const nextRowsHost = nextVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || nextList; + reconcileMissingModelRows(currentRowsHost, nextRows, appendTarget = currentRowsHost) { const currentRows = new Map( Array.from(currentRowsHost.querySelectorAll('.mr-missing-list-row')) .map(row => [row.dataset.missingKey || '', row]) @@ -981,21 +910,56 @@ export const missingBrowserMethods = { const byIndex = currentRowsByIndex.get(nextRow.dataset.missingIndex || ''); return byIndex && !retainedRows.has(byIndex) ? byIndex : null; }; - nextRowsHost.querySelectorAll('.mr-missing-list-row').forEach((nextRow) => { + + nextRows.forEach((nextRow) => { const currentRow = findCurrentRow(nextRow); const row = currentRow ? this.patchMissingModelRowElement(currentRow, nextRow) : nextRow; retainedRows.add(row); - if (currentRowsHost === currentList) { - currentList.appendChild(row); - } else { - currentRowsHost.appendChild(row); - } + appendTarget.appendChild(row); }); currentRows.forEach((row) => { if (!retainedRows.has(row)) row.remove(); }); + }, + + patchMissingModelsBrowserElement(container, html) { + const currentBrowser = container?.querySelector?.('.mr-missing-browser'); + if (!currentBrowser || typeof document === 'undefined') return false; + + const template = document.createElement('template'); + template.innerHTML = String(html || '').trim(); + const nextBrowser = template.content.firstElementChild; + const currentList = currentBrowser.querySelector('.mr-missing-list'); + const nextList = nextBrowser?.querySelector?.('.mr-missing-list'); + if (!nextBrowser || !currentList || !nextList) return false; + + currentBrowser.style.cssText = nextBrowser.style.cssText; + const currentToolbar = currentBrowser.querySelector('.mr-missing-list-toolbar'); + const nextToolbar = nextBrowser.querySelector('.mr-missing-list-toolbar'); + if (currentToolbar && nextToolbar) { + currentToolbar.replaceWith(nextToolbar); + } + + const currentHead = currentList.querySelector('.mr-missing-list-head'); + const nextHead = nextList.querySelector('.mr-missing-list-head'); + if (currentHead && nextHead) { + currentHead.replaceWith(nextHead); + } + + const currentVirtualScroll = currentList.querySelector('.mr-missing-list-virtual-scroll'); + const nextVirtualScroll = nextList.querySelector('.mr-missing-list-virtual-scroll'); + if (currentVirtualScroll && nextVirtualScroll) { + currentVirtualScroll.style.height = nextVirtualScroll.style.height; + } + const currentRowsHost = currentVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || currentList; + const nextRowsHost = nextVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || nextList; + this.reconcileMissingModelRows( + currentRowsHost, + Array.from(nextRowsHost.querySelectorAll('.mr-missing-list-row')), + currentRowsHost === currentList ? currentList : currentRowsHost + ); const currentSplitter = currentBrowser.querySelector('.mr-missing-browser-splitter'); const nextSplitter = nextBrowser.querySelector('.mr-missing-browser-splitter');