Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 64 additions & 20 deletions lib/core/runtime/VirtualList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, '}}');
Expand Down
61 changes: 47 additions & 14 deletions test/unit/virtualList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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.');

Expand All @@ -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;
Expand All @@ -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.');
Expand All @@ -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
Expand All @@ -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);

Expand Down
Loading