diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 874d0bbf3b4d..74bdf4d8ad78 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -14,7 +14,7 @@ permissions: jobs: backport: - uses: dotnet/arcade/.github/workflows/backport-base.yml@acdb3e708ba600e766667825c84f9fa4a49e6c8f + uses: dotnet/arcade/.github/workflows/backport-base.yml@1353cab671305cff0ae5afc0d96ff3d03f239e0c with: pr_description_template: | Backport of #%source_pr_number% to %target_branch% diff --git a/.github/workflows/inter-branch-merge-flow.yml b/.github/workflows/inter-branch-merge-flow.yml index 1ce67ce4b46b..002b2aec04f6 100644 --- a/.github/workflows/inter-branch-merge-flow.yml +++ b/.github/workflows/inter-branch-merge-flow.yml @@ -11,4 +11,4 @@ permissions: jobs: Merge: - uses: dotnet/arcade/.github/workflows/inter-branch-merge-base.yml@acdb3e708ba600e766667825c84f9fa4a49e6c8f + uses: dotnet/arcade/.github/workflows/inter-branch-merge-base.yml@1353cab671305cff0ae5afc0d96ff3d03f239e0c diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 4b6fe3d7084a..e2e05493d7da 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -17,11 +17,11 @@ export const Virtualize = { const dispatcherObserversByDotNetIdPropname = Symbol(); const THROTTLE_MS = 50; +const renderedWindowVersionAttribute = 'data-blazor-virtualize-rendered-window-version'; const SpacerVisibilityReason = { UserScroll: 0, ProgrammaticScroll: 1, ViewportFill: 2, - RenderedContentMeasurement: 3, } as const; const ViewportFillDirection = { @@ -38,6 +38,18 @@ const ScrollSource = { } as const; type ScrollSource = typeof ScrollSource[keyof typeof ScrollSource]; +type AlignmentResult = { + fillDirection: number; + spacerSeparation: number; + containerSize: number; + renderedWindowVersion: number; +}; + +type IntersectionMeasurement = Omit & { + target: Element; + spacerSize: number; +}; + function findClosestScrollContainer(element: HTMLElement | null): HTMLElement | null { // If we recurse up as far as body or the document root, return null so that the // IntersectionObserver observes intersection with the top-level scroll viewport @@ -445,7 +457,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (pendingAlignLocalIndex !== null) { const pending = pendingAlignLocalIndex; pendingAlignLocalIndex = null; - alignToItemAt(pending); + const result = alignToItemAt(pending); + if (result) { + dotNetHelper.invokeMethodAsync('OnAlignmentCompleted', result); + } return; } @@ -618,7 +633,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac subscribeToScroll(); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); - let pendingCallbacks: Map = new Map(); + const pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; let pendingAlignLocalIndex: number | null = null; @@ -640,17 +655,60 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return el.getBoundingClientRect().top - containerTop; } - function reportRenderedContentMeasurement(): void { - const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + function measureRenderedWindow(containerExtent: number, scaleFactor: number): Omit | null { + const beforeVersion = spacerBefore.getAttribute(renderedWindowVersionAttribute); + const afterVersion = spacerAfter.getAttribute(renderedWindowVersionAttribute); + if (beforeVersion === null || afterVersion === null || beforeVersion !== afterVersion) { + return null; + } + const renderedWindowVersion = Number(beforeVersion); + if (!Number.isSafeInteger(renderedWindowVersion)) { + return null; + } + rangeBetweenSpacers.setStartAfter(spacerBefore); rangeBetweenSpacers.setEndBefore(spacerAfter); const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor; - const containerSize = scrollElement.getBoundingClientRect().height / scaleFactor; - dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', 0, spacerSeparation, containerSize, SpacerVisibilityReason.RenderedContentMeasurement); + const containerSize = containerExtent / scaleFactor; + return { spacerSeparation, containerSize, renderedWindowVersion }; + } + + function measureIntersectionTargets(targets: Element[]): IntersectionMeasurement[] { + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + const viewport = getViewportBounds(scaleFactor); + const measurement = measureRenderedWindow(getEffectiveRootExtent(viewport, scaleFactor), scaleFactor); + if (!measurement) { + return []; + } + + const margin = rootMargin * scaleFactor; + const intersectionTop = viewport.top - margin; + const intersectionBottom = viewport.bottom + margin; + const measurements: IntersectionMeasurement[] = []; + + for (const target of targets) { + if (!target.isConnected || (target !== spacerBefore && target !== spacerAfter)) { + continue; + } + + const targetRect = target.getBoundingClientRect(); + const targetIntersectionTop = Math.max(targetRect.top, intersectionTop); + const targetIntersectionBottom = Math.min(targetRect.bottom, intersectionBottom); + if (targetIntersectionBottom < targetIntersectionTop) { + continue; + } + + const spacerSize = target === spacerBefore + ? (targetIntersectionTop - targetRect.top) / scaleFactor + : (targetRect.bottom - targetIntersectionBottom) / scaleFactor; + measurements.push({ target, spacerSize, ...measurement }); + } + + return measurements; } // Measures the target's viewport-relative top and aligns it to containerTop. - function alignToItemAt(localIndex: number): number | null { + function alignToItemAt(localIndex: number): AlignmentResult | null { function beginAlign(): void { scrollActivity.ignoreNextScroll(); scrollActivity.source = ScrollSource.AlignToItem; @@ -669,7 +727,11 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } pendingAlignLocalIndex = null; - reportRenderedContentMeasurement(); + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + const measurement = measureRenderedWindow(getEffectiveRootExtent(getViewportBounds(scaleFactor), scaleFactor), scaleFactor); + if (!measurement) { + return null; + } if (Math.abs(delta) > 0.5) { beginAlign(); @@ -678,7 +740,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement.scrollTo({ top: scrollElement.scrollTop + delta, behavior: 'instant' }); } - return getViewportFillDirection(); + return { + fillDirection: getViewportFillDirection(), + ...measurement, + }; } function getViewportBounds(scaleFactor: number): { top: number; bottom: number } { @@ -692,6 +757,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return { top: viewportTop, bottom: viewportBottom }; } + function getEffectiveRootExtent(viewport: { top: number; bottom: number }, scaleFactor: number): number { + return viewport.bottom - viewport.top + (2 * rootMargin * scaleFactor); + } + function occupiesViewport(spacer: HTMLElement, viewport: { top: number; bottom: number }): boolean { const spacerRect = spacer.getBoundingClientRect(); return Math.min(spacerRect.bottom, viewport.bottom) > Math.max(spacerRect.top, viewport.top); @@ -720,6 +789,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac restoreAnchor: restoreAnchorForShift, alignToItem: alignToItemAt, beginProgrammaticScroll: beginProgrammaticScroll, + reobserveSpacers, anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null, onDispose: () => { mutationObserver.disconnect(); @@ -737,14 +807,19 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac }; function flushPendingCallbacks(): void { - if (pendingCallbacks.size === 0) return; - const entries = Array.from(pendingCallbacks.values()); + if (pendingCallbacks.size === 0) { + return; + } + const measurements = Array.from(pendingCallbacks.values()) + .filter((measurement): measurement is IntersectionMeasurement => measurement !== null); pendingCallbacks.clear(); - processIntersectionEntries(entries); + processIntersectionEntries(measurements); } function intersectionCallback(entries: IntersectionObserverEntry[]): void { - entries.forEach(entry => pendingCallbacks.set(entry.target, entry)); + const measurements = measureIntersectionTargets(entries.map(entry => entry.target)); + const measurementsByTarget = new Map(measurements.map(measurement => [measurement.target, measurement])); + entries.forEach(entry => pendingCallbacks.set(entry.target, measurementsByTarget.get(entry.target) ?? null)); if (!callbackTimeout) { flushPendingCallbacks(); @@ -843,7 +918,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac observersByDotNetObjectId[id].anchorSnapshot = null; } - function processIntersectionEntries(entries: IntersectionObserverEntry[]): void { + function processIntersectionEntries(measurements: IntersectionMeasurement[]): void { // Check if the spacers are still in the DOM. They may have been removed if the component was disposed. if (!spacerBefore.isConnected || !spacerAfter.isConnected) { return; @@ -862,56 +937,41 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac updateAnchorSnapshot(); } - const bothSpacersIntersect = entries.some(entry => entry.target === spacerBefore && entry.isIntersecting) - && entries.some(entry => entry.target === spacerAfter && entry.isIntersecting); + const bothSpacersIntersect = measurements.some(({ target }) => target === spacerBefore) + && measurements.some(({ target }) => target === spacerAfter); - const intersectingEntries = entries.filter(entry => { - if (bothSpacersIntersect && entry.target === spacerAfter) { + const intersectingMeasurements = measurements.filter(({ target }) => { + if (bothSpacersIntersect && target === spacerAfter) { // When both spacers are visible, report only the before spacer to avoid conflicting callbacks. return false; } - if (entry.isIntersecting) { - if (!isSelfScroll) { - // Convergence to the top/bottom edge should not fight with self scroll. - if (entry.target === spacerAfter) { - updateBottomConvergence(source === ScrollSource.UserScroll); - } else if (entry.target === spacerBefore) { - updateTopConvergence(); - } + if (!isSelfScroll) { + // Convergence to the top/bottom edge should not fight with self scroll. + if (target === spacerAfter) { + updateBottomConvergence(source === ScrollSource.UserScroll); + } else if (target === spacerBefore) { + updateTopConvergence(); } - return true; } - if (entry.target === spacerAfter && convergence.bottom && spacerAfter.offsetHeight > 0) { - scrollElement.scrollTop = scrollElement.scrollHeight; - } else if (entry.target === spacerBefore && convergence.top && spacerBefore.offsetHeight > 0) { - scrollElement.scrollTop = 0; - } - return false; + return true; }); - if (intersectingEntries.length === 0) { + if (intersectingMeasurements.length === 0) { if (source === ScrollSource.AlignToItem) { scrollActivity.clear(); } return; } - const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); - - rangeBetweenSpacers.setStartAfter(spacerBefore); - rangeBetweenSpacers.setEndBefore(spacerAfter); - const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor; - - intersectingEntries.forEach((entry): void => { - const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; + intersectingMeasurements.forEach((measurement): void => { const reason = source === ScrollSource.UserScroll ? SpacerVisibilityReason.UserScroll - : (isSelfScroll && (entry.target === spacerBefore || source === ScrollSource.RestoreSnapshot)) + : (isSelfScroll && (measurement.target === spacerBefore || source === ScrollSource.RestoreSnapshot)) ? SpacerVisibilityReason.ProgrammaticScroll : SpacerVisibilityReason.ViewportFill; - const isBefore = entry.target === spacerBefore; + const isBefore = measurement.target === spacerBefore; const spacer = isBefore ? spacerBefore : spacerAfter; if (!isBefore && spacer.offsetHeight === 0) { @@ -927,11 +987,20 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - const spacerSize = isBefore - ? (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor - : (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; const methodName = isBefore ? 'OnSpacerBeforeVisible' : 'OnSpacerAfterVisible'; - dotNetHelper.invokeMethodAsync(methodName, spacerSize, spacerSeparation, containerSize, reason); + const callback = dotNetHelper.invokeMethodAsync( + methodName, + measurement.spacerSize, + measurement.spacerSeparation, + measurement.containerSize, + reason, + measurement.renderedWindowVersion + ); + void Promise.resolve(callback).then(isCurrentMeasurement => { + if (isCurrentMeasurement === false) { + reobserveSpacers(); + } + }); }); if (source === ScrollSource.AlignToItem) { @@ -976,7 +1045,7 @@ function restoreAnchor(dotNetHelper: DotNet.DotNetObject): void { entry?.restoreAnchor?.(); } -function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): number | null { +function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): AlignmentResult | null { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); return observersByDotNetObjectId[id]?.alignToItem?.(localIndex) ?? null; } diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 27039e90e73b..922ae917a431 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -1,6 +1,8 @@ -import { expect, test, describe } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { Virtualize } from '../src/Virtualize'; +const renderedWindowVersionAttribute = 'data-blazor-virtualize-rendered-window-version'; + describe('Virtualize exports', () => { test('exports expected functions', () => { expect(typeof Virtualize.init).toBe('function'); @@ -11,3 +13,346 @@ describe('Virtualize exports', () => { expect(typeof Virtualize.restoreAnchor).toBe('function'); }); }); + +describe('Virtualize intersection measurements', () => { + let intersectionCallback: IntersectionObserverCallback; + let spacerSeparation: number; + let observe: jest.Mock; + let unobserve: jest.Mock; + + beforeEach(() => { + document.body.innerHTML = ''; + spacerSeparation = 600; + observe = jest.fn(); + unobserve = jest.fn(); + invokeMethodAsync.mockReset().mockResolvedValue(true); + + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { supports: () => true }, + }); + Object.defineProperty(globalThis, 'IntersectionObserver', { + configurable: true, + value: class { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + + observe(target: Element) { observe(target); } + unobserve(target: Element) { unobserve(target); } + disconnect() {} + }, + }); + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + + jest.spyOn(document, 'createRange').mockReturnValue({ + setStartAfter() {}, + setEndBefore() {}, + getBoundingClientRect: () => rect(0, spacerSeparation), + } as unknown as Range); + }); + + afterEach(() => { + Virtualize.dispose(dotNetHelper); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + const invokeMethodAsync = jest.fn<(...args: unknown[]) => Promise>(); + const dotNetHelper = { + _callDispatcher: {}, + _id: 1, + dispose: jest.fn(), + invokeMethodAsync, + } as any; + + test('remeasures live DOM when processing a sampled observer entry', () => { + const container = document.createElement('div'); + container.style.overflowY = 'auto'; + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + container.append(spacerBefore, item, spacerAfter); + document.body.append(container); + + setElementMetrics(container, rect(0, 200), 200); + setElementMetrics(spacerBefore, rect(-300, 320), 320); + setElementMetrics(item, rect(20, 50), 50); + setElementMetrics(spacerAfter, rect(700, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + + invokeMethodAsync.mockClear(); + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter); + + const sampledEntry = { + target: spacerBefore, + isIntersecting: true, + boundingClientRect: rect(-300, 320), + intersectionRect: rect(-50, 70), + rootBounds: rect(-50, 300), + } as unknown as IntersectionObserverEntry; + + setElementMetrics(spacerBefore, rect(-100, 120), 120); + spacerSeparation = 500; + spacerBefore.setAttribute(renderedWindowVersionAttribute, '2'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '2'); + + intersectionCallback([sampledEntry], {} as IntersectionObserver); + + expect(invokeMethodAsync).toHaveBeenCalledWith( + 'OnSpacerBeforeVisible', + 50, + 500, + 300, + 2, + 2); + }); + + test('uses effective viewport height when the document is the scroll root', () => { + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + document.body.append(spacerBefore, item, spacerAfter); + + setElementMetrics(spacerBefore, rect(-100, 120), 120); + setElementMetrics(item, rect(20, 50), 50); + setElementMetrics(spacerAfter, rect(1000, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue(rect(0, 31000)); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(900); + + invokeMethodAsync.mockClear(); + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter); + + intersectionCallback([{ + target: spacerBefore, + isIntersecting: true, + } as unknown as IntersectionObserverEntry], {} as IntersectionObserver); + + expect(invokeMethodAsync).toHaveBeenCalledWith( + 'OnSpacerBeforeVisible', + 50, + 600, + 1000, + 2, + 1); + }); + + test('uses effective viewport height when aligning with the document as the scroll root', () => { + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + document.body.append(spacerBefore, item, spacerAfter); + + setElementMetrics(spacerBefore, rect(-100, 100), 100); + setElementMetrics(item, rect(0, 50), 50); + setElementMetrics(spacerAfter, rect(1100, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue(rect(0, 31000)); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(900); + + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter); + + expect(Virtualize.alignToItem(dotNetHelper, 0)).toEqual({ + fillDirection: 0, + spacerSeparation: 600, + containerSize: 1000, + renderedWindowVersion: 1, + }); + }); + + test('clears alignment state when deferred targets no longer intersect', () => { + jest.useFakeTimers(); + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + document.body.append(spacerBefore, item, spacerAfter); + + setElementMetrics(spacerBefore, rect(-10, 20), 20); + setElementMetrics(item, rect(10, 50), 50); + setElementMetrics(spacerAfter, rect(1000, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(300); + + invokeMethodAsync.mockClear(); + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter, 0); + const entry = { + target: spacerBefore, + isIntersecting: true, + } as unknown as IntersectionObserverEntry; + intersectionCallback([entry], {} as IntersectionObserver); + invokeMethodAsync.mockClear(); + + Virtualize.beginProgrammaticScroll(dotNetHelper); + setElementMetrics(spacerBefore, rect(-200, 20), 20); + intersectionCallback([entry], {} as IntersectionObserver); + jest.advanceTimersByTime(50); + + setElementMetrics(spacerBefore, rect(-10, 20), 20); + intersectionCallback([entry], {} as IntersectionObserver); + + expect(invokeMethodAsync).toHaveBeenCalledWith( + 'OnSpacerBeforeVisible', + 0, + 600, + 400, + 2, + 1); + }); + + test('dispatches callback-time measurement after the target moves during throttling', () => { + jest.useFakeTimers(); + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + document.body.append(spacerBefore, item, spacerAfter); + + setElementMetrics(spacerBefore, rect(-10, 20), 20); + setElementMetrics(item, rect(10, 50), 50); + setElementMetrics(spacerAfter, rect(1000, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(300); + + invokeMethodAsync.mockClear(); + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter, 0); + const entry = { + target: spacerBefore, + isIntersecting: true, + } as unknown as IntersectionObserverEntry; + intersectionCallback([entry], {} as IntersectionObserver); + invokeMethodAsync.mockClear(); + + setElementMetrics(spacerBefore, rect(-100, 120), 120); + spacerSeparation = 500; + spacerBefore.setAttribute(renderedWindowVersionAttribute, '2'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '2'); + intersectionCallback([entry], {} as IntersectionObserver); + + setElementMetrics(spacerBefore, rect(-200, 20), 20); + spacerSeparation = 400; + spacerBefore.setAttribute(renderedWindowVersionAttribute, '3'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '3'); + jest.advanceTimersByTime(50); + + expect(invokeMethodAsync).toHaveBeenCalledWith( + 'OnSpacerBeforeVisible', + 50, + 500, + 400, + 2, + 2); + }); + + test.each([ + ['edge-adjacent nonzero spacer', -60, 10, true], + ['separated spacer', -61, 10, false], + ['edge-adjacent zero-height spacer', -50, 0, true], + ['overlapping spacer', -59, 10, true], + ])('matches threshold-zero intersection semantics for %s', (_, top, height, expectedCallback) => { + const container = document.createElement('div'); + container.style.overflowY = 'auto'; + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + container.append(spacerBefore, item, spacerAfter); + document.body.append(container); + + setElementMetrics(container, rect(0, 200), 200); + setElementMetrics(spacerBefore, rect(top, height), height); + setElementMetrics(item, rect(20, 50), 50); + setElementMetrics(spacerAfter, rect(1000, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + + invokeMethodAsync.mockClear(); + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter); + intersectionCallback([{ + target: spacerBefore, + isIntersecting: expectedCallback, + } as unknown as IntersectionObserverEntry], {} as IntersectionObserver); + + expect(invokeMethodAsync).toHaveBeenCalledTimes(expectedCallback ? 1 : 0); + }); + + test('reobserves spacers only when managed code rejects a stale measurement', async () => { + jest.useFakeTimers(); + const container = document.createElement('div'); + container.style.overflowY = 'auto'; + const spacerBefore = document.createElement('div'); + const item = document.createElement('div'); + const spacerAfter = document.createElement('div'); + spacerBefore.style.overflowY = 'visible'; + container.append(spacerBefore, item, spacerAfter); + document.body.append(container); + + setElementMetrics(container, rect(0, 200), 200); + setElementMetrics(spacerBefore, rect(-10, 20), 20); + setElementMetrics(item, rect(10, 50), 50); + setElementMetrics(spacerAfter, rect(1000, 100), 100); + spacerBefore.setAttribute(renderedWindowVersionAttribute, '1'); + spacerAfter.setAttribute(renderedWindowVersionAttribute, '1'); + + invokeMethodAsync.mockReset().mockResolvedValueOnce(false); + Virtualize.init(dotNetHelper, spacerBefore, spacerAfter); + observe.mockClear(); + unobserve.mockClear(); + + const entry = { + target: spacerBefore, + isIntersecting: true, + } as unknown as IntersectionObserverEntry; + intersectionCallback([entry], {} as IntersectionObserver); + await Promise.resolve(); + + expect(unobserve).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(50); + observe.mockClear(); + unobserve.mockClear(); + invokeMethodAsync.mockResolvedValueOnce(true); + intersectionCallback([entry], {} as IntersectionObserver); + await Promise.resolve(); + + expect(unobserve).not.toHaveBeenCalled(); + expect(observe).not.toHaveBeenCalled(); + }); +}); + +function rect(top: number, height: number): DOMRect { + return { + x: 0, + y: top, + width: 100, + height, + top, + right: 100, + bottom: top + height, + left: 0, + toJSON() {}, + }; +} + +function setElementMetrics(element: HTMLElement, elementRect: DOMRect, height: number): void { + element.getBoundingClientRect = () => elementRect; + Object.defineProperty(element, 'offsetHeight', { configurable: true, value: height }); + Object.defineProperty(element, 'clientHeight', { configurable: true, value: height }); +} diff --git a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 6ff86ed69bbf..86a2b2273af6 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -5,6 +5,7 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization; internal interface IVirtualizeJsCallbacks { - void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason); - void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason); + bool OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + bool OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + void OnAlignmentCompleted(VirtualizeAlignmentResult result); } diff --git a/src/Components/Web/src/Virtualization/SpacerVisibilityReason.cs b/src/Components/Web/src/Virtualization/SpacerVisibilityReason.cs index 04813a3e6a5e..937e11aaa343 100644 --- a/src/Components/Web/src/Virtualization/SpacerVisibilityReason.cs +++ b/src/Components/Web/src/Virtualization/SpacerVisibilityReason.cs @@ -23,10 +23,4 @@ internal enum SpacerVisibilityReason /// window should grow toward the spacer to fill it. /// ViewportFill = 2, - - /// - /// Not a real spacer-visibility event. Refines the item-size estimate from the already-rendered - /// window without triggering window redistribution. - /// - RenderedContentMeasurement = 3, } diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 1dde437f7666..cb7ddae87a97 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -24,7 +24,7 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal int _itemsBefore; - private int _visibleItemCapacity; + internal int _visibleItemCapacity; // If the client reports a viewport so large that it could show more than MaxItemCount items, // we keep track of the "unused" capacity, which is the amount of blank space we want to leave @@ -32,7 +32,7 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I // then the bottom spacer would always stay visible and the client would request more items in an // infinite (but asynchronous) loop, as it would believe there are more items to render and // enough space to render them into. - private int _unusedItemCapacity; + internal int _unusedItemCapacity; private int _itemCount; @@ -42,7 +42,15 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal int _lastRenderedPlaceholderCount; - private float _itemSize; + internal long _renderedWindowVersion; + + private long _contentRevision; + + private RenderedWindowIdentity? _lastRenderedWindowIdentity; + + private TItem[]? _lastLoadedItemsSnapshot; + + internal float _itemSize; private float _lastSetItemSize; @@ -364,15 +372,8 @@ private async Task EnsureRenderCommittedAsync(bool refetchRequired, Cancellation return null; } - var initialItemSize = _itemSize; - var fillDirection = await _jsInterop.AlignToItemAsync(localIndex, token); - if (_initialIndex.Phase == InitialIndexPhase.Pending && _itemSize != initialItemSize) - { - StateHasChanged(); - return null; - } - - return fillDirection; + var result = await _jsInterop.AlignToItemAsync(localIndex, token); + return ProcessAlignmentResult(result, updateWindowFromViewport: false); } private int ClampToItemRange(int requested) @@ -551,17 +552,25 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) throw oldRefreshException; } + var renderedWindowIdentity = GetRenderedWindowIdentity(); + if (renderedWindowIdentity != _lastRenderedWindowIdentity) + { + _renderedWindowVersion++; + } + var renderedWindowVersion = _renderedWindowVersion; + builder.OpenElement(0, SpacerElement); builder.AddAttribute(1, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(_itemsBefore)); builder.AddAttribute(2, "aria-hidden", "true"); - builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference); + builder.AddAttribute(3, "data-blazor-virtualize-rendered-window-version", renderedWindowVersion); + builder.AddElementReferenceCapture(4, elementReference => _spacerBefore = elementReference); builder.CloseElement(); var lastItemIndex = Math.Min(_itemsBefore + _visibleItemCapacity, _itemCount); var renderIndex = _itemsBefore; var placeholdersBeforeCount = Math.Min(_loadedItemsStartIndex, lastItemIndex); - builder.OpenRegion(3); + builder.OpenRegion(5); // Render placeholders before the loaded items. for (; renderIndex < placeholdersBeforeCount; renderIndex++) @@ -577,7 +586,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) if (_loadedItems != null && !_loading && _itemCount == 0 && _emptyContent != null) { - builder.AddContent(4, _emptyContent); + builder.AddContent(6, _emptyContent); } else if (_loadedItems != null && _itemTemplate != null) { @@ -585,7 +594,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) .Skip(_itemsBefore - _loadedItemsStartIndex) .Take(lastItemIndex - _loadedItemsStartIndex); - builder.OpenRegion(5); + builder.OpenRegion(7); var isFirstRenderedItem = true; foreach (var item in itemsToShow) @@ -607,7 +616,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) _lastRenderedPlaceholderCount = Math.Max(0, lastItemIndex - _itemsBefore - _lastRenderedItemCount); - builder.OpenRegion(6); + builder.OpenRegion(8); // Render the placeholders after the loaded items. for (; renderIndex < lastItemIndex; renderIndex++) @@ -619,34 +628,43 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) var itemsAfter = Math.Max(0, _itemCount - _visibleItemCapacity - _itemsBefore); - builder.OpenElement(7, SpacerElement); - builder.AddAttribute(8, "aria-hidden", "true"); - builder.AddAttribute(9, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(itemsAfter)); + builder.OpenElement(9, SpacerElement); + builder.AddAttribute(10, "aria-hidden", "true"); + builder.AddAttribute(11, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(itemsAfter)); + builder.AddAttribute(12, "data-blazor-virtualize-rendered-window-version", renderedWindowVersion); if (_unusedItemCapacity != 0) { - builder.AddAttribute(10, "data-blazor-virtualize-loop-breaker-transform", GetSpacerHeightPx(_unusedItemCapacity)); + builder.AddAttribute(13, "data-blazor-virtualize-loop-breaker-transform", GetSpacerHeightPx(_unusedItemCapacity)); } - builder.AddElementReferenceCapture(11, elementReference => _spacerAfter = elementReference); + builder.AddElementReferenceCapture(14, elementReference => _spacerAfter = elementReference); builder.CloseElement(); + + _lastRenderedWindowIdentity = GetRenderedWindowIdentity(); } + private RenderedWindowIdentity GetRenderedWindowIdentity() + => new( + _itemsBefore, + _visibleItemCapacity, + _unusedItemCapacity, + _itemCount, + _loadedItemsStartIndex, + _lastRenderedItemCount, + _lastRenderedPlaceholderCount, + _itemSize, + _totalMeasuredHeight, + _measuredItemCount, + _loading, + _contentRevision, + SpacerElement); + private string GetSpacerHeightPx(int itemCount) => (itemCount * GetItemHeight()).ToString(CultureInfo.InvariantCulture); private float GetItemHeight() => _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize; - private void UpdateItemSizeFromRenderedContent(float spacerSize, float spacerSeparation, float containerSize) - { - if (_initialIndex.Phase != InitialIndexPhase.Pending) - { - return; - } - - CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out _, out _, out _); - } - private void CancelInFlightScrollForUserInteraction() { _initialIndex.Abort(); @@ -658,34 +676,39 @@ private void CancelInFlightScrollForUserInteraction() } } - void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason) + bool IVirtualizeJsCallbacks.OnBeforeSpacerVisible( + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason, + long renderedWindowVersion) { - if (reason == SpacerVisibilityReason.RenderedContentMeasurement) - { - UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); - return; - } if (_pendingAnchorRestore) { - return; + return true; } if (_initialIndex.Phase == InitialIndexPhase.None && InitialItemIndex > 0) { - return; + return true; + } + if (reason == SpacerVisibilityReason.UserScroll) + { + CancelInFlightScrollForUserInteraction(); + } + if (renderedWindowVersion != _renderedWindowVersion) + { + return false; } switch (reason) { case SpacerVisibilityReason.ProgrammaticScroll: - return; - case SpacerVisibilityReason.UserScroll: - CancelInFlightScrollForUserInteraction(); - break; + return true; case SpacerVisibilityReason.ViewportFill: // A fill callback while our own scroll is in flight is a side effect of that scroll — // acting on it would move the target. if (_currentScrollCts is not null) { - return; + return true; } break; } @@ -698,7 +721,7 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer ViewportFillDirection.Before, visibleItemCapacity, unusedItemCapacity); - return; + return true; } // Slide window up by at least one if spacer is visible but position unchanged. @@ -708,28 +731,33 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer } UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); + return true; } - void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason) + bool IVirtualizeJsCallbacks.OnAfterSpacerVisible( + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason, + long renderedWindowVersion) { - if (reason == SpacerVisibilityReason.RenderedContentMeasurement) - { - UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); - return; - } if (_pendingAnchorRestore || reason == SpacerVisibilityReason.ProgrammaticScroll) { - return; + return true; } if (reason == SpacerVisibilityReason.UserScroll) { CancelInFlightScrollForUserInteraction(); } - else if (reason == SpacerVisibilityReason.ViewportFill && _currentScrollCts is not null) + if (renderedWindowVersion != _renderedWindowVersion) + { + return false; + } + if (reason == SpacerVisibilityReason.ViewportFill && _currentScrollCts is not null) { // Bottom-spacer fill while our own scroll is in flight: the window moved but scrollTop hasn't // landed, so acting on it would undo the target. The real fill runs once the scroll completes. - return; + return true; } var hadNewMeasurements = CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); @@ -739,7 +767,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS ViewportFillDirection.After, visibleItemCapacity, unusedItemCapacity); - return; + return true; } var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity); @@ -762,6 +790,58 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS } UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); + return true; + } + + void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult result) + { + if (_initialIndex.Phase != InitialIndexPhase.Pending) + { + return; + } + + ProcessAlignmentResult(result, updateWindowFromViewport: true); + } + + private ViewportFillDirection? ProcessAlignmentResult( + VirtualizeAlignmentResult? result, + bool updateWindowFromViewport) + { + if (result is null || result.RenderedWindowVersion != _renderedWindowVersion) + { + return null; + } + + var initialItemSize = _itemSize; + var visibleItemCapacity = _visibleItemCapacity; + var unusedItemCapacity = _unusedItemCapacity; + if (_initialIndex.Phase == InitialIndexPhase.Pending) + { + CalculateItemDistribution( + 0, + result.SpacerSeparation, + result.ContainerSize, + out _, + out visibleItemCapacity, + out unusedItemCapacity); + } + + if (_initialIndex.Phase == InitialIndexPhase.Pending + && _itemSize != initialItemSize + && _initialIndex.TryBeginRemeasure(_itemSize)) + { + StateHasChanged(); + return null; + } + + _visibleItemCapacity = visibleItemCapacity; + _unusedItemCapacity = unusedItemCapacity; + if (updateWindowFromViewport) + { + UpdateWindowFromViewport(result.FillDirection, _visibleItemCapacity, _unusedItemCapacity); + } + + return result.FillDirection; } private void UpdateWindowFromViewport( @@ -1033,7 +1113,15 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess, CancellationT } _itemCount = result.TotalItemCount; - _loadedItems = result.Items; + var loadedItems = result.Items.ToArray(); + if (_itemsProvider != DefaultItemsProvider + || _lastLoadedItemsSnapshot is null + || !_lastLoadedItemsSnapshot.SequenceEqual(loadedItems)) + { + _contentRevision++; + } + _lastLoadedItemsSnapshot = loadedItems; + _loadedItems = loadedItems; _loadedItemsStartIndex = _itemsBefore; // For DefaultItemsProvider, capture the first loaded item so we can detect @@ -1056,6 +1144,7 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess, CancellationT StateHasChanged(); } } + catch (Exception e) { if (e is OperationCanceledException oce && oce.CancellationToken == cancellationToken) @@ -1180,6 +1269,21 @@ public async ValueTask DisposeAsync() } } + private readonly record struct RenderedWindowIdentity( + int ItemsBefore, + int VisibleItemCapacity, + int UnusedItemCapacity, + int ItemCount, + int LoadedItemsStartIndex, + int LastRenderedItemCount, + int LastRenderedPlaceholderCount, + float ItemSize, + float TotalMeasuredHeight, + int MeasuredItemCount, + bool IsLoading, + long ContentRevision, + string SpacerElement); + private enum InitialIndexPhase { None, @@ -1190,6 +1294,7 @@ private enum InitialIndexPhase private sealed class InitialIndexState { private float _alignItemSize; + private bool _hasRemeasured; public InitialIndexPhase Phase { get; private set; } @@ -1199,6 +1304,19 @@ public void BeginPending(float itemSize) { Phase = InitialIndexPhase.Pending; _alignItemSize = itemSize; + _hasRemeasured = false; + } + + public bool TryBeginRemeasure(float itemSize) + { + if (_hasRemeasured || itemSize == _alignItemSize) + { + return false; + } + + _alignItemSize = itemSize; + _hasRemeasured = true; + return true; } public void Abort() diff --git a/src/Components/Web/src/Virtualization/VirtualizeAlignmentResult.cs b/src/Components/Web/src/Virtualization/VirtualizeAlignmentResult.cs new file mode 100644 index 000000000000..7567b6db2439 --- /dev/null +++ b/src/Components/Web/src/Virtualization/VirtualizeAlignmentResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web.Virtualization; + +internal sealed class VirtualizeAlignmentResult +{ + public ViewportFillDirection FillDirection { get; set; } + + public float SpacerSeparation { get; set; } + + public float ContainerSize { get; set; } + + public long RenderedWindowVersion { get; set; } +} diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index a020df48732d..44a0bb6cf3c1 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -18,6 +18,7 @@ internal sealed class VirtualizeJsInterop : IAsyncDisposable [DynamicDependency(nameof(OnSpacerBeforeVisible))] [DynamicDependency(nameof(OnSpacerAfterVisible))] + [DynamicDependency(nameof(OnAlignmentCompleted))] public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime) { _owner = owner; @@ -31,15 +32,21 @@ public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementRef } [JSInvokable] - public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason) + public bool OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason); + return _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); } [JSInvokable] - public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason) + public bool OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason); + return _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); + } + + [JSInvokable] + public void OnAlignmentCompleted(VirtualizeAlignmentResult result) + { + _owner.OnAlignmentCompleted(result); } public ValueTask ScrollToBottomAsync() @@ -62,9 +69,9 @@ public ValueTask RestoreAnchorAsync() return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.restoreAnchor", _selfReference); } - public ValueTask AlignToItemAsync(int localIndex, CancellationToken cancellationToken = default) + public ValueTask AlignToItemAsync(int localIndex, CancellationToken cancellationToken = default) { - return _jsRuntime.InvokeAsync($"{JsFunctionsPrefix}.alignToItem", cancellationToken, _selfReference, localIndex); + return _jsRuntime.InvokeAsync($"{JsFunctionsPrefix}.alignToItem", cancellationToken, _selfReference, localIndex); } public ValueTask BeginProgrammaticScrollAsync() diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 41a086534179..819c523ebc84 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Reflection; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -94,7 +95,8 @@ public async Task Virtualize_DispatchesExceptionsFromItemsProviderThroughRendere Assert.NotNull(renderedVirtualize); // Simulate a JS spacer callback. - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 50f, 100f, SpacerVisibilityReason.ViewportFill); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( + 10f, 50f, 100f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion); // Validate that the exception is dispatched through the renderer. var ex = await Assert.ThrowsAsync(async () => await testRenderer.RenderRootComponentAsync(componentId)); @@ -124,12 +126,12 @@ public async Task Virtualize_MeasurementsUpdateRunningAverage() // First callback triggers distribution calculation and re-render await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 80f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 80f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); // Second callback — with items now rendered, CalculateItemDistribution derives // item heights from spacerSeparation and _lastRenderedItemCount await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 80f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 80f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.True(virtualize._totalMeasuredHeight > 0); Assert.True(virtualize._measuredItemCount > 0); @@ -159,9 +161,9 @@ public async Task Virtualize_ZeroSpacerSeparationDoesNotCorruptAverage() // First callback with valid spacerSeparation establishes measurements await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var heightAfterValid = virtualize._totalMeasuredHeight; var countAfterValid = virtualize._measuredItemCount; @@ -170,7 +172,7 @@ await testRenderer.Dispatcher.InvokeAsync(() => // Callback with zero spacerSeparation should not add measurements // (realItemHeight would be zero or negative) await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 0f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 0f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.Equal(heightAfterValid, virtualize._totalMeasuredHeight); Assert.Equal(countAfterValid, virtualize._measuredItemCount); @@ -196,7 +198,7 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var countBefore = requests.Count; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(100f, 300f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(100f, 300f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.True(requests.Count > countBefore, "ItemsProvider should be called when before spacer becomes visible with measurements"); @@ -220,10 +222,10 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.Contains(requests, r => r.StartIndex > 0); } @@ -247,16 +249,16 @@ ValueTask> trackingProvider(ItemsProviderRequest reques // Establish baseline await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var countAfterBaseline = requests.Count; var heightBefore = virtualize._totalMeasuredHeight; // NaN spacerSeparation should not corrupt measurements or crash await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, float.NaN, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, float.NaN, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, float.NaN, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(50f, float.NaN, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.True(requests.Count > countAfterBaseline, "Component should still process callbacks after NaN spacerSeparation"); @@ -280,17 +282,17 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var heightBefore = virtualize._totalMeasuredHeight; var countBefore = virtualize._measuredItemCount; // Negative spacerSeparation produces negative realItemHeight — should not // accumulate into the running average. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, -500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, -500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, -100f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(50f, -100f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); // Measurements should not have changed from the negative inputs Assert.Equal(heightBefore, virtualize._totalMeasuredHeight); @@ -315,16 +317,16 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 100f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var countAfterBaseline = requests.Count; // Extremely large (infinity) spacerSeparation — component should handle // without overflow or crash. MaxItemCount caps the visible capacity. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, float.PositiveInfinity, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, float.PositiveInfinity, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, float.PositiveInfinity, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(50f, float.PositiveInfinity, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.True(requests.Count > countAfterBaseline, "Component should still process callbacks after infinity spacerSeparation"); @@ -352,7 +354,8 @@ public async Task Virtualize_RendersItemsWithoutWrapperElements() Assert.NotNull(renderedVirtualize); await testRenderer.Dispatcher.InvokeAsync(() => - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, SpacerVisibilityReason.ViewportFill)); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( + 0f, 150f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); // Items should be rendered directly without wrapper elements var hasWrapperElements = testRenderer.Batches @@ -387,7 +390,8 @@ public async Task Virtualize_TableSpacerElement_RendersItemsDirectlyWithTrSpacer Assert.NotNull(renderedVirtualize); await testRenderer.Dispatcher.InvokeAsync(() => - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, SpacerVisibilityReason.ViewportFill)); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( + 0f, 150f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); @@ -422,7 +426,7 @@ public async Task Virtualize_RefreshDataAsync_ResetsRunningAverage() for (int i = 0; i < 10; i++) { await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 90f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 90f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); } // After several cycles, measurements should have accumulated @@ -461,12 +465,12 @@ public async Task Virtualize_ScrollToBottom_SetWhenAtEndWithNewMeasurements() // First callback triggers items to render await testRenderer.Dispatcher.InvokeAsync(() => ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( - 0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + 0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); // 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, SpacerVisibilityReason.ViewportFill)); + 0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); var scrollToBottomCalled = mockJs.Invocations.Any(i => i.Arguments.Count > 0 && @@ -485,7 +489,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, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(5000f, 1000f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.False(virtualize._pendingScrollToBottom); } @@ -514,7 +518,7 @@ public async Task Virtualize_ScrollToBottom_NotSetWhenMeasurementsNotApplied() // First call: real measurements at the bottom — should set pending await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); Assert.True(renderedVirtualize._lastRenderedItemCount > 0, "Items should have rendered"); @@ -523,7 +527,7 @@ await testRenderer.Dispatcher.InvokeAsync(() => // 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, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 0f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); Assert.False(renderedVirtualize._pendingScrollToBottom, "scrollToBottom should not be set when no new measurements were applied"); @@ -549,7 +553,7 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callCountAfterMount = requests.Count; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(50f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(50f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Assert.Equal(callCountAfterMount + 1, requests.Count); @@ -581,13 +585,13 @@ public async Task Virtualize_BothSpacersVisible_SmallItemCountDoesNotCrash() var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(0f, 150f, 1000f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(0f, 150f, 1000f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); // After multiple callbacks, measurements should accumulate Assert.True(renderedVirtualize._measuredItemCount > 0); @@ -622,11 +626,11 @@ public async Task Virtualize_FixedItems_MeasurementsAccumulateWithoutBreakingRen // First callback triggers render with items (setting _lastRenderedItemCount) await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 1000f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 1000f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); // Second callback accumulates measurements from spacerSeparation await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 1000f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 1000f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); Assert.True(renderedVirtualize._totalMeasuredHeight > 0); Assert.True(renderedVirtualize._measuredItemCount > 0); @@ -663,13 +667,13 @@ ValueTask> delayedProvider(ItemsProviderRequest request var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 0f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 0f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); Assert.Single(pendingCalls); var firstCall = pendingCalls[0]; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 0f, 1000f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 0f, 1000f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); Assert.Equal(2, pendingCalls.Count); var secondCall = pendingCalls[1]; @@ -718,18 +722,350 @@ ValueTask> trackingProvider(ItemsProviderRequest reques var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); var lastRequest = requests.Last(); Assert.True(lastRequest.Count <= 50, $"Expected request count <= 50 (MaxItemCount=20 + 2*OverscanCount=30), but got {lastRequest.Count}"); } + [Fact] + public async Task AlignmentResult_StaleVersionIsIgnoredAndCurrentVersionIsAccepted() + { + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, + totalItems: 100, + initialItemIndex: 90); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + var currentVersion = virtualize._renderedWindowVersion; + var initialState = GetVirtualizeState(virtualize); + var measuredHeight = virtualize._lastRenderedItemCount * 40f; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAlignmentCompleted(new VirtualizeAlignmentResult + { + FillDirection = ViewportFillDirection.Before, + SpacerSeparation = measuredHeight, + ContainerSize = 500f, + RenderedWindowVersion = currentVersion - 1, + })); + + Assert.Equal(initialState, GetVirtualizeState(virtualize)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAlignmentCompleted(new VirtualizeAlignmentResult + { + FillDirection = ViewportFillDirection.Before, + SpacerSeparation = measuredHeight, + ContainerSize = 500f, + RenderedWindowVersion = currentVersion, + })); + + var currentState = GetVirtualizeState(virtualize); + Assert.True( + currentState.ItemsBefore != initialState.ItemsBefore + || currentState.VisibleItemCapacity != initialState.VisibleItemCapacity); + } + + [Fact] + public async Task AlignmentResult_AfterUserScrollAbortsInitialAlignment_IsIgnored() + { + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, + totalItems: 100, + initialItemIndex: 90); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 500f, + 500f, + SpacerVisibilityReason.UserScroll, + virtualize._renderedWindowVersion)); + + var stateAfterUserScroll = GetVirtualizeState(virtualize); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAlignmentCompleted(new VirtualizeAlignmentResult + { + FillDirection = ViewportFillDirection.Before, + SpacerSeparation = virtualize._lastRenderedItemCount * 40f, + ContainerSize = 500f, + RenderedWindowVersion = virtualize._renderedWindowVersion, + })); + + Assert.Equal(stateAfterUserScroll, GetVirtualizeState(virtualize)); + } + + [Fact] + public async Task BeforeSpacerCallback_UsesVersionIdentityWhenRenderedItemCountsMatch() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 500); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 500f, + 500f, + SpacerVisibilityReason.ViewportFill, + virtualize._renderedWindowVersion)); + + var oldVersion = virtualize._renderedWindowVersion; + var oldRenderedItemCount = virtualize._lastRenderedItemCount; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible( + 5000f, + 2000f, + 500f, + SpacerVisibilityReason.ViewportFill, + oldVersion)); + + Assert.True(virtualize._renderedWindowVersion > oldVersion); + Assert.Equal(oldRenderedItemCount, virtualize._lastRenderedItemCount); + var currentState = GetVirtualizeState(virtualize); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible( + 0f, + 100f, + 1000f, + SpacerVisibilityReason.UserScroll, + oldVersion)); + + Assert.Equal(currentState, GetVirtualizeState(virtualize)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible( + 0f, + 100f, + 1000f, + SpacerVisibilityReason.UserScroll, + virtualize._renderedWindowVersion)); + + Assert.NotEqual(currentState, GetVirtualizeState(virtualize)); + } + + [Fact] + public async Task AfterSpacerCallback_StaleVersionIsIgnoredAndCurrentVersionIsAccepted() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 500); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + var currentVersion = virtualize._renderedWindowVersion; + var initialState = GetVirtualizeState(virtualize); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 1000f, + 1000f, + SpacerVisibilityReason.ViewportFill, + currentVersion - 1)); + + Assert.Equal(initialState, GetVirtualizeState(virtualize)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 1000f, + 1000f, + SpacerVisibilityReason.ViewportFill, + currentVersion)); + + Assert.NotEqual(initialState, GetVirtualizeState(virtualize)); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task SpacerCallback_GeometryNeutralRerenderPreservesVersion(bool beforeSpacer, bool useItemsProvider) + { + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, + totalItems: 500, + useItemsProvider: useItemsProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible( + 5000f, + 500f, + 500f, + SpacerVisibilityReason.ViewportFill, + virtualize._renderedWindowVersion)); + + var renderedWindowVersion = virtualize._renderedWindowVersion; + await renderer.Dispatcher.InvokeAsync(() => virtualize.SetParametersAsync(ParameterView.Empty)); + + Assert.Equal(renderedWindowVersion, virtualize._renderedWindowVersion); + var stateBeforeCallback = GetVirtualizeState(virtualize); + + await renderer.Dispatcher.InvokeAsync(() => + { + if (beforeSpacer) + { + callbacks.OnBeforeSpacerVisible(2500f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedWindowVersion); + } + else + { + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedWindowVersion); + } + }); + + Assert.NotEqual(stateBeforeCallback, GetVirtualizeState(virtualize)); + } + + [Fact] + public async Task SpacerCallback_ItemSizeChangeAdvancesVersionAndRearmsStaleMeasurement() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 500); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + var staleVersion = virtualize._renderedWindowVersion; + virtualize._itemSize = 60f; + await renderer.Dispatcher.InvokeAsync(() => virtualize.SetParametersAsync(ParameterView.Empty)); + + Assert.True(virtualize._renderedWindowVersion > staleVersion); + var stateBeforeCallback = GetVirtualizeState(virtualize); + var accepted = await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 1000f, + 1000f, + SpacerVisibilityReason.ViewportFill, + staleVersion)); + + Assert.False(accepted); + Assert.Equal(stateBeforeCallback, GetVirtualizeState(virtualize)); + } + + [Fact] + public async Task RefreshData_AdvancesVersionWhenWindowGeometryIsUnchanged() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 500); + var renderedWindowVersion = virtualize._renderedWindowVersion; + + await virtualize.RefreshDataAsync(); + await renderer.Dispatcher.InvokeAsync(() => virtualize.SetParametersAsync(ParameterView.Empty)); + + Assert.True(virtualize._renderedWindowVersion > renderedWindowVersion); + } + + [Fact] + public async Task SpacerCallback_StaleUserScrollCancelsInFlightScroll() + { + var blockProvider = false; + var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requestCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async ValueTask> provider(ItemsProviderRequest request) + { + if (!blockProvider) + { + return new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100); + } + + requestStarted.TrySetResult(); + using var registration = request.CancellationToken.Register(requestCanceled.SetResult); + await Task.Delay(Timeout.InfiniteTimeSpan, request.CancellationToken); + return default; + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, + totalItems: 100, + customProvider: provider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + blockProvider = true; + + Task scrollTask = null; + await renderer.Dispatcher.InvokeAsync(() => { scrollTask = virtualize.ScrollToItemAsync(90); }); + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 500f, + 500f, + SpacerVisibilityReason.UserScroll, + virtualize._renderedWindowVersion - 1)); + + await requestCanceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await scrollTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task SpacerCallback_StaleUserScrollDoesNotCancelWhileAnchorRestoreIsPending() + { + var blockProvider = false; + var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requestCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async ValueTask> provider(ItemsProviderRequest request) + { + if (!blockProvider) + { + return new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100); + } + + requestStarted.TrySetResult(); + using var registration = request.CancellationToken.Register(requestCanceled.SetResult); + await Task.Delay(Timeout.InfiniteTimeSpan, request.CancellationToken); + return default; + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, + totalItems: 100, + customProvider: provider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + blockProvider = true; + using var cts = new CancellationTokenSource(); + + Task scrollTask = null; + await renderer.Dispatcher.InvokeAsync(() => { scrollTask = virtualize.ScrollToItemAsync(90, cts.Token); }); + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + typeof(Virtualize).GetField("_pendingAnchorRestore", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(virtualize, true); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible( + 0f, + 500f, + 500f, + SpacerVisibilityReason.UserScroll, + virtualize._renderedWindowVersion - 1)); + + Assert.False(requestCanceled.Task.IsCompleted); + + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => scrollTask.WaitAsync(TimeSpan.FromSeconds(5))); + } + + private static VirtualizeState GetVirtualizeState(Virtualize virtualize) + => new( + virtualize._itemsBefore, + virtualize._visibleItemCapacity, + virtualize._unusedItemCapacity, + virtualize._lastRenderedItemCount, + virtualize._measuredItemCount, + virtualize._totalMeasuredHeight, + virtualize._itemSize, + virtualize._renderedWindowVersion); + private async Task<(Virtualize virtualize, TestRenderer renderer)> CreateRenderedVirtualize( float itemSize, int totalItems, ItemsProviderDelegate customProvider = null, - RenderFragment childContent = null) + RenderFragment childContent = null, + int initialItemIndex = 0, + bool useItemsProvider = true) { Virtualize renderedVirtualize = null; @@ -740,7 +1076,13 @@ await testRenderer.Dispatcher.InvokeAsync(() => var rootComponent = new VirtualizeTestHostcomponent { - InnerContent = BuildVirtualize(itemSize, provider, null, virtualize => renderedVirtualize = virtualize, childContent) + InnerContent = BuildVirtualize( + itemSize, + useItemsProvider ? provider : null, + useItemsProvider ? null : Enumerable.Range(0, totalItems).ToList(), + virtualize => renderedVirtualize = virtualize, + childContent, + initialItemIndex) }; var serviceProvider = new ServiceCollection() @@ -767,7 +1109,8 @@ private RenderFragment BuildVirtualize( ItemsProviderDelegate itemsProvider, ICollection items, Action> captureRenderedVirtualize = null, - RenderFragment childContent = null) + RenderFragment childContent = null, + int initialItemIndex = 0) => builder => { builder.OpenComponent>(0); @@ -780,6 +1123,11 @@ private RenderFragment BuildVirtualize( builder.AddComponentParameter(5, "ChildContent", childContent); } + if (initialItemIndex != 0) + { + builder.AddComponentParameter(6, "InitialItemIndex", initialItemIndex); + } + if (captureRenderedVirtualize != null) { builder.AddComponentReferenceCapture(4, component => captureRenderedVirtualize(component as Virtualize)); @@ -910,7 +1258,7 @@ ValueTask> growingProvider(ItemsProviderRequest request // Initial IO callback to set up _itemCount await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); var itemsBeforeAfterInit = renderedVirtualize._itemsBefore; @@ -919,7 +1267,7 @@ await testRenderer.Dispatcher.InvokeAsync(() => // IO-driven refresh (NOT RefreshDataAsync) — triggered by spacer becoming visible await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); // _itemsBefore may change due to normal IO redistribution, but should NOT // have been shifted by exactly countDelta (20) which would indicate false @@ -955,7 +1303,7 @@ public async Task Virtualize_DefaultProvider_ValueTypeItem_AppendDoesNotAssumePr // Initial IO callback to set up _itemCount. await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); var itemsBeforeAfterInit = renderedVirtualize._itemsBefore; @@ -964,7 +1312,7 @@ await testRenderer.Dispatcher.InvokeAsync(() => // IO-driven refresh re-reads the in-memory list and observes count growth 100 -> 120. await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 800f, 800f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); var shift = renderedVirtualize._itemsBefore - itemsBeforeAfterInit; Assert.True(shift != 20, @@ -993,7 +1341,7 @@ public async Task ScrollToIndexAsync_NegativeIndexDoesNotThrow() var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Task task = null; await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToItemAsync(-5); }); @@ -1011,7 +1359,7 @@ public async Task ScrollToIndexAsync_IndexBeyondCountDoesNotThrow() var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); Task task = null; await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToItemAsync(99_999); }); @@ -1077,7 +1425,7 @@ async ValueTask> provider(ItemsProviderRequest request) itemSize: 50f, totalItems: 100, customProvider: provider); var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); blockProvider = true; using var cts = new CancellationTokenSource(); @@ -1098,7 +1446,7 @@ public async Task ScrollToIndexAsync_SecondCallDoesNotFaultFirstTask() var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var (firstTask, secondTask) = await renderer.Dispatcher.InvokeAsync(() => { @@ -1179,14 +1527,14 @@ public async Task OnBeforeSpacerVisible_ProgrammaticScroll_DoesNotResetWindow() // Establish measurements, then move the window away from the top via a before-spacer whose size // implies many items above the viewport. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var itemsBeforeAtTarget = virtualize._itemsBefore; Assert.True(itemsBeforeAtTarget > 0, "Precondition: the window should have moved away from the top."); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ProgrammaticScroll)); + callbacks.OnBeforeSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ProgrammaticScroll, virtualize._renderedWindowVersion)); Assert.Equal(itemsBeforeAtTarget, virtualize._itemsBefore); } @@ -1200,15 +1548,15 @@ public async Task OnBeforeSpacerVisible_UserScroll_MovesWindowTowardTop() var callbacks = (IVirtualizeJsCallbacks)virtualize; await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, SpacerVisibilityReason.ViewportFill, virtualize._renderedWindowVersion)); var itemsBeforeAtTarget = virtualize._itemsBefore; Assert.True(itemsBeforeAtTarget > 0, "Precondition: the window should have moved away from the top."); // A UserScroll before-spacer with spacerSize=0 (top of the list in view) moves the window up. await renderer.Dispatcher.InvokeAsync(() => - callbacks.OnBeforeSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.UserScroll)); + callbacks.OnBeforeSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.UserScroll, virtualize._renderedWindowVersion)); Assert.True(virtualize._itemsBefore < itemsBeforeAtTarget, $"A UserScroll before-spacer callback should move the window toward the top; it stayed at {virtualize._itemsBefore}."); @@ -1252,7 +1600,7 @@ public async Task InitialIndex_ShortListThatFits_TargetOnScreenWithoutScroll_Doe // push the window off the top (everything already fits). var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; await testRenderer.Dispatcher.InvokeAsync(() => - callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill)); + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, SpacerVisibilityReason.ViewportFill, renderedVirtualize._renderedWindowVersion)); Assert.Equal(0, renderedVirtualize._itemsBefore); } @@ -1381,6 +1729,15 @@ public async Task Virtualize_SpacersUseDataAttributeNotInlineStyle() Assert.Equal("0", (string)heightAttributes[0].AttributeValue); Assert.True(double.TryParse((string)heightAttributes[1].AttributeValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _)); + var versionAttributes = referenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Attribute + && f.AttributeName == "data-blazor-virtualize-rendered-window-version") + .ToList(); + + Assert.Equal(2, versionAttributes.Count); + Assert.All(versionAttributes, attribute => + Assert.Equal(renderedVirtualize._renderedWindowVersion, Convert.ToInt64(attribute.AttributeValue, CultureInfo.InvariantCulture))); + var inlineStyleAttributes = referenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "style") @@ -1390,4 +1747,14 @@ public async Task Virtualize_SpacersUseDataAttributeNotInlineStyle() var hostStyle = Assert.Single(inlineStyleAttributes); Assert.Equal("overflow: auto; height: 800px;", (string)hostStyle.AttributeValue); } + + private readonly record struct VirtualizeState( + int ItemsBefore, + int VisibleItemCapacity, + int UnusedItemCapacity, + int LastRenderedItemCount, + int MeasuredItemCount, + float TotalMeasuredHeight, + float ItemSize, + long RenderedWindowVersion); } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 3344c3c3b2d0..ee97438f9431 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -231,6 +231,99 @@ void RestoreSpacerCallbackRecorder() => js.ExecuteScript("window.__restoreVirtualizeSpacerRecorder?.()"); } + [Fact] + public void SpacerIntersection_MatchesNativeThresholdZeroEdgeSemantics() + { + Browser.MountTestComponent(); + var js = (IJavaScriptExecutor)Browser; + + var results = (ReadOnlyCollection)js.ExecuteAsyncScript( + """ + const done = arguments[arguments.length - 1]; + const cases = [ + { name: 'edge-adjacent nonzero spacer', top: -10, height: 10 }, + { name: 'separated spacer', top: -11, height: 10 }, + { name: 'edge-adjacent zero-height spacer', top: 0, height: 0 }, + { name: 'overlapping spacer', top: -9, height: 10 }, + ]; + + Promise.all(cases.map(async (testCase, index) => { + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + top: '100px', + left: `${100 + index * 120}px`, + width: '100px', + height: '100px', + overflowY: 'auto', + }); + + const spacerBefore = document.createElement('div'); + Object.assign(spacerBefore.style, { + position: 'absolute', + top: `${testCase.top}px`, + width: '10px', + }); + spacerBefore.setAttribute('data-blazor-virtualize-reserved-height', `${testCase.height}`); + spacerBefore.setAttribute('data-blazor-virtualize-rendered-window-version', '1'); + + const item = document.createElement('div'); + Object.assign(item.style, { + position: 'absolute', + top: '20px', + height: '20px', + }); + + const spacerAfter = document.createElement('div'); + Object.assign(spacerAfter.style, { + position: 'absolute', + top: '500px', + width: '10px', + }); + spacerAfter.setAttribute('data-blazor-virtualize-reserved-height', '10'); + spacerAfter.setAttribute('data-blazor-virtualize-rendered-window-version', '1'); + + container.append(spacerBefore, item, spacerAfter); + document.body.append(container); + + const calls = []; + const helper = { + _callDispatcher: {}, + _id: index + 1, + invokeMethodAsync(methodName, ...args) { + calls.push({ methodName, args }); + return Promise.resolve(); + }, + dispose() {}, + }; + + Blazor._internal.Virtualize.init(helper, spacerBefore, spacerAfter, 0, 0); + await new Promise(resolve => setTimeout(resolve, 100)); + Blazor._internal.Virtualize.dispose(helper); + container.remove(); + + return { + name: testCase.name, + wasReported: calls.some(call => call.methodName === 'OnSpacerBeforeVisible'), + }; + })).then(done); + """); + + Assert.Collection( + results, + result => AssertIntersectionResult(result, "edge-adjacent nonzero spacer", expected: true), + result => AssertIntersectionResult(result, "separated spacer", expected: false), + result => AssertIntersectionResult(result, "edge-adjacent zero-height spacer", expected: true), + result => AssertIntersectionResult(result, "overlapping spacer", expected: true)); + + static void AssertIntersectionResult(object value, string expectedName, bool expected) + { + var result = (Dictionary)value; + Assert.Equal(expectedName, result["name"]); + Assert.Equal(expected, result["wasReported"]); + } + } + [Fact] public void RerendersWhenItemSizeShrinks_Sync() { @@ -4990,7 +5083,7 @@ private void AssertViewportStaysStable( /// /// Waits for the Virtualize render cycle to settle by checking that the rendered - /// item count, scrollTop, and first visible item identity stabilize. + /// item range, item count, scroll extent, scrollTop, and first visible item identity stabilize. /// Use after actions that trigger async rendering (prepend/append with ItemsProvider on Server) /// to ensure anchor restore has completed before making single-shot assertions. /// Pass for containers whose rows are not .item[data-index]. @@ -4998,8 +5091,11 @@ private void AssertViewportStaysStable( private void WaitForRenderToSettle(IWebElement container, IJavaScriptExecutor js, string itemSelector = ".item[data-index]") { long lastScrollTop = -1; + long lastScrollHeight = -1; int lastItemCount = -1; string lastFirstIndex = ""; + string lastFirstRenderedIndex = ""; + string lastLastRenderedIndex = ""; int stableCount = 0; Browser.True(() => @@ -5017,12 +5113,23 @@ private void WaitForRenderToSettle(IWebElement container, IJavaScriptExecutor js break; } } - return { scrollTop: Math.round(c.scrollTop), itemCount: items.length, firstIndex: firstIdx }; + var index = item => item ? item.getAttribute('data-index') || item.textContent : ''; + return { + scrollTop: Math.round(c.scrollTop), + scrollHeight: Math.round(c.scrollHeight), + itemCount: items.length, + firstIndex: firstIdx, + firstRenderedIndex: index(items[0]), + lastRenderedIndex: index(items[items.length - 1]) + }; ", container, itemSelector) as Dictionary; var scrollTop = Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture); + var scrollHeight = Convert.ToInt64(result["scrollHeight"], CultureInfo.InvariantCulture); var itemCount = Convert.ToInt32(result["itemCount"], CultureInfo.InvariantCulture); var firstIndex = result["firstIndex"]?.ToString() ?? ""; + var firstRenderedIndex = result["firstRenderedIndex"]?.ToString() ?? ""; + var lastRenderedIndex = result["lastRenderedIndex"]?.ToString() ?? ""; if (string.IsNullOrEmpty(firstIndex)) { @@ -5031,7 +5138,12 @@ private void WaitForRenderToSettle(IWebElement container, IJavaScriptExecutor js // item to reappear rather than reporting this moment as stable. stableCount = 0; } - else if (scrollTop == lastScrollTop && itemCount == lastItemCount && firstIndex == lastFirstIndex) + else if (scrollTop == lastScrollTop + && scrollHeight == lastScrollHeight + && itemCount == lastItemCount + && firstIndex == lastFirstIndex + && firstRenderedIndex == lastFirstRenderedIndex + && lastRenderedIndex == lastLastRenderedIndex) { stableCount++; } @@ -5041,11 +5153,14 @@ private void WaitForRenderToSettle(IWebElement container, IJavaScriptExecutor js } lastScrollTop = scrollTop; + lastScrollHeight = scrollHeight; lastItemCount = itemCount; lastFirstIndex = firstIndex; + lastFirstRenderedIndex = firstRenderedIndex; + lastLastRenderedIndex = lastRenderedIndex; - // Require 3 consecutive stable reads to account for async provider delays. - return stableCount >= 3; + // Require 5 consecutive stable reads to account for async provider delays and geometry updates. + return stableCount >= 5; }, TimeSpan.FromSeconds(15), "Render cycle did not settle in time"); } @@ -5548,6 +5663,69 @@ private bool ViewportEdgeCoveredByRealItem(IJavaScriptExecutor js, string edge) ", edge); } + [Fact] + public void InitialItemIndex_NearEndResize_DoesNotApplyStaleRenderedWindowMeasurement() + { + Browser.Manage().Logs.GetLog(LogType.Browser); + Browser.MountTestComponent(); + Browser.SetWindowSize(1024, 2900); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + SetStaleMeasurementContainerHeight(js, 2500); + Browser.Exists(By.Id("set-stale-measurement-scenario")).Click(); + Browser.Contains("Stale measurement scenario", () => Browser.Exists(By.Id("status")).Text); + Browser.Exists(By.Id("unload-list")).Click(); + Browser.Exists(By.Id("list-not-loaded")); + SetManualInitialIndex(1990); + Browser.Exists(By.Id("reload-with-initial-index")).Click(); + + AssertViewportState("initial 2500px"); + + for (var cycle = 1; cycle <= 3; cycle++) + { + SetStaleMeasurementContainerHeight(js, 2000); + AssertViewportState($"cycle {cycle} at 2000px"); + SetStaleMeasurementContainerHeight(js, 2500); + AssertViewportState($"cycle {cycle} at 2500px"); + } + + var severeLogs = Browser.Manage().Logs.GetLog(LogType.Browser) + .Where(entry => entry.Level == LogLevel.Severe) + .ToArray(); + Assert.Empty(severeLogs); + + void AssertViewportState(string stage) + { + WaitForRenderToSettle(container, js); + var firstVisibleItem = GetTopRenderedIndex(js); + var topCovered = ViewportEdgeCoveredByRealItem(js, "top"); + var bottomCovered = ViewportEdgeCoveredByRealItem(js, "bottom"); + var scrollHeight = Convert.ToDouble( + js.ExecuteScript("return document.getElementById('scroll-container').scrollHeight;"), + CultureInfo.InvariantCulture); + var atScrollEnd = Convert.ToBoolean( + js.ExecuteScript(""" + const container = document.getElementById('scroll-container'); + return Math.abs(container.scrollTop + container.clientHeight - container.scrollHeight) <= 2; + """), + CultureInfo.InvariantCulture); + + Assert.True(firstVisibleItem == 1950 + && topCovered + && (bottomCovered || atScrollEnd) + && Math.Abs(scrollHeight - 100_000) <= 2, + $"Unexpected viewport state after {stage}: firstVisibleItem={firstVisibleItem}, " + + $"scrollTop={GetScrollTop(js, container)}, scrollHeight={scrollHeight}, " + + $"topCovered={topCovered}, bottomCovered={bottomCovered}, atScrollEnd={atScrollEnd}."); + } + } + + private static void SetStaleMeasurementContainerHeight(IJavaScriptExecutor js, int height) + { + js.ExecuteScript("document.getElementById('scroll-container').style.height = `${arguments[0]}px`;", height); + } + [Fact] public void InitialIndex_PendingGrowth_DoesNotExceedMaxItemCount() { diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor index 2cf5364d4716..d9c36b6d6db3 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -24,7 +24,7 @@
+ style="@GetItemStyle(item)">
Item @item.Index
@@ -42,7 +42,7 @@
+ style="@GetItemStyle(item)">
Item @item.Index
@@ -88,6 +88,7 @@ + @@ -117,6 +118,7 @@ private VirtualizeAnchorMode anchorMode = VirtualizeAnchorMode.Start; private bool useVariableHeight = false; private bool useAverageHeightTrap; + private bool useExactItemSize; private bool useItemsProvider = false; private bool omitItemComparer = false; private int itemSize = 50; @@ -162,6 +164,11 @@ .ToList(); } + private string GetItemStyle(DynamicItem item) + => useExactItemSize + ? $"height: {item.Height}px;" + : $"border: 1px solid gray; padding: 5px; min-height: {item.Height}px;"; + private int GetHeight(int index) { if (useAverageHeightTrap) @@ -347,6 +354,22 @@ statusMessage = "Tall rows before index 100"; } + private void SetStaleMeasurementScenario() + { + itemSize = 50; + maxItemCount = 100; + overscanCount = 3; + useVariableHeight = false; + useAverageHeightTrap = false; + useExactItemSize = true; + items = Enumerable.Range(0, 2000) + .Select(i => new DynamicItem { Index = i, Height = itemSize }) + .ToList(); + nextPrependIndex = -1; + nextAppendIndex = items.Count; + statusMessage = "Stale measurement scenario: 2000 exact 50px items, overscan 3"; + } + private void ExpandVisibleItem() { var target = items.FirstOrDefault(i => i.Index == 3); diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs index 5583d91b0044..382147adc2ab 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs @@ -63,6 +63,11 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS private readonly ManualResetValueTaskSource _appCompletedTaskSource = new(); private readonly Lock _completionLock = new(); + // Published under _completionLock by the abort that first transitions the stream into the Aborted state. It is completed + // once that abort has finished running its side-effects (performed outside the lock). Request finalization waits on + // it before pooling the stream so a late abort can't tear down a transport that has been reused. + private TaskCompletionSource? _abortCompletedTcs; + protected RequestHeaderParsingState _requestHeaderParsingState; public bool EndStreamReceived => (_completionState & StreamCompletionFlags.EndStreamReceived) == StreamCompletionFlags.EndStreamReceived; @@ -120,6 +125,7 @@ public void Initialize(Http3StreamContext context) _eagerRequestHeadersParsedLimit = ServerOptions.Limits.MaxRequestHeaderCount * 2; _isMethodConnect = false; _completionState = default; + _abortCompletedTcs = null; IsReceivingTrailerHeaders = false; StreamTimeoutTimestamp = 0; @@ -167,8 +173,17 @@ public void Abort(ConnectionAbortedException abortReason, Http3ErrorCode errorCo private void AbortCore(Exception exception, Http3ErrorCode errorCode) { + TaskCompletionSource abortCompleted; + lock (_completionLock) { + // Only the completion-state transition is performed under _completionLock. The abort + // side-effects below must run *outside* the lock: _http3Output.Stop() (and the frame + // writer/transport teardown) acquire Http3OutputProducer._dataWriterLock, which is taken + // in the opposite order on the inline output path (Http3OutputProducer.FlushAsync holds + // _dataWriterLock and, via the inline data pipe pump -> QuicStreamContext.FireStreamClosed, + // calls back into AbortCore -> _completionLock). Holding _completionLock across Stop() would + // therefore create a _completionLock <-> _dataWriterLock deadlock. This mirrors Http2Stream. if (IsCompleted || IsAborted) { return; @@ -181,7 +196,14 @@ private void AbortCore(Exception exception, Http3ErrorCode errorCode) return; } - if (!(exception is ConnectionAbortedException abortReason)) + // Publish before releasing the lock so request finalization can observe this in-flight abort + // and wait for the side-effects below to finish before the stream is pooled and reused. + abortCompleted = _abortCompletedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + try + { + if (exception is not ConnectionAbortedException abortReason) { abortReason = new ConnectionAbortedException(exception.Message, exception); } @@ -206,6 +228,28 @@ private void AbortCore(Exception exception, Http3ErrorCode errorCode) // Abort framewriter and underlying transport after stopping output. _frameWriter.Abort(abortReason); } + finally + { + abortCompleted.TrySetResult(); + } + } + + // Marks the stream Completed and waits for any in-flight abort's side-effects to finish. Called by + // request finalization before the transport is drained, disposed and pooled. Setting Completed under + // _completionLock closes the door: any abort arriving afterwards sees IsCompleted and no-ops, so it + // can't mutate a transport that has been reused. An abort that won the race before the door closed is + // captured here and awaited so its Stop()/frame writer Abort() land on this stream, not a reused one. + private ValueTask CompleteAndWaitForAbortAsync() + { + TaskCompletionSource? abortCompleted; + + lock (_completionLock) + { + _completionState |= StreamCompletionFlags.Completed; + abortCompleted = _abortCompletedTcs; + } + + return abortCompleted is null ? default : new ValueTask(abortCompleted.Task); } protected override void OnErrorAfterResponseStarted() @@ -731,11 +775,16 @@ public async Task ProcessRequestAsync(IHttpApplication appli } finally { + // Wait for any in-flight abort to finish mutating this stream's transport/output before + // we drain, dispose and pool it; otherwise a late abort could tear down a stream that has + // already been reused for another request. This also marks the stream Completed so any + // subsequent abort becomes a no-op. + await CompleteAndWaitForAbortAsync(); + // Drain transports and dispose. await _context.StreamContext.DisposeAsync(); // Tells the connection to remove the stream from its active collection. - ApplyCompletionFlag(StreamCompletionFlags.Completed); _context.StreamLifetimeHandler.OnStreamCompleted(this); // If we have a webtransport session on this stream, end it