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
39 changes: 3 additions & 36 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export const Virtualize = {
init,
dispose,
scrollToBottom,
measureRenderedItems,
refreshObservers,
};

Expand Down Expand Up @@ -43,38 +42,6 @@ function getScaleFactor(spacerBefore: HTMLElement, spacerAfter: HTMLElement): nu
return (Number.isFinite(scale) && scale > 0) ? scale : 1;
}

interface MeasurementResult {
heightSum: number;
heightCount: number;
scaleFactor: number;
}

function measureRenderedItems(spacerBefore: HTMLElement, spacerAfter: HTMLElement): MeasurementResult {
const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);

// Collect <!--virtualize:item--> comment delimiters between spacers (N+1 fence for N items).
const delimiters: Comment[] = [];
for (let node: Node | null = spacerBefore.nextSibling; node && node !== spacerAfter; node = node.nextSibling) {
if (node.nodeType === Node.COMMENT_NODE && node.textContent === 'virtualize:item') {
delimiters.push(node as Comment);
}
}

if (delimiters.length < 2) {
return { heightSum: 0, heightCount: 0, scaleFactor };
}

// Measure total height from first to last delimiter. Using a single Range
// naturally includes any table border-spacing between rows.
const heightCount = delimiters.length - 1;
const range = document.createRange();
range.setStartAfter(delimiters[0]);
range.setEndBefore(delimiters[delimiters.length - 1]);
const heightSum = range.getBoundingClientRect().height / scaleFactor;

return { heightSum, heightCount, scaleFactor };
}

function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50): void {
// If the component was disposed before the JS interop call completed, the element references may be null
// or the elements may have been disconnected from the DOM. Return early to avoid errors.
Expand Down Expand Up @@ -319,7 +286,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return;
}

const { heightSum: measurementSum, heightCount: measurementCount, scaleFactor } = measureRenderedItems(spacerBefore, spacerAfter);
const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);

rangeBetweenSpacers.setStartAfter(spacerBefore);
rangeBetweenSpacers.setEndBefore(spacerAfter);
Expand All @@ -330,13 +297,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac

if (entry.target === spacerBefore) {
const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor;
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize, measurementSum, measurementCount);
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize);
} else if (entry.target === spacerAfter && spacerAfter.offsetHeight > 0) {
// When we first start up, both the "before" and "after" spacers will be visible, but it's only relevant to raise a
// single event to load the initial data. To avoid raising two events, skip the one for the "after" spacer if we know
// it's meaningless to talk about any overlap into it.
const spacerSize = (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor;
dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', spacerSize, spacerSeparation, containerSize, measurementSum, measurementCount);
dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', spacerSize, spacerSeparation, containerSize);
}
});
}
Expand Down
94 changes: 7 additions & 87 deletions src/Components/Web.JS/test/Virtualize.test.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,11 @@
import { expect, test, describe, beforeEach, afterEach } from '@jest/globals';
import { expect, test, describe } from '@jest/globals';
import { Virtualize } from '../src/Virtualize';

const { measureRenderedItems } = Virtualize;

function createDOM(heights: number[]): { before: HTMLDivElement; after: HTMLDivElement } {
const container = document.createElement('div');
document.body.appendChild(container);
const before = document.createElement('div');
const after = document.createElement('div');
container.appendChild(before);

// Build N+1 fence: comment, item, comment, item, ..., comment
for (const h of heights) {
const comment = document.createComment('virtualize:item');
container.appendChild(comment);
const item = document.createElement('div');
item.getBoundingClientRect = () => ({
height: h, width: 100, top: 0, left: 0, bottom: h, right: 100,
x: 0, y: 0, toJSON() { return this; },
});
container.appendChild(item);
}
if (heights.length > 0) {
container.appendChild(document.createComment('virtualize:item'));
}

container.appendChild(after);

// jsdom doesn't implement Range.getBoundingClientRect.
// Patch createRange to return a mock that sums item heights between start and end.
const origCreateRange = document.createRange.bind(document);
document.createRange = () => {
const range = origCreateRange();
let startNode: Node | null = null;
const origSetStartAfter = range.setStartAfter.bind(range);
const origSetEndBefore = range.setEndBefore.bind(range);
range.setStartAfter = (node: Node) => { startNode = node; origSetStartAfter(node); };
range.setEndBefore = (node: Node) => {
origSetEndBefore(node);
// Sum heights of element children between startNode and node
let totalHeight = 0;
if (startNode) {
for (let n = startNode.nextSibling; n && n !== node; n = n.nextSibling) {
if (n instanceof HTMLElement && n.getBoundingClientRect) {
totalHeight += n.getBoundingClientRect().height;
}
}
}
range.getBoundingClientRect = () => ({
height: totalHeight, width: 100, top: 0, left: 0, bottom: totalHeight, right: 100,
x: 0, y: 0, toJSON() { return this; },
} as DOMRect);
};
return range;
};

return { before, after };
}

describe('measureRenderedItems', () => {
test('returns aggregated sum and count for valid items', () => {
const { before, after } = createDOM([40, 60]);
const result = measureRenderedItems(before, after);
expect(result.heightSum).toBe(100);
expect(result.heightCount).toBe(2);
});

test('includes zero-height items in count', () => {
const { before, after } = createDOM([50, 0, 30]);
const result = measureRenderedItems(before, after);
// Total height is 80 (50+0+30), all 3 items counted
expect(result.heightSum).toBe(80);
expect(result.heightCount).toBe(3);
});

test('returns zero for empty item list', () => {
const { before, after } = createDOM([]);
const result = measureRenderedItems(before, after);
expect(result.heightSum).toBe(0);
expect(result.heightCount).toBe(0);
});

test('returns zero for single item (needs at least 2 delimiters)', () => {
// Single item has 2 delimiters which is the minimum
const { before, after } = createDOM([42]);
const result = measureRenderedItems(before, after);
expect(result.heightSum).toBe(42);
expect(result.heightCount).toBe(1);
describe('Virtualize exports', () => {
test('exports expected functions', () => {
expect(typeof Virtualize.init).toBe('function');
expect(typeof Virtualize.dispose).toBe('function');
expect(typeof Virtualize.scrollToBottom).toBe('function');
expect(typeof Virtualize.refreshObservers).toBe('function');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization;

internal interface IVirtualizeJsCallbacks
{
void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount);
void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount);
void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize);
void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize);
}
39 changes: 21 additions & 18 deletions src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I

private int _loadedItemsStartIndex;

private int _lastRenderedItemCount;
internal int _lastRenderedItemCount;

private int _lastRenderedPlaceholderCount;
internal int _lastRenderedPlaceholderCount;

private float _itemSize;

Expand Down Expand Up @@ -290,19 +290,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)

builder.OpenRegion(5);

// Render items with comment delimiters for JS height measurement (N+1 fence pattern).
foreach (var item in itemsToShow)
{
builder.AddMarkupContent(0, "<!--virtualize:item-->");
_itemTemplate(item)(builder);
_lastRenderedItemCount++;
}

if (_lastRenderedItemCount > 0)
{
builder.AddMarkupContent(1, "<!--virtualize:item-->");
}

renderIndex += _lastRenderedItemCount;

builder.CloseRegion();
Expand Down Expand Up @@ -344,21 +337,31 @@ private string GetSpacerStyle(int itemsInSpacer)
private float GetItemHeight()
=> _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize;

private bool ProcessMeasurements(float measuredItemHeightSum, int measuredItemCount)
private bool ProcessMeasurements(float spacerSeparation)
{
if (measuredItemCount <= 0)
// Accumulate item height measurements only when no placeholders are rendered,
// so spacerSeparation directly represents real item heights. This avoids a
// feedback loop: subtracting (placeholderCount * _itemSize) makes the accumulated
// measurements depend on _itemSize, which itself depends on those measurements.
// Under CSS zoom, rounding errors in that loop compound and diverge from reality.
if (_lastRenderedItemCount <= 0 || _lastRenderedPlaceholderCount > 0)
{
return false;
}

_totalMeasuredHeight += measuredItemHeightSum;
_measuredItemCount += measuredItemCount;
return true;
if (spacerSeparation > 0)
{
_totalMeasuredHeight += spacerSeparation;
_measuredItemCount += _lastRenderedItemCount;
return true;
}

return false;
}

void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
{
ProcessMeasurements(measuredItemHeightSum, measuredItemCount);
ProcessMeasurements(spacerSeparation);

CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity);

Expand All @@ -371,9 +374,9 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
}

void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
{
var hadNewMeasurements = ProcessMeasurements(measuredItemHeightSum, measuredItemCount);
var hadNewMeasurements = ProcessMeasurements(spacerSeparation);

CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementRef
}

[JSInvokable]
public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize)
{
_owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, measuredItemHeightSum, measuredItemCount);
_owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize);
}

[JSInvokable]
public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize)
{
_owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, measuredItemHeightSum, measuredItemCount);
_owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize);
}

public ValueTask ScrollToBottomAsync()
Expand Down
Loading
Loading