diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs index d4e8671..3f67e7b 100644 --- a/tests/test_dom_patchers.mjs +++ b/tests/test_dom_patchers.mjs @@ -3,6 +3,11 @@ import path from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; import { Window } from 'happy-dom'; +import { + bindEventOnce, + setTextIfChanged, + syncElementAttributes, +} from '../web/resolver/utils/dom_patch_utils.js'; const projectRoot = path.resolve(import.meta.dirname, '..'); const resolveDownloadMethodsSource = fs.readFileSync( @@ -59,6 +64,31 @@ const patchQueuedSelections = eval(`(${extractMethod(queueMethodsSource, 'patchQ const patchDownloadsPanelElement = eval(`(${extractMethod(queueMethodsSource, 'patchDownloadsPanelElement')})`); const patchLoadedModelsProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'patchLoadedModelsProgress')})`); +test('shared DOM helpers synchronize attributes, text, and one-time listeners', () => { + const window = new Window(); + const current = window.document.createElement('button'); + const next = window.document.createElement('button'); + current.setAttribute('class', 'old'); + current.setAttribute('data-stale', '1'); + next.setAttribute('class', 'new'); + next.setAttribute('data-value', '2'); + + syncElementAttributes(current, next); + assert.equal(current.getAttribute('class'), 'new'); + assert.equal(current.hasAttribute('data-stale'), false); + assert.equal(current.getAttribute('data-value'), '2'); + + assert.equal(setTextIfChanged(current, 'Ready'), true); + assert.equal(setTextIfChanged(current, 'Ready'), false); + + let clicks = 0; + const handler = () => { clicks += 1; }; + assert.equal(bindEventOnce(current, 'click', handler, 'ready'), true); + assert.equal(bindEventOnce(current, 'click', handler, 'ready'), false); + current.click(); + assert.equal(clicks, 1); +}); + function createDialog(window) { return { syncSearchElementAttributes, diff --git a/web/resolver/utils/dom_patch_utils.js b/web/resolver/utils/dom_patch_utils.js new file mode 100644 index 0000000..3c5a348 --- /dev/null +++ b/web/resolver/utils/dom_patch_utils.js @@ -0,0 +1,44 @@ +const boundEventKeys = new WeakMap(); + +export function syncElementAttributes(current, next) { + if (!current || !next) return current; + + const nextAttributeNames = new Set( + Array.from(next.attributes || [], attribute => attribute.name) + ); + for (const attribute of Array.from(current.attributes || [])) { + if (!nextAttributeNames.has(attribute.name)) { + current.removeAttribute(attribute.name); + } + } + for (const attribute of Array.from(next.attributes || [])) { + if (current.getAttribute(attribute.name) !== attribute.value) { + current.setAttribute(attribute.name, attribute.value); + } + } + return current; +} + +export function setTextIfChanged(element, value) { + if (!element) return false; + const nextText = String(value ?? ''); + if (element.textContent === nextText) return false; + element.textContent = nextText; + return true; +} + +export function bindEventOnce(element, eventName, handler, key = eventName) { + if (!element?.addEventListener || !eventName || typeof handler !== 'function') return false; + + let elementKeys = boundEventKeys.get(element); + if (!elementKeys) { + elementKeys = new Set(); + boundEventKeys.set(element, elementKeys); + } + + const bindingKey = `${eventName}:${String(key)}`; + if (elementKeys.has(bindingKey)) return false; + elementKeys.add(bindingKey); + element.addEventListener(eventName, handler); + return true; +}