diff --git a/lib/core/runtime/VirtualList.js b/lib/core/runtime/VirtualList.js index b12ae3c..d0e987a 100644 --- a/lib/core/runtime/VirtualList.js +++ b/lib/core/runtime/VirtualList.js @@ -33,8 +33,12 @@ export class VirtualList extends AvenxComponent { this.templateNode = null; /** @type {string} Loop item variable name (e.g. 'item') */ this.itemVar = 'item'; - /** @type {ResizeObserver|null} Resize observer to monitor dynamic item size changes */ + /** @type {ResizeObserver|null} Resize observer to monitor dynamic item size changes and viewport resizing */ this.resizeObserver = null; + /** @type {number} Cached viewport height to prevent redundant layout loops */ + this.lastViewportHeight = 0; + /** @type {number|null} Animation frame request handle */ + this.rafId = null; this.renderer = new TemplateRenderer(); this.patcher = new DomPatcher(); @@ -76,9 +80,61 @@ export class VirtualList extends AvenxComponent { this.$refs.spacer.innerHTML = ''; } this.measuredHeights = []; + this.initResizeObserver(); this.layout(); } + /** + * Initializes unified ResizeObserver for viewport container and row items. + */ + initResizeObserver() { + if (this.resizeObserver || typeof ResizeObserver === 'undefined') return; + + this.resizeObserver = new ResizeObserver((entries) => { + let layoutNeeded = false; + for (const entry of entries) { + if (this.$refs.viewport && entry.target === this.$refs.viewport) { + const newHeight = entry.target.clientHeight; + if (newHeight !== this.lastViewportHeight) { + this.lastViewportHeight = newHeight; + layoutNeeded = true; + } + } else if (entry.target && entry.target.hasAttribute && entry.target.hasAttribute('data-index')) { + const indexAttr = entry.target.getAttribute('data-index'); + if (indexAttr !== null) { + const idx = parseInt(indexAttr, 10); + const newHeight = entry.target.offsetHeight; + if (newHeight && newHeight !== this.measuredHeights[idx]) { + this.measuredHeights[idx] = newHeight; + layoutNeeded = true; + } + } + } + } + if (layoutNeeded) { + this.scheduleLayout(); + } + }); + + if (this.$refs.viewport) { + this.lastViewportHeight = this.$refs.viewport.clientHeight || 0; + this.resizeObserver.observe(this.$refs.viewport); + } + } + + /** + * Schedules layout recalculation on the next animation frame to prevent layout thrashing. + */ + scheduleLayout() { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + } + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + this.layout(); + }); + } + /** * Lifecycle hook called after component updates. * Re-evaluates virtualization layouts when properties (e.g. items) change. @@ -92,6 +148,10 @@ export class VirtualList extends AvenxComponent { * Clean up event listeners and observers to prevent leaks. */ onUnmount() { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } if (this.$refs.viewport) { this.$refs.viewport.removeEventListener('scroll', this.onScroll); } @@ -135,6 +195,7 @@ export class VirtualList extends AvenxComponent { if (!viewport || !spacer) return; const viewportHeight = viewport.clientHeight || 400; + this.lastViewportHeight = viewportHeight; const scrollTop = viewport.scrollTop; // 1. Calculate heights array @@ -188,25 +249,8 @@ export class VirtualList extends AvenxComponent { spacer.appendChild(itemEl); } - // 6. Lazy-initialize ResizeObserver - if (!this.resizeObserver && typeof ResizeObserver !== 'undefined') { - this.resizeObserver = new ResizeObserver((entries) => { - let sizeChanged = false; - for (const entry of entries) { - const indexAttr = entry.target.getAttribute('data-index'); - if (indexAttr === null) continue; - const idx = parseInt(indexAttr, 10); - const newHeight = entry.target.offsetHeight; - if (newHeight && newHeight !== this.measuredHeights[idx]) { - this.measuredHeights[idx] = newHeight; - sizeChanged = true; - } - } - if (sizeChanged) { - requestAnimationFrame(() => this.layout()); - } - }); - } + // 6. Ensure unified ResizeObserver is initialized + this.initResizeObserver(); // 7. Dynamic patching and recycling of row elements const templateHTML = this.templateNode.innerHTML.replace(/{%/g, '{{').replace(/%}/g, '}}'); diff --git a/test/unit/virtualList.test.js b/test/unit/virtualList.test.js index a676045..e6a581c 100644 --- a/test/unit/virtualList.test.js +++ b/test/unit/virtualList.test.js @@ -8,15 +8,21 @@ if (typeof global.ResizeObserver === 'undefined') { global.ResizeObserver = class MockResizeObserver { constructor(callback) { this.callback = callback; + this.observedTargets = new Set(); MockResizeObserver.instances.push(this); } observe(target) { - this.target = target; + this.observedTargets.add(target); + } + unobserve(target) { + this.observedTargets.delete(target); + } + disconnect() { + this.observedTargets.clear(); + this.isDisconnected = true; } - unobserve() {} - disconnect() {} trigger(entry) { - this.callback([entry]); + this.callback(Array.isArray(entry) ? entry : [entry]); } }; global.ResizeObserver.instances = []; @@ -70,16 +76,13 @@ async function runTests() { const spacer = virtualListInstance.$refs.spacer; assert.ok(spacer, 'Spacer ref should exist.'); - + const viewport = virtualListInstance.$refs.viewport; + assert.ok(viewport, 'Viewport ref should exist.'); // 2. Validate initial render with DOM footprint constraints const initialChildrenCount = spacer.childNodes.length; console.log(` Initial visible elements count: ${initialChildrenCount}`); - // Default viewportHeight falls back to 400px. - // 400px client height / 30px height = ~14 visible elements. - // Plus buffer (5 above + 5 below = 10 buffer). - // Total should be around 24-25. assert.ok(initialChildrenCount > 0, 'Should render some visible elements.'); assert.ok(initialChildrenCount < 50, 'Should render under 50 elements for DOM footprint constraints.'); @@ -92,7 +95,6 @@ async function runTests() { // 3. Test Scroll and recycled node updates console.log(' Simulating scroll to index 100...'); - const viewport = virtualListInstance.$refs.viewport; // Scroll past 100 items (100 * 30px = 3000px) viewport.scrollTop = 3000; @@ -102,8 +104,6 @@ async function runTests() { console.log(` Scrolled visible elements count: ${scrolledChildrenCount}`); assert.ok(scrolledChildrenCount < 50, 'Scrolled DOM footprint must remain under 50 elements.'); - // Index 100 items scroll, minus 5 items buffer. - // So the first rendered item should be around index 95. const firstChildIndex = parseInt(spacer.childNodes[0].getAttribute('data-index'), 10); console.log(` First visible element index after scroll: ${firstChildIndex}`); assert.ok(firstChildIndex > 90 && firstChildIndex < 100, 'First visible index should align with scroll offset and buffer.'); @@ -125,14 +125,13 @@ async function runTests() { 'Total height represented by padding + elements height should match 10,000 items * 30px.' ); - // 5. Test dynamic resizing + // 5. Test dynamic item resizing via ResizeObserver console.log(' Testing dynamic item resizing via ResizeObserver...'); if (global.ResizeObserver.instances.length > 0) { const firstRow = spacer.childNodes[0]; const observerInstance = global.ResizeObserver.instances[0]; // Simulate first row height resizing to 100px - // Mock setting offsetHeight directly if possible, or trigger manually Object.defineProperty(firstRow, 'offsetHeight', { value: 100, configurable: true @@ -153,10 +152,44 @@ async function runTests() { 300070, // original 300,000 + 70px difference (100px - 30px) 'Total spacer height should reactively update to account for resized item.' ); + + // 6. Test viewport container resizing via ResizeObserver + console.log(' Testing viewport container resizing via ResizeObserver...'); + const countBeforeViewportResize = spacer.childNodes.length; + + // Simulate viewport clientHeight changing from 400px to 800px + Object.defineProperty(viewport, 'clientHeight', { + value: 800, + configurable: true + }); + + observerInstance.trigger({ + target: viewport + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const countAfterViewportResize = spacer.childNodes.length; + console.log(` Visible elements count after viewport expansion (400px -> 800px): ${countAfterViewportResize}`); + assert.ok( + countAfterViewportResize > countBeforeViewportResize, + 'Expanding viewport height should trigger recalculation and increase visible rows count.' + ); } else { console.log(' ⚠️ ResizeObserver mock not registered or observed.'); } + // 7. Test observer cleanup on unmount + console.log(' Testing unmount cleanup...'); + const observerInstance = virtualListInstance.resizeObserver; + page.unmount(); + + assert.strictEqual(virtualListInstance.resizeObserver, null, 'resizeObserver reference should be null after unmount.'); + assert.strictEqual(virtualListInstance.rafId, null, 'rafId handle should be reset after unmount.'); + if (observerInstance) { + assert.strictEqual(observerInstance.isDisconnected, true, 'ResizeObserver should be disconnected on unmount.'); + } + // Clean up DOM document.body.removeChild(testRoot);