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 = `
+
+ `;
+ 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, `
+
+ `);
+
+ 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');