refactor missing model row reconciliation

This commit is contained in:
Dariusz L
2026-08-05 15:05:51 +02:00
parent dc0524b72c
commit f1cdfcdbd6
2 changed files with 125 additions and 81 deletions
+82 -2
View File
@@ -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 = `
<div class="mr-missing-browser">
<div class="mr-missing-list">
<div class="mr-missing-list-virtual-scroll">
<div class="mr-missing-list-virtual-rows">
<div class="mr-missing-list-row" data-missing-key="key-a" data-missing-index="0" data-missing-slot-keys="slot-a">old-a</div>
<div class="mr-missing-list-row" data-missing-key="key-b" data-missing-index="1" data-missing-slot-keys="slot-b">old-b</div>
<div class="mr-missing-list-row" data-missing-key="key-c" data-missing-index="2" data-missing-slot-keys="slot-c">old-c</div>
<div class="mr-missing-list-row" data-missing-key="stale" data-missing-index="3" data-missing-slot-keys="slot-stale">stale</div>
</div>
</div>
</div>
</div>
`;
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, `
<div class="mr-missing-browser">
<div class="mr-missing-list">
<div class="mr-missing-list-virtual-scroll">
<div class="mr-missing-list-virtual-rows">
<div class="mr-missing-list-row" data-missing-key="key-a" data-missing-index="10" data-missing-slot-keys="slot-new-a">new-a</div>
<div class="mr-missing-list-row" data-missing-key="new-b" data-missing-index="11" data-missing-slot-keys="slot-b">new-b</div>
<div class="mr-missing-list-row" data-missing-key="new-c" data-missing-index="2" data-missing-slot-keys="slot-new-c">new-c</div>
<div class="mr-missing-list-row" data-missing-key="new-d" data-missing-index="12" data-missing-slot-keys="slot-new-d">new-d</div>
</div>
</div>
</div>
</div>
`);
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')})`
+43 -79
View File
@@ -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');