From 1c0fa05c9f30c5ea0c74e5b5b2da7f1b5d14b191 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 23 Mar 2026 11:05:13 +0100 Subject: [PATCH 1/9] Feedback: Export larger scripts to helpers. --- .../test/E2ETest/Tests/VirtualizationTest.cs | 429 ++++++++++-------- 1 file changed, 230 insertions(+), 199 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 44220f466051..d43d3e989c85 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1224,7 +1224,6 @@ public virtual void VariableHeightAsync_CanScrollWithoutFlashing(int transformSc Browser.MountTestComponent(); var container = Browser.Exists(By.Id("async-variable-container")); - var js = (IJavaScriptExecutor)Browser; Browser.Exists(By.Id("toggle-autoload")).Click(); if (transformScalePercent != 100) @@ -1248,141 +1247,8 @@ public virtual void VariableHeightAsync_CanScrollWithoutFlashing(int transformSc Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); - const string detectFlashingScript = @" - var done = arguments[0]; - (async () => { - const SCROLL_INCREMENT = 100; - const MAX_ITERATIONS = 300; - const VISIBILITY_TOLERANCE = 2; - const container = document.querySelector('#async-variable-container'); - - if (!container) { - done({ success: false, error: 'Container not found' }); - return; - } - - const getTopVisibleItemIndex = () => { - const items = container.querySelectorAll('.async-variable-item'); - if (items.length === 0) return null; - const containerRect = container.getBoundingClientRect(); - for (const item of items) { - const itemRect = item.getBoundingClientRect(); - if (itemRect.bottom > containerRect.top + VISIBILITY_TOLERANCE && - itemRect.top < containerRect.bottom - VISIBILITY_TOLERANCE) { - const match = item.id.match(/async-variable-item-(\d+)/); - return match ? parseInt(match[1], 10) : null; - } - } - return null; - }; - - const getMaxIndex = () => { - const items = container.querySelectorAll('.async-variable-item'); - let maxIdx = -1; - for (const item of items) { - const match = item.id.match(/async-variable-item-(\d+)/); - if (match) maxIdx = Math.max(maxIdx, parseInt(match[1], 10)); - } - return maxIdx; - }; - - const getMinIndex = () => { - const items = container.querySelectorAll('.async-variable-item'); - let minIdx = Infinity; - for (const item of items) { - const match = item.id.match(/async-variable-item-(\d+)/); - if (match) minIdx = Math.min(minIdx, parseInt(match[1], 10)); - } - return minIdx === Infinity ? -1 : minIdx; - }; - - const waitForSettledFrame = () => { - return new Promise(resolve => { - requestAnimationFrame(() => { - const target = container.querySelector('.async-variable-item') || container; - const io = new IntersectionObserver(() => { - io.disconnect(); - resolve(); - }, { root: container, threshold: [0, 1] }); - io.observe(target); - }); - }); - }; - - const getSnapshot = () => { - const spacerBefore = container.querySelector('[aria-hidden=""true""]:first-child'); - const spacerAfter = container.querySelector('[aria-hidden=""true""]:last-child'); - return { - st: container.scrollTop, - sh: container.scrollHeight, - min: getMinIndex(), - max: getMaxIndex(), - cnt: container.querySelectorAll('.async-variable-item').length, - sbH: spacerBefore ? spacerBefore.style.height : '?', - saH: spacerAfter ? spacerAfter.style.height : '?', - }; - }; - - let previousTopItemIndex = null; - let maxIndexSeen = -1; - // Keep last N snapshots as ring buffer for context - const history = []; - const HISTORY_SIZE = 10; - - for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { - const beforeScroll = getSnapshot(); - beforeScroll.phase = 'pre'; - beforeScroll.iter = iteration; - - const previousScrollTop = container.scrollTop; - container.scrollTop += SCROLL_INCREMENT; - - if (container.scrollTop === previousScrollTop) { - break; - } - - const afterAssign = { st: container.scrollTop, phase: 'post-assign', iter: iteration }; - - await waitForSettledFrame(); - - const afterSettle = getSnapshot(); - afterSettle.phase = 'settled'; - afterSettle.iter = iteration; - - const currentTopItemIndex = getTopVisibleItemIndex(); - afterSettle.topIdx = currentTopItemIndex; - - history.push({ beforeScroll, afterAssign, afterSettle }); - if (history.length > HISTORY_SIZE) history.shift(); - - if (previousTopItemIndex !== null && currentTopItemIndex !== null && currentTopItemIndex < previousTopItemIndex) { - // Format history as compact string - const histStr = history.map(h => { - const b = h.beforeScroll; - const a = h.afterSettle; - return `i${b.iter}:[st:${b.st}->${h.afterAssign.st}->${a.st}, items:${b.min}..${b.max}(${b.cnt})->${a.min}..${a.max}(${a.cnt}), sb:${b.sbH}->${a.sbH}, sa:${b.saH}->${a.saH}, top:${a.topIdx}]`; - }).join(' | '); - - const scale = container.offsetHeight === 0 ? 1 : - Math.round(container.getBoundingClientRect().height / container.offsetHeight * 1000) / 1000; - - done({ - success: false, - error: `Flashing at iter ${iteration}: ${previousTopItemIndex}->${currentTopItemIndex}. scale=${scale}, offsetH=${container.offsetHeight}. HISTORY: ${histStr}` - }); - return; - } - - if (currentTopItemIndex !== null) { - previousTopItemIndex = currentTopItemIndex; - } - maxIndexSeen = Math.max(maxIndexSeen, getMaxIndex()); - } - - done({ success: true, maxIndexSeen }); - })();"; - - var result = (Dictionary)js.ExecuteAsyncScript(detectFlashingScript); + var result = ExecuteDetectFlashingScript( + "async-variable-container", ".async-variable-item"); var success = (bool)result["success"]; if (!success) { @@ -1570,28 +1436,11 @@ public void RapidScrollReversals_DoNotCauseErrors() Browser.MountTestComponent(); var container = Browser.Exists(By.Id("scroll-behavior-container")); - var js = (IJavaScriptExecutor)Browser; Browser.True(() => GetElementCount(container, ".scroll-behavior-item") > 0); - const string rapidScrollScript = @" - var done = arguments[0]; - (async () => { - const container = document.getElementById('scroll-behavior-container'); - const wait = ms => new Promise(r => setTimeout(r, ms)); + var result = ExecuteRapidScrollReversalScript( + "scroll-behavior-container", ".scroll-behavior-item"); - for (let i = 0; i < 20; i++) { - container.scrollTop += 300; - await wait(30); - container.scrollTop -= 150; - await wait(30); - } - await wait(200); - - const items = container.querySelectorAll('.scroll-behavior-item'); - done({ itemCount: items.length, scrollTop: container.scrollTop }); - })();"; - - var result = (Dictionary)js.ExecuteAsyncScript(rapidScrollScript); var itemCount = Convert.ToInt32(result["itemCount"], CultureInfo.InvariantCulture); Assert.True(itemCount > 0, "Items should still be visible after rapid scroll reversals"); } @@ -1665,7 +1514,6 @@ public void VariableHeight_VisualStability_NoBackwardJumps() Browser.MountTestComponent(); var container = Browser.Exists(By.Id("async-variable-container")); - var js = (IJavaScriptExecutor)Browser; Browser.Exists(By.Id("toggle-autoload")).Click(); var setCount200Button = Browser.Exists(By.Id("set-count-200")); @@ -1673,50 +1521,9 @@ public void VariableHeight_VisualStability_NoBackwardJumps() Browser.Exists(By.Id("refresh-data")).Click(); Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); - const string stabilityScript = @" - var done = arguments[0]; - (async () => { - const container = document.getElementById('async-variable-container'); - const scrollLog = []; - const wait = ms => new Promise(r => setTimeout(r, ms)); - - const scrollHandler = () => { - scrollLog.push({ - t: performance.now(), - st: container.scrollTop, - items: container.querySelectorAll('.async-variable-item').length - }); - }; - container.addEventListener('scroll', scrollHandler); + var result = ExecuteScrollStabilityScript( + "async-variable-container", ".async-variable-item"); - for (let i = 0; i < 50; i++) { - container.scrollTop += 40; - await wait(50); - } - - container.removeEventListener('scroll', scrollHandler); - await wait(200); - - let backwardJumps = 0; - let maxBackwardJump = 0; - for (let i = 1; i < scrollLog.length; i++) { - const delta = scrollLog[i].st - scrollLog[i-1].st; - if (delta < -5) { - backwardJumps++; - maxBackwardJump = Math.max(maxBackwardJump, Math.abs(delta)); - } - } - - done({ - totalEvents: scrollLog.length, - backwardJumps: backwardJumps, - maxBackwardJump: maxBackwardJump, - finalScrollTop: container.scrollTop, - finalItemCount: container.querySelectorAll('.async-variable-item').length - }); - })();"; - - var result = (Dictionary)js.ExecuteAsyncScript(stabilityScript); var backwardJumps = Convert.ToInt32(result["backwardJumps"], CultureInfo.InvariantCulture); var totalEvents = Convert.ToInt32(result["totalEvents"], CultureInfo.InvariantCulture); var finalItemCount = Convert.ToInt32(result["finalItemCount"], CultureInfo.InvariantCulture); @@ -1844,4 +1651,228 @@ public void VirtualizeWorksInsideHorizontalOverflowContainer() var lastElement = Browser.Exists(By.Id("horizontal-overflow-row-999")); Browser.True(() => lastElement.Displayed); } + + /// + /// Scrolls through all items detecting visual flashing (backward index jumps). + /// Scrolls in 100px increments up to 300 iterations, tracking the top visible item index. + /// Returns success=true if no flashing detected, with maxIndexSeen. + /// + private Dictionary ExecuteDetectFlashingScript(string containerId, string itemSelector) + { + var itemIdPrefix = itemSelector.TrimStart('.') + "-"; + var script = $@" + var done = arguments[0]; + (async () => {{ + const SCROLL_INCREMENT = 100; + const MAX_ITERATIONS = 300; + const VISIBILITY_TOLERANCE = 2; + const container = document.querySelector('#{containerId}'); + + if (!container) {{ + done({{ success: false, error: 'Container not found' }}); + return; + }} + + const getTopVisibleItemIndex = () => {{ + const items = container.querySelectorAll('{itemSelector}'); + if (items.length === 0) return null; + const containerRect = container.getBoundingClientRect(); + for (const item of items) {{ + const itemRect = item.getBoundingClientRect(); + if (itemRect.bottom > containerRect.top + VISIBILITY_TOLERANCE && + itemRect.top < containerRect.bottom - VISIBILITY_TOLERANCE) {{ + const match = item.id.match(/{itemIdPrefix}(\d+)/); + return match ? parseInt(match[1], 10) : null; + }} + }} + return null; + }}; + + const getMaxIndex = () => {{ + const items = container.querySelectorAll('{itemSelector}'); + let maxIdx = -1; + for (const item of items) {{ + const match = item.id.match(/{itemIdPrefix}(\d+)/); + if (match) maxIdx = Math.max(maxIdx, parseInt(match[1], 10)); + }} + return maxIdx; + }}; + + const getMinIndex = () => {{ + const items = container.querySelectorAll('{itemSelector}'); + let minIdx = Infinity; + for (const item of items) {{ + const match = item.id.match(/{itemIdPrefix}(\d+)/); + if (match) minIdx = Math.min(minIdx, parseInt(match[1], 10)); + }} + return minIdx === Infinity ? -1 : minIdx; + }}; + + const waitForSettledFrame = () => {{ + return new Promise(resolve => {{ + requestAnimationFrame(() => {{ + const target = container.querySelector('{itemSelector}') || container; + const io = new IntersectionObserver(() => {{ + io.disconnect(); + resolve(); + }}, {{ root: container, threshold: [0, 1] }}); + io.observe(target); + }}); + }}); + }}; + + const getSnapshot = () => {{ + const spacerBefore = container.querySelector('[aria-hidden=""true""]:first-child'); + const spacerAfter = container.querySelector('[aria-hidden=""true""]:last-child'); + return {{ + st: container.scrollTop, + sh: container.scrollHeight, + min: getMinIndex(), + max: getMaxIndex(), + cnt: container.querySelectorAll('{itemSelector}').length, + sbH: spacerBefore ? spacerBefore.style.height : '?', + saH: spacerAfter ? spacerAfter.style.height : '?', + }}; + }}; + + let previousTopItemIndex = null; + let maxIndexSeen = -1; + const history = []; + const HISTORY_SIZE = 10; + + for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {{ + const beforeScroll = getSnapshot(); + beforeScroll.phase = 'pre'; + beforeScroll.iter = iteration; + + const previousScrollTop = container.scrollTop; + container.scrollTop += SCROLL_INCREMENT; + + if (container.scrollTop === previousScrollTop) {{ + break; + }} + + const afterAssign = {{ st: container.scrollTop, phase: 'post-assign', iter: iteration }}; + + await waitForSettledFrame(); + + const afterSettle = getSnapshot(); + afterSettle.phase = 'settled'; + afterSettle.iter = iteration; + + const currentTopItemIndex = getTopVisibleItemIndex(); + afterSettle.topIdx = currentTopItemIndex; + + history.push({{ beforeScroll, afterAssign, afterSettle }}); + if (history.length > HISTORY_SIZE) history.shift(); + + if (previousTopItemIndex !== null && currentTopItemIndex !== null && currentTopItemIndex < previousTopItemIndex) {{ + const histStr = history.map(h => {{ + const b = h.beforeScroll; + const a = h.afterSettle; + return `i${{b.iter}}:[st:${{b.st}}->${{h.afterAssign.st}}->${{a.st}}, items:${{b.min}}..${{b.max}}(${{b.cnt}})->${{a.min}}..${{a.max}}(${{a.cnt}}), sb:${{b.sbH}}->${{a.sbH}}, sa:${{b.saH}}->${{a.saH}}, top:${{a.topIdx}}]`; + }}).join(' | '); + + const scale = container.offsetHeight === 0 ? 1 : + Math.round(container.getBoundingClientRect().height / container.offsetHeight * 1000) / 1000; + + done({{ + success: false, + error: `Flashing at iter ${{iteration}}: ${{previousTopItemIndex}}->${{currentTopItemIndex}}. scale=${{scale}}, offsetH=${{container.offsetHeight}}. HISTORY: ${{histStr}}` + }}); + return; + }} + + if (currentTopItemIndex !== null) {{ + previousTopItemIndex = currentTopItemIndex; + }} + maxIndexSeen = Math.max(maxIndexSeen, getMaxIndex()); + }} + + done({{ success: true, maxIndexSeen }}); + }})();"; + + return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); + } + + /// + /// Performs 20 rapid scroll reversals (+300px then -150px with 30ms delays) + /// to verify rendering stability under rapid direction changes. + /// Returns the item count and final scroll position. + /// + private Dictionary ExecuteRapidScrollReversalScript(string containerId, string itemSelector) + { + var script = $@" + var done = arguments[0]; + (async () => {{ + const container = document.getElementById('{containerId}'); + const wait = ms => new Promise(r => setTimeout(r, ms)); + + for (let i = 0; i < 20; i++) {{ + container.scrollTop += 300; + await wait(30); + container.scrollTop -= 150; + await wait(30); + }} + await wait(200); + + const items = container.querySelectorAll('{itemSelector}'); + done({{ itemCount: items.length, scrollTop: container.scrollTop }}); + }})();"; + + return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); + } + + /// + /// Monitors scroll events during 50 incremental scrolls (40px each, 50ms apart). + /// Detects backward scroll jumps exceeding 5px threshold. + /// Returns event counts and stability metrics. + /// + private Dictionary ExecuteScrollStabilityScript(string containerId, string itemSelector) + { + var script = $@" + var done = arguments[0]; + (async () => {{ + const container = document.getElementById('{containerId}'); + const scrollLog = []; + const wait = ms => new Promise(r => setTimeout(r, ms)); + + const scrollHandler = () => {{ + scrollLog.push({{ + t: performance.now(), + st: container.scrollTop, + items: container.querySelectorAll('{itemSelector}').length + }}); + }}; + container.addEventListener('scroll', scrollHandler); + + for (let i = 0; i < 50; i++) {{ + container.scrollTop += 40; + await wait(50); + }} + + container.removeEventListener('scroll', scrollHandler); + await wait(200); + + let backwardJumps = 0; + let maxBackwardJump = 0; + for (let i = 1; i < scrollLog.length; i++) {{ + const delta = scrollLog[i].st - scrollLog[i-1].st; + if (delta < -5) {{ + backwardJumps++; + maxBackwardJump = Math.max(maxBackwardJump, Math.abs(delta)); + }} + }} + + done({{ + totalEvents: scrollLog.length, + backwardJumps: backwardJumps, + maxBackwardJump: maxBackwardJump, + finalScrollTop: container.scrollTop, + finalItemCount: container.querySelectorAll('{itemSelector}').length + }}); + }})();"; + + return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); + } } From f2e531964ec5b8a0e2cd62db4775deba701ba7d9 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 23 Mar 2026 11:06:25 +0100 Subject: [PATCH 2/9] Avoid excessive communication across the boundary. --- src/Components/Web.JS/src/Virtualize.ts | 39 +--- src/Components/Web.JS/test/Virtualize.test.ts | 94 +-------- .../Virtualization/IVirtualizeJsCallbacks.cs | 4 +- .../Web/src/Virtualization/Virtualize.cs | 44 ++-- .../src/Virtualization/VirtualizeJsInterop.cs | 8 +- .../Web/test/Virtualization/VirtualizeTest.cs | 188 +++++++++++------- 6 files changed, 155 insertions(+), 222 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index dfee502d5766..f968bedb3083 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -7,7 +7,6 @@ export const Virtualize = { init, dispose, scrollToBottom, - measureRenderedItems, refreshObservers, }; @@ -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 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. @@ -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); @@ -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); } }); } diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index e54aa81a4a3f..318063225786 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -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'); }); }); diff --git a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 2ef922e647d0..06087f04c97b 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -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); } diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 0a18365111bb..15a33195d4ae 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -37,9 +37,9 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private int _loadedItemsStartIndex; - private int _lastRenderedItemCount; + internal int _lastRenderedItemCount; - private int _lastRenderedPlaceholderCount; + internal int _lastRenderedPlaceholderCount; private float _itemSize; @@ -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, ""); _itemTemplate(item)(builder); _lastRenderedItemCount++; } - if (_lastRenderedItemCount > 0) - { - builder.AddMarkupContent(1, ""); - } - renderIndex += _lastRenderedItemCount; builder.CloseRegion(); @@ -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 void ProcessMeasurements(float spacerSeparation) { - if (measuredItemCount <= 0) + // Derive item height measurements from spacerSeparation (total content height + // between spacers) and the server-known rendered item count. This avoids + // any new JS→.NET inputs — spacerSeparation was already sent, and + // _lastRenderedItemCount is server-side state. + if (_lastRenderedItemCount <= 0) { - return false; + return; } - _totalMeasuredHeight += measuredItemHeightSum; - _measuredItemCount += measuredItemCount; - return true; + // Subtract placeholder height contribution to isolate real item heights. + var placeholderHeight = _lastRenderedPlaceholderCount * _itemSize; + var realItemHeight = spacerSeparation - placeholderHeight; + + if (realItemHeight > 0) + { + _totalMeasuredHeight += realItemHeight; + _measuredItemCount += _lastRenderedItemCount; + } } - 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); @@ -371,9 +374,10 @@ 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 hadMeasurements = _lastRenderedItemCount > 0; + ProcessMeasurements(spacerSeparation); CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); @@ -387,7 +391,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS // When we're at the very bottom and new measurements arrived, // scroll to bottom so the viewport stays pinned while items converge. - if (itemsAfter == 0 && hadNewMeasurements) + if (itemsAfter == 0 && hadMeasurements) { _pendingScrollToBottom = true; } diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index 59b6c9168022..363557cb7688 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -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() diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 57f3a43566e6..6ed9166b25ef 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -93,7 +93,7 @@ public async Task Virtualize_DispatchesExceptionsFromItemsProviderThroughRendere Assert.NotNull(renderedVirtualize); // Simulate a JS spacer callback. - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 50f, 100f, 0f, 0); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 50f, 100f); // Validate that the exception is dispatched through the renderer. var ex = await Assert.ThrowsAsync(async () => await testRenderer.RenderRootComponentAsync(componentId)); @@ -135,7 +135,7 @@ ValueTask> countingItemsProvider(ItemsProviderRequest r // Heights: 30 + 70 + 50 = 150, count = 3 await testRenderer.Dispatcher.InvokeAsync(() => - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, 150f, 3)); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f)); Assert.True(itemsProviderCallCount > initialCallCount, "ItemsProvider should be called after spacer callback with measurements"); @@ -144,14 +144,34 @@ await testRenderer.Dispatcher.InvokeAsync(() => [Fact] public async Task Virtualize_MeasurementsUpdateRunningAverage() { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + // Use fixed items with a template so items actually render + Virtualize virtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + captureRenderedVirtualize: v => virtualize = v) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + var callbacks = (IVirtualizeJsCallbacks)virtualize; - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 80f, 500f, 80f, 2)); + // First callback triggers item rendering + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 80f, 500f)); + + // Second callback accumulates measurements from rendered items + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 80f, 500f)); - Assert.Equal(80f, virtualize._totalMeasuredHeight); - Assert.Equal(2, virtualize._measuredItemCount); + Assert.True(virtualize._totalMeasuredHeight > 0); + Assert.True(virtualize._measuredItemCount > 0); } [Fact] @@ -161,10 +181,10 @@ public async Task Virtualize_NullMeasurementsUseDefaultItemSize() var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 200f, 400f, 0f, 0)); + callbacks.OnAfterSpacerVisible(0f, 200f, 400f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 200f, 400f, 0f, 0)); + callbacks.OnAfterSpacerVisible(0f, 200f, 400f)); } [Fact] @@ -173,17 +193,23 @@ public async Task Virtualize_ZeroLengthMeasurementsDoNotCorruptAverage() var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); var callbacks = (IVirtualizeJsCallbacks)virtualize; + // First callback loads items await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); + // Second callback accumulates measurements await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 0f, 0)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); + var heightAfterFirst = virtualize._totalMeasuredHeight; + var countAfterFirst = virtualize._measuredItemCount; + + // Third callback with same spacerSeparation accumulates more await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 0f, 0)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); - Assert.Equal(100f, virtualize._totalMeasuredHeight); - Assert.Equal(2, virtualize._measuredItemCount); + // Average should remain stable (same spacerSeparation each time) + Assert.True(virtualize._measuredItemCount >= countAfterFirst); } [Fact] @@ -196,7 +222,7 @@ public async Task Virtualize_BimodalMeasurementsProduceValidAverage() { // Bimodal: 30+300+30+300+30+300 = 990, count = 6 await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 990f, 600f, 990f, 6)); + callbacks.OnAfterSpacerVisible(0f, 990f, 600f)); } } @@ -207,7 +233,7 @@ public async Task Virtualize_VerySmallMeasurementsDoNotCauseExcessiveItemCounts( var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 5f, 1000f, 5f, 5)); + callbacks.OnAfterSpacerVisible(0f, 5f, 1000f)); } [Fact] @@ -217,7 +243,7 @@ public async Task Virtualize_LargeMeasurementsProduceValidDistribution() var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 4000f, 500f, 4000f, 2)); + callbacks.OnAfterSpacerVisible(0f, 4000f, 500f)); } [Fact] @@ -240,7 +266,7 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var countBefore = requests.Count; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(100f, 300f, 500f, 150f, 3)); + callbacks.OnBeforeSpacerVisible(100f, 300f, 500f)); Assert.True(requests.Count > countBefore, "ItemsProvider should be called when before spacer becomes visible with measurements"); @@ -264,10 +290,10 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, 150f, 3)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, 100f, 2)); + callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f)); Assert.Contains(requests, r => r.StartIndex > 0); } @@ -290,16 +316,16 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); var countAfterBaseline = requests.Count; // NaN/invalid values are filtered in JS before aggregation. // Only the valid measurement (30f) is included in the sum. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 30f, 1)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 100f, 500f, 100f, 2)); + callbacks.OnBeforeSpacerVisible(50f, 100f, 500f)); Assert.True(requests.Count > countAfterBaseline, "Component should still process callbacks after receiving NaN measurements"); @@ -323,16 +349,16 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); var countAfterBaseline = requests.Count; // Negative values are filtered in JS before aggregation. // Only the valid measurement (50f) is included in the sum. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 50f, 1)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 100f, 500f, 100f, 2)); + callbacks.OnBeforeSpacerVisible(50f, 100f, 500f)); Assert.True(requests.Count > countAfterBaseline, "Component should still process callbacks after receiving negative measurements"); @@ -356,23 +382,23 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); var countAfterBaseline = requests.Count; // Infinity values are filtered in JS before aggregation. // No valid measurements means count=0. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 0f, 0)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 100f, 500f, 100f, 2)); + callbacks.OnBeforeSpacerVisible(50f, 100f, 500f)); Assert.True(requests.Count > countAfterBaseline, "Component should still process callbacks after receiving infinity measurements"); } [Fact] - public async Task Virtualize_RendersCommentDelimitersForJsMeasurement() + public async Task Virtualize_RendersItemsWithoutWrapperElements() { Virtualize renderedVirtualize = null; @@ -393,19 +419,20 @@ public async Task Virtualize_RendersCommentDelimitersForJsMeasurement() Assert.NotNull(renderedVirtualize); await testRenderer.Dispatcher.InvokeAsync(() => - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, 0f, 0)); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f)); - var hasCommentDelimiters = testRenderer.Batches + // Items should be rendered directly without wrapper elements + var hasWrapperElements = testRenderer.Batches .SelectMany(b => b.ReferenceFrames) - .Any(f => f.FrameType == RenderTreeFrameType.Markup - && f.MarkupContent == ""); + .Any(f => f.FrameType == RenderTreeFrameType.Element + && f.ElementName == "virtualize-item"); - Assert.True(hasCommentDelimiters, - "Items should be delimited by comments for JS measurement"); + Assert.False(hasWrapperElements, + "Items should be rendered directly without wrapper elements"); } [Fact] - public async Task Virtualize_TableSpacerElement_UsesCommentDelimitersNotWrapperElements() + public async Task Virtualize_TableSpacerElement_RendersItemsDirectlyWithTrSpacers() { Virtualize renderedVirtualize = null; @@ -427,19 +454,13 @@ public async Task Virtualize_TableSpacerElement_UsesCommentDelimitersNotWrapperE Assert.NotNull(renderedVirtualize); await testRenderer.Dispatcher.InvokeAsync(() => - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, 0f, 0)); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f)); var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); - var hasCommentDelimiters = referenceFrames - .Any(f => f.FrameType == RenderTreeFrameType.Markup - && f.MarkupContent == ""); - var hasTrSpacers = referenceFrames .Any(f => f.FrameType == RenderTreeFrameType.Element && f.ElementName == "tr"); - Assert.True(hasCommentDelimiters, - "Items should use comment delimiters, not wrapper elements"); Assert.True(hasTrSpacers, "Spacer elements should use 'tr' tag when SpacerElement='tr'"); } @@ -447,19 +468,34 @@ await testRenderer.Dispatcher.InvokeAsync(() => [Fact] public async Task Virtualize_RefreshDataAsync_ResetsRunningAverage() { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + Virtualize virtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + captureRenderedVirtualize: v => virtualize = v) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + // Multiple callbacks to load items and accumulate measurements for (int i = 0; i < 10; i++) { - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 90f, 500f, 90f, 3)); + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 90f, 500f)); } - Assert.True(virtualize._totalMeasuredHeight > 0); + // After several cycles, measurements should have accumulated Assert.True(virtualize._measuredItemCount > 0); - await renderer.Dispatcher.InvokeAsync(() => virtualize.RefreshDataAsync()); + await testRenderer.Dispatcher.InvokeAsync(() => virtualize.RefreshDataAsync()); Assert.Equal(0f, virtualize._totalMeasuredHeight); Assert.Equal(0, virtualize._measuredItemCount); @@ -472,17 +508,10 @@ public async Task Virtualize_ScrollToBottom_SetWhenAtEndWithNewMeasurements() Virtualize renderedVirtualize = null; - ValueTask> provider(ItemsProviderRequest request) - { - return ValueTask.FromResult(new ItemsProviderResult( - Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), - 100)); - } - var rootComponent = new VirtualizeTestHostcomponent { - InnerContent = BuildVirtualize(50f, provider, null, - virtualize => renderedVirtualize = virtualize) + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + captureRenderedVirtualize: v => renderedVirtualize = v) }; var serviceProvider = new ServiceCollection() @@ -495,10 +524,15 @@ ValueTask> provider(ItemsProviderRequest request) await testRenderer.RenderRootComponentAsync(componentId); Assert.NotNull(renderedVirtualize); - // spacerSize=0 means at the very bottom; new measurements should trigger scrollToBottom + // First callback triggers items to render + await testRenderer.Dispatcher.InvokeAsync(() => + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( + 0f, 500f, 500f)); + + // Second callback: spacerSize=0 means at the very bottom; with items rendered, should trigger scrollToBottom await testRenderer.Dispatcher.InvokeAsync(() => ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( - 0f, 500f, 500f, 100f, 2)); + 0f, 500f, 500f)); var scrollToBottomCalled = mockJs.Invocations.Any(i => i.Arguments.Count > 0 && @@ -517,7 +551,7 @@ public async Task Virtualize_ScrollToBottom_NotSetWhenNotAtEnd() // spacerSize=5000 means many items remain after the viewport await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(5000f, 1000f, 500f, 100f, 2)); + callbacks.OnAfterSpacerVisible(5000f, 1000f, 500f)); Assert.False(virtualize._pendingScrollToBottom); } @@ -542,7 +576,7 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callCountAfterMount = requests.Count; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 500f, 500f, 0f, 0)); + callbacks.OnBeforeSpacerVisible(50f, 500f, 500f)); Assert.Equal(callCountAfterMount + 1, requests.Count); @@ -574,15 +608,16 @@ public async Task Virtualize_BothSpacersVisible_SmallItemCountDoesNotCrash() var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, 150f, 3)); + callbacks.OnAfterSpacerVisible(0f, 150f, 1000f)); await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(0f, 150f, 1000f, 0f, 0)); + callbacks.OnBeforeSpacerVisible(0f, 150f, 1000f)); await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, 0f, 0)); + callbacks.OnAfterSpacerVisible(0f, 150f, 1000f)); - Assert.Equal(3, renderedVirtualize._measuredItemCount); + // After multiple callbacks, measurements should accumulate + Assert.True(renderedVirtualize._measuredItemCount > 0); } [Fact] @@ -612,11 +647,16 @@ public async Task Virtualize_FixedItems_MeasurementsAccumulateWithoutBreakingRen Assert.Equal(0f, renderedVirtualize._totalMeasuredHeight); Assert.Equal(0, renderedVirtualize._measuredItemCount); + // First callback triggers render with items (setting _lastRenderedItemCount) + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 1000f, 500f)); + + // Second callback accumulates measurements from spacerSeparation await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 1000f, 500f, 150f, 3)); + callbacks.OnAfterSpacerVisible(0f, 1000f, 500f)); - Assert.Equal(150f, renderedVirtualize._totalMeasuredHeight); - Assert.Equal(3, renderedVirtualize._measuredItemCount); + Assert.True(renderedVirtualize._totalMeasuredHeight > 0); + Assert.True(renderedVirtualize._measuredItemCount > 0); } [Fact] @@ -650,13 +690,13 @@ ValueTask> delayedProvider(ItemsProviderRequest request var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 0f, 500f, 50f, 1)); + callbacks.OnAfterSpacerVisible(0f, 0f, 500f)); Assert.Single(pendingCalls); var firstCall = pendingCalls[0]; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 0f, 1000f, 50f, 1)); + callbacks.OnAfterSpacerVisible(0f, 0f, 1000f)); Assert.Equal(2, pendingCalls.Count); var secondCall = pendingCalls[1]; @@ -664,8 +704,10 @@ await testRenderer.Dispatcher.InvokeAsync(() => Assert.True(firstCall.request.CancellationToken.IsCancellationRequested); Assert.False(secondCall.request.CancellationToken.IsCancellationRequested); - Assert.Equal(2, renderedVirtualize._measuredItemCount); - Assert.Equal(100f, renderedVirtualize._totalMeasuredHeight); + // With delayed provider, no items have rendered so _lastRenderedItemCount = 0 + // and no measurements accumulate (correct: can't measure what hasn't rendered) + Assert.Equal(0, renderedVirtualize._measuredItemCount); + Assert.Equal(0f, renderedVirtualize._totalMeasuredHeight); foreach (var call in pendingCalls.Where(c => !c.tcs.Task.IsCompleted)) { call.tcs.TrySetResult(new ItemsProviderResult(Array.Empty(), 0)); From 69db47e33488eacfe79d7703b9d5ab0875d86b13 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 23 Mar 2026 11:25:31 +0100 Subject: [PATCH 3/9] Test max item limit in virtualization. --- .../Web/test/Virtualization/VirtualizeTest.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 6ed9166b25ef..d6427e39b740 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -714,6 +714,44 @@ await testRenderer.Dispatcher.InvokeAsync(() => } } + [Fact] + public async Task MaxItemCount_ClampsVisibleItemCapacity() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 1000 - request.StartIndex)), + 1000)); + } + + Virtualize renderedVirtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithMaxItemCount(10f, trackingProvider, maxItemCount: 20, captureRenderedVirtualize: v => renderedVirtualize = v) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + Assert.NotNull(renderedVirtualize); + + var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + var lastRequest = requests.Last(); + Assert.True(lastRequest.Count <= 50, + $"Expected request count <= 50 (MaxItemCount=20 + 2*OverscanCount=30), but got {lastRequest.Count}"); + } + private async Task<(Virtualize virtualize, TestRenderer renderer)> CreateRenderedVirtualize( float itemSize, int totalItems, @@ -797,6 +835,26 @@ private RenderFragment BuildVirtualizeWithContent( builder.CloseComponent(); }; + private RenderFragment BuildVirtualizeWithMaxItemCount( + float itemSize, + ItemsProviderDelegate itemsProvider, + int maxItemCount, + Action> captureRenderedVirtualize = null) + => builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "ItemSize", itemSize); + builder.AddComponentParameter(2, "ItemsProvider", itemsProvider); + builder.AddComponentParameter(3, "MaxItemCount", maxItemCount); + + if (captureRenderedVirtualize != null) + { + builder.AddComponentReferenceCapture(4, component => captureRenderedVirtualize(component as Virtualize)); + } + + builder.CloseComponent(); + }; + private RenderFragment BuildVirtualizeWithMultiRootContent( float itemSize, ICollection items, From d94ee8827327d58dc03b0ba5f045f4479f7ff343 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 23 Mar 2026 12:02:11 +0100 Subject: [PATCH 4/9] Remove the testst that are no longer necessary without the broad API surface. --- .../Web/test/Virtualization/VirtualizeTest.cs | 172 ++++++------------ 1 file changed, 52 insertions(+), 120 deletions(-) diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index d6427e39b740..0e90ed41e291 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -101,22 +101,14 @@ public async Task Virtualize_DispatchesExceptionsFromItemsProviderThroughRendere } [Fact] - public async Task Virtualize_AcceptsItemMeasurementsFromSpacerCallback() + public async Task Virtualize_MeasurementsUpdateRunningAverage() { - Virtualize renderedVirtualize = null; - var itemsProviderCallCount = 0; - - ValueTask> countingItemsProvider(ItemsProviderRequest request) - { - itemsProviderCallCount++; - return ValueTask.FromResult(new ItemsProviderResult( - Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), - 100)); - } - + // Use fixed items with a template so items actually render + Virtualize virtualize = null; var rootComponent = new VirtualizeTestHostcomponent { - InnerContent = BuildVirtualize(50f, countingItemsProvider, null, virtualize => renderedVirtualize = virtualize) + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + captureRenderedVirtualize: v => virtualize = v) }; var serviceProvider = new ServiceCollection() @@ -125,26 +117,28 @@ ValueTask> countingItemsProvider(ItemsProviderRequest r var testRenderer = new TestRenderer(serviceProvider); var componentId = testRenderer.AssignRootComponentId(rootComponent); - await testRenderer.RenderRootComponentAsync(componentId); - Assert.NotNull(renderedVirtualize); - var initialCallCount = itemsProviderCallCount; + var callbacks = (IVirtualizeJsCallbacks)virtualize; - // Simulate JS callback with pre-aggregated measurements (sum and count) - // Heights: 30 + 70 + 50 = 150, count = 3 + // First callback triggers distribution calculation and re-render + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 80f, 500f)); + // Second callback — with items now rendered, ProcessMeasurements derives + // item heights from spacerSeparation and _lastRenderedItemCount await testRenderer.Dispatcher.InvokeAsync(() => - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f)); + callbacks.OnAfterSpacerVisible(0f, 80f, 500f)); - Assert.True(itemsProviderCallCount > initialCallCount, - "ItemsProvider should be called after spacer callback with measurements"); + Assert.True(virtualize._totalMeasuredHeight > 0); + Assert.True(virtualize._measuredItemCount > 0); } [Fact] - public async Task Virtualize_MeasurementsUpdateRunningAverage() + public async Task Virtualize_ZeroSpacerSeparationDoesNotCorruptAverage() { - // Use fixed items with a template so items actually render + // BuildVirtualizeWithContent provides Items + ChildContent so the test renderer + // actually renders items, incrementing _lastRenderedItemCount (needed for ProcessMeasurements). Virtualize virtualize = null; var rootComponent = new VirtualizeTestHostcomponent { @@ -162,88 +156,23 @@ public async Task Virtualize_MeasurementsUpdateRunningAverage() var callbacks = (IVirtualizeJsCallbacks)virtualize; - // First callback triggers item rendering + // First callback with valid spacerSeparation establishes measurements await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 80f, 500f)); - - // Second callback accumulates measurements from rendered items + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 80f, 500f)); - - Assert.True(virtualize._totalMeasuredHeight > 0); - Assert.True(virtualize._measuredItemCount > 0); - } - - [Fact] - public async Task Virtualize_NullMeasurementsUseDefaultItemSize() - { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 40f, totalItems: 100); - var callbacks = (IVirtualizeJsCallbacks)virtualize; - - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 200f, 400f)); - - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 200f, 400f)); - } - - [Fact] - public async Task Virtualize_ZeroLengthMeasurementsDoNotCorruptAverage() - { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); - var callbacks = (IVirtualizeJsCallbacks)virtualize; - - // First callback loads items - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); - - // Second callback accumulates measurements - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); - - var heightAfterFirst = virtualize._totalMeasuredHeight; - var countAfterFirst = virtualize._measuredItemCount; - - // Third callback with same spacerSeparation accumulates more - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); - - // Average should remain stable (same spacerSeparation each time) - Assert.True(virtualize._measuredItemCount >= countAfterFirst); - } - - [Fact] - public async Task Virtualize_BimodalMeasurementsProduceValidAverage() - { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 200); - var callbacks = (IVirtualizeJsCallbacks)virtualize; - - for (int i = 0; i < 2; i++) - { - // Bimodal: 30+300+30+300+30+300 = 990, count = 6 - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 990f, 600f)); - } - } - - [Fact] - public async Task Virtualize_VerySmallMeasurementsDoNotCauseExcessiveItemCounts() - { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 10000); - var callbacks = (IVirtualizeJsCallbacks)virtualize; + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 5f, 1000f)); - } + var heightAfterValid = virtualize._totalMeasuredHeight; + var countAfterValid = virtualize._measuredItemCount; + Assert.True(heightAfterValid > 0, "Should have accumulated measurements"); - [Fact] - public async Task Virtualize_LargeMeasurementsProduceValidDistribution() - { - var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); - var callbacks = (IVirtualizeJsCallbacks)virtualize; + // Callback with zero spacerSeparation should not add measurements + // (realItemHeight would be zero or negative) + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 0f, 500f)); - await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 4000f, 500f)); + Assert.Equal(heightAfterValid, virtualize._totalMeasuredHeight); + Assert.Equal(countAfterValid, virtualize._measuredItemCount); } [Fact] @@ -299,7 +228,7 @@ await renderer.Dispatcher.InvokeAsync(() => } [Fact] - public async Task Virtualize_NaNMeasurementsDoNotCrashComponent() + public async Task Virtualize_NaNSpacerSeparationDoesNotCrashComponent() { var requests = new List(); @@ -315,24 +244,25 @@ ValueTask> trackingProvider(ItemsProviderRequest reques itemSize: 50f, totalItems: 100, customProvider: trackingProvider); var callbacks = (IVirtualizeJsCallbacks)virtualize; + // Establish baseline await renderer.Dispatcher.InvokeAsync(() => callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); var countAfterBaseline = requests.Count; + var heightBefore = virtualize._totalMeasuredHeight; - // NaN/invalid values are filtered in JS before aggregation. - // Only the valid measurement (30f) is included in the sum. + // NaN spacerSeparation should not corrupt measurements or crash await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); + callbacks.OnAfterSpacerVisible(0f, float.NaN, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 100f, 500f)); + callbacks.OnBeforeSpacerVisible(50f, float.NaN, 500f)); Assert.True(requests.Count > countAfterBaseline, - "Component should still process callbacks after receiving NaN measurements"); + "Component should still process callbacks after NaN spacerSeparation"); } [Fact] - public async Task Virtualize_NegativeMeasurementsDoNotCrashComponent() + public async Task Virtualize_NegativeSpacerSeparationDoesNotCrashComponent() { var requests = new List(); @@ -350,22 +280,24 @@ ValueTask> trackingProvider(ItemsProviderRequest reques await renderer.Dispatcher.InvokeAsync(() => callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); - var countAfterBaseline = requests.Count; + var heightBefore = virtualize._totalMeasuredHeight; + var countBefore = virtualize._measuredItemCount; - // Negative values are filtered in JS before aggregation. - // Only the valid measurement (50f) is included in the sum. + // Negative spacerSeparation produces negative realItemHeight — should not + // accumulate into the running average. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); + callbacks.OnAfterSpacerVisible(0f, -500f, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 100f, 500f)); + callbacks.OnBeforeSpacerVisible(50f, -100f, 500f)); - Assert.True(requests.Count > countAfterBaseline, - "Component should still process callbacks after receiving negative measurements"); + // Measurements should not have changed from the negative inputs + Assert.Equal(heightBefore, virtualize._totalMeasuredHeight); + Assert.Equal(countBefore, virtualize._measuredItemCount); } [Fact] - public async Task Virtualize_InfinityMeasurementsDoNotCrashComponent() + public async Task Virtualize_InfinitySpacerSeparationDoesNotCrashComponent() { var requests = new List(); @@ -385,16 +317,16 @@ await renderer.Dispatcher.InvokeAsync(() => callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); var countAfterBaseline = requests.Count; - // Infinity values are filtered in JS before aggregation. - // No valid measurements means count=0. + // Extremely large (infinity) spacerSeparation — component should handle + // without overflow or crash. MaxItemCount caps the visible capacity. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f)); + callbacks.OnAfterSpacerVisible(0f, float.PositiveInfinity, 500f)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 100f, 500f)); + callbacks.OnBeforeSpacerVisible(50f, float.PositiveInfinity, 500f)); Assert.True(requests.Count > countAfterBaseline, - "Component should still process callbacks after receiving infinity measurements"); + "Component should still process callbacks after infinity spacerSeparation"); } [Fact] From 63b438fd53660bdc5a6e7be6a5c5882352251bab Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 23 Mar 2026 16:17:05 +0100 Subject: [PATCH 5/9] Feedback: `_lastRenderedItemCount > 0` check is not precise, can trigger. With regression test. --- .../Web/src/Virtualization/Virtualize.cs | 31 +++++++-------- .../Web/test/Virtualization/VirtualizeTest.cs | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 15a33195d4ae..6622face0396 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -337,26 +337,26 @@ private string GetSpacerStyle(int itemsInSpacer) private float GetItemHeight() => _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize; - private void ProcessMeasurements(float spacerSeparation) + private bool ProcessMeasurements(float spacerSeparation) { - // Derive item height measurements from spacerSeparation (total content height - // between spacers) and the server-known rendered item count. This avoids - // any new JS→.NET inputs — spacerSeparation was already sent, and - // _lastRenderedItemCount is server-side state. - if (_lastRenderedItemCount <= 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; + return false; } - // Subtract placeholder height contribution to isolate real item heights. - var placeholderHeight = _lastRenderedPlaceholderCount * _itemSize; - var realItemHeight = spacerSeparation - placeholderHeight; - - if (realItemHeight > 0) + if (spacerSeparation > 0) { - _totalMeasuredHeight += realItemHeight; + _totalMeasuredHeight += spacerSeparation; _measuredItemCount += _lastRenderedItemCount; + return true; } + + return false; } void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) @@ -376,8 +376,7 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { - var hadMeasurements = _lastRenderedItemCount > 0; - ProcessMeasurements(spacerSeparation); + var hadNewMeasurements = ProcessMeasurements(spacerSeparation); CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); @@ -391,7 +390,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS // When we're at the very bottom and new measurements arrived, // scroll to bottom so the viewport stays pinned while items converge. - if (itemsAfter == 0 && hadMeasurements) + if (itemsAfter == 0 && hadNewMeasurements) { _pendingScrollToBottom = true; } diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 0e90ed41e291..f0435bdbb2c1 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -488,6 +488,45 @@ await renderer.Dispatcher.InvokeAsync(() => Assert.False(virtualize._pendingScrollToBottom); } + [Fact] + public async Task Virtualize_ScrollToBottom_NotSetWhenMeasurementsNotApplied() + { + Virtualize renderedVirtualize = null; + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + captureRenderedVirtualize: v => renderedVirtualize = v) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + Assert.NotNull(renderedVirtualize); + + var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; + + // First call: real measurements at the bottom — should set pending + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + Assert.True(renderedVirtualize._lastRenderedItemCount > 0, + "Items should have rendered"); + renderedVirtualize._pendingScrollToBottom = false; + + // Second call: spacerSeparation=0 at the bottom — no new measurements, + // so _pendingScrollToBottom must NOT be set + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 0f, 500f)); + + Assert.False(renderedVirtualize._pendingScrollToBottom, + "scrollToBottom should not be set when ProcessMeasurements did not apply new measurements"); + } + [Fact] public async Task Virtualize_FirstRender_DoesNotShiftStartIndexAwayFromZero() { From 7b8c1260983cbe2e310b188ceef70f71c7103b2e Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 24 Mar 2026 10:08:22 +0100 Subject: [PATCH 6/9] Align test code with its purpose. --- src/Components/test/E2ETest/Tests/VirtualizationTest.cs | 7 +++++-- .../BasicTestApp/VirtualizationDynamicContent.razor | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index d43d3e989c85..2813401f74c9 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -997,8 +997,11 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() Browser.True(() => GetElementCount(container, ".item") > 0); // Scroll down so item 2 is not visible - js.ExecuteScript("arguments[0].scrollTop = 200", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 200); + js.ExecuteScript("arguments[0].scrollTop = 2000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 2000); + + // Confirm item 2 is truly not in the DOM (virtualized away) + Browser.True(() => container.FindElements(By.CssSelector("[data-index='2']")).Count == 0); // Get the position of a visible item before expanding the off-screen item var visibleItems = container.FindElements(By.CssSelector(".item")); diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor index 1c8a128d6f6a..602517d08cae 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor @@ -35,8 +35,8 @@ protected override void OnInitialized() { - // Create 30 items with initial height of 50px - items = Enumerable.Range(0, 30) + // 50 items: with initial height of 50px + items = Enumerable.Range(0, 50) .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) .ToList(); } From cce07e7d9d3a0cc7b66e17912108a69fc766d661 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 24 Mar 2026 11:41:18 +0100 Subject: [PATCH 7/9] 50 items was on the border of overscan, we have to increase. --- .../BasicTestApp/VirtualizationDynamicContent.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor index 602517d08cae..7188b11abcc9 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor @@ -35,8 +35,8 @@ protected override void OnInitialized() { - // 50 items: with initial height of 50px - items = Enumerable.Range(0, 50) + // 75 items with initial height of 50px (enough for true off-screen virtualization) + items = Enumerable.Range(0, 75) .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) .ToList(); } From 86075e5663ddc6db961ee2c54ba2b4dedb71d7b3 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 24 Mar 2026 13:09:45 +0100 Subject: [PATCH 8/9] Diagnostic: capture DOM state on CI for off-screen test failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/E2ETest/Tests/VirtualizationTest.cs | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 2813401f74c9..88713226015a 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1000,8 +1000,34 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() js.ExecuteScript("arguments[0].scrollTop = 2000", container); Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 2000); - // Confirm item 2 is truly not in the DOM (virtualized away) - Browser.True(() => container.FindElements(By.CssSelector("[data-index='2']")).Count == 0); + // Wait a moment for virtualization to process the scroll + Thread.Sleep(2000); + + // Diagnostic: capture full state to understand CI failures + var debugInfo = (string)js.ExecuteScript(@" + var c = arguments[0]; + var items = c.querySelectorAll('[data-index]'); + var indices = Array.from(items).map(i => i.getAttribute('data-index')); + var children = Array.from(c.children); + var childInfo = children.map(ch => ch.tagName + '(' + ch.style.height + ')' + (ch.dataset.index || '')); + return JSON.stringify({ + scrollTop: c.scrollTop, + scrollHeight: c.scrollHeight, + clientHeight: c.clientHeight, + offsetHeight: c.offsetHeight, + itemCount: items.length, + firstIdx: indices[0], + lastIdx: indices[indices.length - 1], + item2: c.querySelector('[data-index=""2""]') !== null, + childCount: c.children.length, + firstChildH: children[0] ? children[0].style.height : 'none', + lastChildH: children[children.length-1] ? children[children.length-1].style.height : 'none' + }); + ", container); + + Assert.True( + container.FindElements(By.CssSelector("[data-index='2']")).Count == 0, + $"Item 2 should not be in DOM after scrolling. State: {debugInfo}"); // Get the position of a visible item before expanding the off-screen item var visibleItems = container.FindElements(By.CssSelector(".item")); From 9bfec7df6421b0b02a7db1d8b919c17bb9172e78 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 24 Mar 2026 14:42:09 +0100 Subject: [PATCH 9/9] Fix off-screen test: scroll to bottom for reliable virtualization Scrolling to a fixed offset (2000px) left most items in the 'after' spacer region, so itemsBefore was only 1 and item 2 remained in the rendered window. Scrolling to the very bottom ensures itemsBefore is large enough that item 2 is virtualized out of the DOM. Also removes temporary CI diagnostic code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/E2ETest/Tests/VirtualizationTest.cs | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 88713226015a..fa40f512f31c 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -996,38 +996,19 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() var status = Browser.Exists(By.Id("status")); Browser.True(() => GetElementCount(container, ".item") > 0); - // Scroll down so item 2 is not visible - js.ExecuteScript("arguments[0].scrollTop = 2000", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 2000); - - // Wait a moment for virtualization to process the scroll - Thread.Sleep(2000); - - // Diagnostic: capture full state to understand CI failures - var debugInfo = (string)js.ExecuteScript(@" - var c = arguments[0]; - var items = c.querySelectorAll('[data-index]'); - var indices = Array.from(items).map(i => i.getAttribute('data-index')); - var children = Array.from(c.children); - var childInfo = children.map(ch => ch.tagName + '(' + ch.style.height + ')' + (ch.dataset.index || '')); - return JSON.stringify({ - scrollTop: c.scrollTop, - scrollHeight: c.scrollHeight, - clientHeight: c.clientHeight, - offsetHeight: c.offsetHeight, - itemCount: items.length, - firstIdx: indices[0], - lastIdx: indices[indices.length - 1], - item2: c.querySelector('[data-index=""2""]') !== null, - childCount: c.children.length, - firstChildH: children[0] ? children[0].style.height : 'none', - lastChildH: children[children.length-1] ? children[children.length-1].style.height : 'none' - }); - ", container); + // Scroll to the very bottom so item 2 is virtualized out of the DOM + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + Browser.True(() => + { + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return scrollTop >= scrollHeight - clientHeight - 1; + }); - Assert.True( - container.FindElements(By.CssSelector("[data-index='2']")).Count == 0, - $"Item 2 should not be in DOM after scrolling. State: {debugInfo}"); + // Wait for virtualization to converge after scrolling to bottom + Browser.True(() => container.FindElements(By.CssSelector("[data-index='2']")).Count == 0, + TimeSpan.FromSeconds(10)); // Get the position of a visible item before expanding the off-screen item var visibleItems = container.FindElements(By.CssSelector(".item"));