From ee5949696cd8ecf57cfa28703ea4bbd27d1ab7fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:54:10 +0000 Subject: [PATCH 01/19] Initial plan From de8ed19463ed91e5dc00824dc1b959d19abcb252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Fri, 21 Aug 2026 17:00:43 +0200 Subject: [PATCH 02/19] [Kestrel] Fix h3 connection-level and stream-level abort locking (#68102) * Fix h3 connection-level and stream-level abort locking * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Core/src/Internal/Http3/Http3Stream.cs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) 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 From 60f8f1f77d3a0d30f1175783e4657d1ca3f9893f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:54:10 +0000 Subject: [PATCH 03/19] Initial plan Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> From 638248695dd8e8c55399976d6622ebb1792abc69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:44:25 +0000 Subject: [PATCH 04/19] Reject stale Virtualize viewport measurements Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 106 ++++++++--- .../Virtualization/IVirtualizeJsCallbacks.cs | 6 +- .../Web/src/Virtualization/Virtualize.cs | 86 ++++++--- .../VirtualizeAlignmentResult.cs | 15 ++ .../src/Virtualization/VirtualizeJsInterop.cs | 19 +- .../VirtualizeJsCallbacksTestExtensions.cs | 35 ++++ .../Web/test/Virtualization/VirtualizeTest.cs | 166 +++++++++++++++++- .../VirtualizationRenderModesTest.cs | 134 ++++++++++++++ .../VirtualizationAnchorMode.razor | 35 +++- ...tualizationTransitionToInteractivity.razor | 29 ++- 10 files changed, 563 insertions(+), 68 deletions(-) create mode 100644 src/Components/Web/src/Virtualization/VirtualizeAlignmentResult.cs create mode 100644 src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 4b6fe3d7084a..b4007635939f 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -17,6 +17,7 @@ export const Virtualize = { const dispatcherObserversByDotNetIdPropname = Symbol(); const THROTTLE_MS = 50; +const renderedWindowVersionAttribute = 'data-blazor-virtualize-rendered-window-version'; const SpacerVisibilityReason = { UserScroll: 0, ProgrammaticScroll: 1, @@ -38,6 +39,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 & { + entry: IntersectionObserverEntry; + 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 +458,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 +634,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac subscribeToScroll(); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); - let pendingCallbacks: Map = new Map(); + let pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; let pendingAlignLocalIndex: number | null = null; @@ -640,17 +656,41 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return el.getBoundingClientRect().top - containerTop; } - function reportRenderedContentMeasurement(): void { + function measureRenderedWindow(containerExtent?: 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; + } + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); 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 ?? scrollElement.getBoundingClientRect().height) / scaleFactor; + return { spacerSeparation, containerSize, renderedWindowVersion }; + } + + function measureIntersectionEntry(entry: IntersectionObserverEntry): IntersectionMeasurement | null { + const measurement = measureRenderedWindow(entry.rootBounds?.height); + if (!measurement) { + return null; + } + + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + const isBefore = entry.target === spacerBefore; + const spacerSize = isBefore + ? (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor + : (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; + return { entry, spacerSize, ...measurement }; } // 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 +709,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } pendingAlignLocalIndex = null; - reportRenderedContentMeasurement(); + const measurement = measureRenderedWindow(); + if (!measurement) { + return null; + } if (Math.abs(delta) > 0.5) { beginAlign(); @@ -678,7 +721,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 } { @@ -738,13 +784,18 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac function flushPendingCallbacks(): void { if (pendingCallbacks.size === 0) return; - const entries = Array.from(pendingCallbacks.values()); + const measurements = Array.from(pendingCallbacks.values()); pendingCallbacks.clear(); - processIntersectionEntries(entries); + processIntersectionEntries(measurements); } function intersectionCallback(entries: IntersectionObserverEntry[]): void { - entries.forEach(entry => pendingCallbacks.set(entry.target, entry)); + entries.forEach(entry => { + const measurement = measureIntersectionEntry(entry); + if (measurement) { + pendingCallbacks.set(entry.target, measurement); + } + }); if (!callbackTimeout) { flushPendingCallbacks(); @@ -843,7 +894,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,10 +913,10 @@ 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(({ entry }) => entry.target === spacerBefore && entry.isIntersecting) + && measurements.some(({ entry }) => entry.target === spacerAfter && entry.isIntersecting); - const intersectingEntries = entries.filter(entry => { + const intersectingMeasurements = measurements.filter(({ entry }) => { if (bothSpacersIntersect && entry.target === spacerAfter) { // When both spacers are visible, report only the before spacer to avoid conflicting callbacks. return false; @@ -890,21 +941,15 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return false; }); - 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 { entry } = measurement; const reason = source === ScrollSource.UserScroll ? SpacerVisibilityReason.UserScroll : (isSelfScroll && (entry.target === spacerBefore || source === ScrollSource.RestoreSnapshot)) @@ -927,11 +972,14 @@ 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); + dotNetHelper.invokeMethodAsync( + methodName, + measurement.spacerSize, + measurement.spacerSeparation, + measurement.containerSize, + reason, + measurement.renderedWindowVersion); }); if (source === ScrollSource.AlignToItem) { @@ -976,7 +1024,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/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 6ff86ed69bbf..8ea0c24717f8 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -5,6 +5,8 @@ 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); + long RenderedWindowVersion { get; } + void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + void OnAlignmentCompleted(VirtualizeAlignmentResult result); } diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 1dde437f7666..47e4b4a8e531 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,9 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal int _lastRenderedPlaceholderCount; - private float _itemSize; + internal long _renderedWindowVersion; + + internal float _itemSize; private float _lastSetItemSize; @@ -364,15 +366,9 @@ 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; - } + var result = await _jsInterop.AlignToItemAsync(localIndex, token); - return fillDirection; + return ProcessAlignmentResult(result); } private int ClampToItemRange(int requested) @@ -551,17 +547,20 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) throw oldRefreshException; } + 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 +576,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 +584,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 +606,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,14 +618,15 @@ 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(); } @@ -658,8 +658,20 @@ private void CancelInFlightScrollForUserInteraction() } } - void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason) + long IVirtualizeJsCallbacks.RenderedWindowVersion => _renderedWindowVersion; + + void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason, + long renderedWindowVersion) { + if (renderedWindowVersion != _renderedWindowVersion) + { + return; + } + if (reason == SpacerVisibilityReason.RenderedContentMeasurement) { UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); @@ -710,8 +722,18 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } - void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason) + void IVirtualizeJsCallbacks.OnAfterSpacerVisible( + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason, + long renderedWindowVersion) { + if (renderedWindowVersion != _renderedWindowVersion) + { + return; + } + if (reason == SpacerVisibilityReason.RenderedContentMeasurement) { UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); @@ -764,6 +786,24 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } + void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult result) + { + var fillDirection = ProcessAlignmentResult(result); + UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity); + } + + private ViewportFillDirection? ProcessAlignmentResult(VirtualizeAlignmentResult? result) + { + if (result is null || result.RenderedWindowVersion != _renderedWindowVersion) + { + return null; + } + + UpdateItemSizeFromRenderedContent(0, result.SpacerSeparation, result.ContainerSize); + + return result.FillDirection; + } + private void UpdateWindowFromViewport( ViewportFillDirection? fillDirection, int visibleItemCapacity, 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..2bd678a7d1b6 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 void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason); + _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); } [JSInvokable] - public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason) + public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason); + _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/VirtualizeJsCallbacksTestExtensions.cs b/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs new file mode 100644 index 000000000000..367269f878c6 --- /dev/null +++ b/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Web.Virtualization; + +namespace Microsoft.AspNetCore.Components.Virtualization; + +internal static class VirtualizeJsCallbacksTestExtensions +{ + public static void OnBeforeSpacerVisible( + this IVirtualizeJsCallbacks callbacks, + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason) + => callbacks.OnBeforeSpacerVisible( + spacerSize, + spacerSeparation, + containerSize, + reason, + callbacks.RenderedWindowVersion); + + public static void OnAfterSpacerVisible( + this IVirtualizeJsCallbacks callbacks, + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason) + => callbacks.OnAfterSpacerVisible( + spacerSize, + spacerSeparation, + containerSize, + reason, + callbacks.RenderedWindowVersion); +} diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 41a086534179..ef08441e7cfa 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -725,11 +725,140 @@ await testRenderer.Dispatcher.InvokeAsync(() => $"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 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)); + } + + 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) { Virtualize renderedVirtualize = null; @@ -740,7 +869,13 @@ await testRenderer.Dispatcher.InvokeAsync(() => var rootComponent = new VirtualizeTestHostcomponent { - InnerContent = BuildVirtualize(itemSize, provider, null, virtualize => renderedVirtualize = virtualize, childContent) + InnerContent = BuildVirtualize( + itemSize, + provider, + null, + virtualize => renderedVirtualize = virtualize, + childContent, + initialItemIndex) }; var serviceProvider = new ServiceCollection() @@ -767,7 +902,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 +916,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)); @@ -1381,6 +1522,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 +1540,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/ServerRenderingTests/VirtualizationRenderModesTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs index 4c8641e7064b..0b6d2310120c 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs @@ -83,6 +83,44 @@ public void InitialItemIndex_IsAppliedOnPrerenderedInteractiveServerLoad() } } + [Fact] + public void InitialItemIndex_NearEndResize_DoesNotApplyStaleRenderedWindowMeasurement() + { + Browser.Manage().Logs.GetLog(LogType.Browser); + Navigate($"{ServerPathBase}/interactivity/virtualization?test-stale-measurement=true&initial-item-index=1990"); + + Browser.Exists(By.Id("scroll-container")); + Browser.Exists(By.Id("blazor-started")); + var js = (IJavaScriptExecutor)Browser; + + var states = new List + { + WaitForViewportToSettle(js, "initial 2500px"), + }; + + for (var cycle = 1; cycle <= 3; cycle++) + { + SetContainerHeight(js, 2000); + states.Add(WaitForViewportToSettle(js, $"cycle {cycle} at 2000px")); + SetContainerHeight(js, 2500); + states.Add(WaitForViewportToSettle(js, $"cycle {cycle} at 2500px")); + } + + foreach (var state in states) + { + Assert.True(state.FirstVisibleItem == 1950 + && state.TopCovered + && (state.BottomCovered || state.AtScrollEnd) + && Math.Abs(state.ScrollHeight - 100_000) <= 2, + $"Unexpected viewport state after {state.Stage}: {state}"); + } + + var severeLogs = Browser.Manage().Logs.GetLog(LogType.Browser) + .Where(entry => entry.Level == LogLevel.Severe) + .ToArray(); + Assert.Empty(severeLogs); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -187,6 +225,102 @@ private static void ScrollTopToEnd(IWebDriver browser, IWebElement elem) js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", elem); } + private ViewportState WaitForViewportToSettle(IJavaScriptExecutor js, string stage) + { + ViewportState? previous = null; + var stableReads = 0; + var current = default(ViewportState); + + Browser.True(() => + { + current = GetViewportState(js) with { Stage = stage }; + if (previous is { } previousValue + && current.FirstVisibleItem == previousValue.FirstVisibleItem + && current.FirstRenderedItem == previousValue.FirstRenderedItem + && current.LastRenderedItem == previousValue.LastRenderedItem + && current.RenderedItemCount == previousValue.RenderedItemCount + && current.ScrollHeight == previousValue.ScrollHeight + && current.ScrollTop == previousValue.ScrollTop) + { + stableReads++; + } + else + { + stableReads = 0; + } + + previous = current; + return stableReads >= 5; + }, TimeSpan.FromSeconds(15), $"Viewport did not settle after {stage}. Last state: {current}"); + + return current; + } + + private static ViewportState GetViewportState(IJavaScriptExecutor js) + { + var result = (Dictionary)js.ExecuteScript( + """ + const container = document.getElementById('scroll-container'); + const containerRect = container.getBoundingClientRect(); + const viewportTop = containerRect.top + container.clientTop; + const viewportBottom = viewportTop + container.clientHeight; + const items = Array.from(container.querySelectorAll('.item[data-index]')); + const visibleItems = items.filter(item => { + const rect = item.getBoundingClientRect(); + return rect.bottom > viewportTop + 1 && rect.top < viewportBottom - 1; + }); + const topCovered = items.some(item => { + const rect = item.getBoundingClientRect(); + return rect.top <= viewportTop + 1 && rect.bottom > viewportTop + 1; + }); + const bottomCovered = items.some(item => { + const rect = item.getBoundingClientRect(); + return rect.top < viewportBottom - 1 && rect.bottom >= viewportBottom - 1; + }); + const index = item => item ? Number(item.getAttribute('data-index')) : -1; + return { + firstVisibleItem: index(visibleItems[0]), + firstRenderedItem: index(items[0]), + lastRenderedItem: index(items[items.length - 1]), + renderedItemCount: items.length, + scrollHeight: container.scrollHeight, + scrollTop: container.scrollTop, + topCovered, + bottomCovered, + atScrollEnd: Math.abs(container.scrollTop + container.clientHeight - container.scrollHeight) <= 2, + }; + """); + + return new ViewportState( + Stage: string.Empty, + FirstVisibleItem: Convert.ToInt32(result["firstVisibleItem"], CultureInfo.InvariantCulture), + FirstRenderedItem: Convert.ToInt32(result["firstRenderedItem"], CultureInfo.InvariantCulture), + LastRenderedItem: Convert.ToInt32(result["lastRenderedItem"], CultureInfo.InvariantCulture), + RenderedItemCount: Convert.ToInt32(result["renderedItemCount"], CultureInfo.InvariantCulture), + ScrollHeight: Convert.ToDouble(result["scrollHeight"], CultureInfo.InvariantCulture), + ScrollTop: Convert.ToDouble(result["scrollTop"], CultureInfo.InvariantCulture), + TopCovered: Convert.ToBoolean(result["topCovered"], CultureInfo.InvariantCulture), + BottomCovered: Convert.ToBoolean(result["bottomCovered"], CultureInfo.InvariantCulture), + AtScrollEnd: Convert.ToBoolean(result["atScrollEnd"], CultureInfo.InvariantCulture)); + } + + private static void SetContainerHeight(IJavaScriptExecutor js, int height) + { + js.ExecuteScript("document.getElementById('scroll-container').style.height = `${arguments[0]}px`;", height); + } + + private readonly record struct ViewportState( + string Stage, + int FirstVisibleItem, + int FirstRenderedItem, + int LastRenderedItem, + int RenderedItemCount, + double ScrollHeight, + double ScrollTop, + bool TopCovered, + bool BottomCovered, + bool AtScrollEnd); + private static string InstallInitialSpacerCallbackRace(ChromeDriver chromeDriver) { var result = chromeDriver.ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor index 2cf5364d4716..e4f31ab487bb 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -14,7 +14,7 @@ -
+
@if (listLoaded) { @if (useItemsProvider) @@ -24,7 +24,7 @@
+ style="@GetItemStyle(item)">
Item @item.Index
@@ -42,7 +42,7 @@
+ style="@GetItemStyle(item)">
Item @item.Index
@@ -140,6 +140,24 @@ [Parameter] public int InitialItemIndex { get; set; } + [Parameter] + public int InitialItemCount { get; set; } = 1000; + + [Parameter] + public int InitialContainerHeight { get; set; } = 300; + + [Parameter] + public int InitialOverscanCount { get; set; } = 15; + + [Parameter] + public bool InitiallyUseItemsProvider { get; set; } + + [Parameter] + public bool InitiallyUseVariableHeight { get; set; } + + [Parameter] + public bool UseExactItemSize { get; set; } + private static readonly IReadOnlyDictionary _emptyComparerSplat = new Dictionary(); @@ -157,11 +175,20 @@ protected override void OnInitialized() { initialItemIndex = InitialItemIndex; - items = Enumerable.Range(0, 1000) + overscanCount = InitialOverscanCount; + useItemsProvider = InitiallyUseItemsProvider; + useVariableHeight = InitiallyUseVariableHeight; + items = Enumerable.Range(0, InitialItemCount) .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) .ToList(); + nextAppendIndex = InitialItemCount; } + 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) diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor index aade876ecf52..bed9308d719c 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor @@ -2,7 +2,18 @@ @using BasicTestApp @using TestContentPackage; -@if (TestInitialItemIndex) +@if (TestStaleMeasurement) +{ + +} +else if (TestInitialItemIndex) { } @@ -25,4 +36,20 @@ else [Parameter] [SupplyParameterFromQuery(Name = "test-initial-item-index")] public bool TestInitialItemIndex { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "test-stale-measurement")] + public bool TestStaleMeasurement { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "initial-item-index")] + public int ReproInitialItemIndex { get; set; } = 1990; + + [Parameter] + [SupplyParameterFromQuery(Name = "use-items-provider")] + public bool UseItemsProvider { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "use-variable-height")] + public bool UseVariableHeight { get; set; } } From 2b236ffabc1c6f630fe66565388264dadd4ceb62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:59:53 -0700 Subject: [PATCH 05/19] [main] (deps): Bump dotnet/arcade/.github/workflows/inter-branch-merge-base.yml (#68685) Bumps [dotnet/arcade/.github/workflows/inter-branch-merge-base.yml](https://github.com/dotnet/arcade) from acdb3e708ba600e766667825c84f9fa4a49e6c8f to 1353cab671305cff0ae5afc0d96ff3d03f239e0c. - [Commits](https://github.com/dotnet/arcade/compare/acdb3e708ba600e766667825c84f9fa4a49e6c8f...1353cab671305cff0ae5afc0d96ff3d03f239e0c) --- updated-dependencies: - dependency-name: dotnet/arcade/.github/workflows/inter-branch-merge-base.yml dependency-version: 1353cab671305cff0ae5afc0d96ff3d03f239e0c dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/inter-branch-merge-flow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 62c4132682f492ae086e7e5fa0b0a3f2f3b9c58a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:00:07 -0700 Subject: [PATCH 06/19] [main] (deps): Bump dotnet/arcade/.github/workflows/backport-base.yml (#68686) Bumps [dotnet/arcade/.github/workflows/backport-base.yml](https://github.com/dotnet/arcade) from acdb3e708ba600e766667825c84f9fa4a49e6c8f to 1353cab671305cff0ae5afc0d96ff3d03f239e0c. - [Commits](https://github.com/dotnet/arcade/compare/acdb3e708ba600e766667825c84f9fa4a49e6c8f...1353cab671305cff0ae5afc0d96ff3d03f239e0c) --- updated-dependencies: - dependency-name: dotnet/arcade/.github/workflows/backport-base.yml dependency-version: 1353cab671305cff0ae5afc0d96ff3d03f239e0c dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/backport.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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% From 65596c02da8d95b0f981e999fe043ace91f732d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:54:10 +0000 Subject: [PATCH 07/19] Initial plan Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> From 7cf633ccfc1add7e4a738fef477dd01190ca9a92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:44:25 +0000 Subject: [PATCH 08/19] Reject stale Virtualize viewport measurements Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 106 ++++++++--- .../Virtualization/IVirtualizeJsCallbacks.cs | 6 +- .../Web/src/Virtualization/Virtualize.cs | 86 ++++++--- .../VirtualizeAlignmentResult.cs | 15 ++ .../src/Virtualization/VirtualizeJsInterop.cs | 19 +- .../VirtualizeJsCallbacksTestExtensions.cs | 35 ++++ .../Web/test/Virtualization/VirtualizeTest.cs | 166 +++++++++++++++++- .../VirtualizationRenderModesTest.cs | 134 ++++++++++++++ .../VirtualizationAnchorMode.razor | 35 +++- ...tualizationTransitionToInteractivity.razor | 29 ++- 10 files changed, 563 insertions(+), 68 deletions(-) create mode 100644 src/Components/Web/src/Virtualization/VirtualizeAlignmentResult.cs create mode 100644 src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 4b6fe3d7084a..b4007635939f 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -17,6 +17,7 @@ export const Virtualize = { const dispatcherObserversByDotNetIdPropname = Symbol(); const THROTTLE_MS = 50; +const renderedWindowVersionAttribute = 'data-blazor-virtualize-rendered-window-version'; const SpacerVisibilityReason = { UserScroll: 0, ProgrammaticScroll: 1, @@ -38,6 +39,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 & { + entry: IntersectionObserverEntry; + 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 +458,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 +634,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac subscribeToScroll(); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); - let pendingCallbacks: Map = new Map(); + let pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; let pendingAlignLocalIndex: number | null = null; @@ -640,17 +656,41 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return el.getBoundingClientRect().top - containerTop; } - function reportRenderedContentMeasurement(): void { + function measureRenderedWindow(containerExtent?: 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; + } + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); 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 ?? scrollElement.getBoundingClientRect().height) / scaleFactor; + return { spacerSeparation, containerSize, renderedWindowVersion }; + } + + function measureIntersectionEntry(entry: IntersectionObserverEntry): IntersectionMeasurement | null { + const measurement = measureRenderedWindow(entry.rootBounds?.height); + if (!measurement) { + return null; + } + + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + const isBefore = entry.target === spacerBefore; + const spacerSize = isBefore + ? (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor + : (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; + return { entry, spacerSize, ...measurement }; } // 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 +709,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } pendingAlignLocalIndex = null; - reportRenderedContentMeasurement(); + const measurement = measureRenderedWindow(); + if (!measurement) { + return null; + } if (Math.abs(delta) > 0.5) { beginAlign(); @@ -678,7 +721,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 } { @@ -738,13 +784,18 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac function flushPendingCallbacks(): void { if (pendingCallbacks.size === 0) return; - const entries = Array.from(pendingCallbacks.values()); + const measurements = Array.from(pendingCallbacks.values()); pendingCallbacks.clear(); - processIntersectionEntries(entries); + processIntersectionEntries(measurements); } function intersectionCallback(entries: IntersectionObserverEntry[]): void { - entries.forEach(entry => pendingCallbacks.set(entry.target, entry)); + entries.forEach(entry => { + const measurement = measureIntersectionEntry(entry); + if (measurement) { + pendingCallbacks.set(entry.target, measurement); + } + }); if (!callbackTimeout) { flushPendingCallbacks(); @@ -843,7 +894,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,10 +913,10 @@ 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(({ entry }) => entry.target === spacerBefore && entry.isIntersecting) + && measurements.some(({ entry }) => entry.target === spacerAfter && entry.isIntersecting); - const intersectingEntries = entries.filter(entry => { + const intersectingMeasurements = measurements.filter(({ entry }) => { if (bothSpacersIntersect && entry.target === spacerAfter) { // When both spacers are visible, report only the before spacer to avoid conflicting callbacks. return false; @@ -890,21 +941,15 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return false; }); - 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 { entry } = measurement; const reason = source === ScrollSource.UserScroll ? SpacerVisibilityReason.UserScroll : (isSelfScroll && (entry.target === spacerBefore || source === ScrollSource.RestoreSnapshot)) @@ -927,11 +972,14 @@ 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); + dotNetHelper.invokeMethodAsync( + methodName, + measurement.spacerSize, + measurement.spacerSeparation, + measurement.containerSize, + reason, + measurement.renderedWindowVersion); }); if (source === ScrollSource.AlignToItem) { @@ -976,7 +1024,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/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 6ff86ed69bbf..8ea0c24717f8 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -5,6 +5,8 @@ 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); + long RenderedWindowVersion { get; } + void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + void OnAlignmentCompleted(VirtualizeAlignmentResult result); } diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 1dde437f7666..47e4b4a8e531 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,9 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal int _lastRenderedPlaceholderCount; - private float _itemSize; + internal long _renderedWindowVersion; + + internal float _itemSize; private float _lastSetItemSize; @@ -364,15 +366,9 @@ 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; - } + var result = await _jsInterop.AlignToItemAsync(localIndex, token); - return fillDirection; + return ProcessAlignmentResult(result); } private int ClampToItemRange(int requested) @@ -551,17 +547,20 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) throw oldRefreshException; } + 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 +576,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 +584,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 +606,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,14 +618,15 @@ 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(); } @@ -658,8 +658,20 @@ private void CancelInFlightScrollForUserInteraction() } } - void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason) + long IVirtualizeJsCallbacks.RenderedWindowVersion => _renderedWindowVersion; + + void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason, + long renderedWindowVersion) { + if (renderedWindowVersion != _renderedWindowVersion) + { + return; + } + if (reason == SpacerVisibilityReason.RenderedContentMeasurement) { UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); @@ -710,8 +722,18 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } - void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason) + void IVirtualizeJsCallbacks.OnAfterSpacerVisible( + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason, + long renderedWindowVersion) { + if (renderedWindowVersion != _renderedWindowVersion) + { + return; + } + if (reason == SpacerVisibilityReason.RenderedContentMeasurement) { UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); @@ -764,6 +786,24 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } + void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult result) + { + var fillDirection = ProcessAlignmentResult(result); + UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity); + } + + private ViewportFillDirection? ProcessAlignmentResult(VirtualizeAlignmentResult? result) + { + if (result is null || result.RenderedWindowVersion != _renderedWindowVersion) + { + return null; + } + + UpdateItemSizeFromRenderedContent(0, result.SpacerSeparation, result.ContainerSize); + + return result.FillDirection; + } + private void UpdateWindowFromViewport( ViewportFillDirection? fillDirection, int visibleItemCapacity, 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..2bd678a7d1b6 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 void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason); + _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); } [JSInvokable] - public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason) + public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason); + _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/VirtualizeJsCallbacksTestExtensions.cs b/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs new file mode 100644 index 000000000000..367269f878c6 --- /dev/null +++ b/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Web.Virtualization; + +namespace Microsoft.AspNetCore.Components.Virtualization; + +internal static class VirtualizeJsCallbacksTestExtensions +{ + public static void OnBeforeSpacerVisible( + this IVirtualizeJsCallbacks callbacks, + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason) + => callbacks.OnBeforeSpacerVisible( + spacerSize, + spacerSeparation, + containerSize, + reason, + callbacks.RenderedWindowVersion); + + public static void OnAfterSpacerVisible( + this IVirtualizeJsCallbacks callbacks, + float spacerSize, + float spacerSeparation, + float containerSize, + SpacerVisibilityReason reason) + => callbacks.OnAfterSpacerVisible( + spacerSize, + spacerSeparation, + containerSize, + reason, + callbacks.RenderedWindowVersion); +} diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 41a086534179..ef08441e7cfa 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -725,11 +725,140 @@ await testRenderer.Dispatcher.InvokeAsync(() => $"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 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)); + } + + 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) { Virtualize renderedVirtualize = null; @@ -740,7 +869,13 @@ await testRenderer.Dispatcher.InvokeAsync(() => var rootComponent = new VirtualizeTestHostcomponent { - InnerContent = BuildVirtualize(itemSize, provider, null, virtualize => renderedVirtualize = virtualize, childContent) + InnerContent = BuildVirtualize( + itemSize, + provider, + null, + virtualize => renderedVirtualize = virtualize, + childContent, + initialItemIndex) }; var serviceProvider = new ServiceCollection() @@ -767,7 +902,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 +916,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)); @@ -1381,6 +1522,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 +1540,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/ServerRenderingTests/VirtualizationRenderModesTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs index 4c8641e7064b..0b6d2310120c 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs @@ -83,6 +83,44 @@ public void InitialItemIndex_IsAppliedOnPrerenderedInteractiveServerLoad() } } + [Fact] + public void InitialItemIndex_NearEndResize_DoesNotApplyStaleRenderedWindowMeasurement() + { + Browser.Manage().Logs.GetLog(LogType.Browser); + Navigate($"{ServerPathBase}/interactivity/virtualization?test-stale-measurement=true&initial-item-index=1990"); + + Browser.Exists(By.Id("scroll-container")); + Browser.Exists(By.Id("blazor-started")); + var js = (IJavaScriptExecutor)Browser; + + var states = new List + { + WaitForViewportToSettle(js, "initial 2500px"), + }; + + for (var cycle = 1; cycle <= 3; cycle++) + { + SetContainerHeight(js, 2000); + states.Add(WaitForViewportToSettle(js, $"cycle {cycle} at 2000px")); + SetContainerHeight(js, 2500); + states.Add(WaitForViewportToSettle(js, $"cycle {cycle} at 2500px")); + } + + foreach (var state in states) + { + Assert.True(state.FirstVisibleItem == 1950 + && state.TopCovered + && (state.BottomCovered || state.AtScrollEnd) + && Math.Abs(state.ScrollHeight - 100_000) <= 2, + $"Unexpected viewport state after {state.Stage}: {state}"); + } + + var severeLogs = Browser.Manage().Logs.GetLog(LogType.Browser) + .Where(entry => entry.Level == LogLevel.Severe) + .ToArray(); + Assert.Empty(severeLogs); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -187,6 +225,102 @@ private static void ScrollTopToEnd(IWebDriver browser, IWebElement elem) js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", elem); } + private ViewportState WaitForViewportToSettle(IJavaScriptExecutor js, string stage) + { + ViewportState? previous = null; + var stableReads = 0; + var current = default(ViewportState); + + Browser.True(() => + { + current = GetViewportState(js) with { Stage = stage }; + if (previous is { } previousValue + && current.FirstVisibleItem == previousValue.FirstVisibleItem + && current.FirstRenderedItem == previousValue.FirstRenderedItem + && current.LastRenderedItem == previousValue.LastRenderedItem + && current.RenderedItemCount == previousValue.RenderedItemCount + && current.ScrollHeight == previousValue.ScrollHeight + && current.ScrollTop == previousValue.ScrollTop) + { + stableReads++; + } + else + { + stableReads = 0; + } + + previous = current; + return stableReads >= 5; + }, TimeSpan.FromSeconds(15), $"Viewport did not settle after {stage}. Last state: {current}"); + + return current; + } + + private static ViewportState GetViewportState(IJavaScriptExecutor js) + { + var result = (Dictionary)js.ExecuteScript( + """ + const container = document.getElementById('scroll-container'); + const containerRect = container.getBoundingClientRect(); + const viewportTop = containerRect.top + container.clientTop; + const viewportBottom = viewportTop + container.clientHeight; + const items = Array.from(container.querySelectorAll('.item[data-index]')); + const visibleItems = items.filter(item => { + const rect = item.getBoundingClientRect(); + return rect.bottom > viewportTop + 1 && rect.top < viewportBottom - 1; + }); + const topCovered = items.some(item => { + const rect = item.getBoundingClientRect(); + return rect.top <= viewportTop + 1 && rect.bottom > viewportTop + 1; + }); + const bottomCovered = items.some(item => { + const rect = item.getBoundingClientRect(); + return rect.top < viewportBottom - 1 && rect.bottom >= viewportBottom - 1; + }); + const index = item => item ? Number(item.getAttribute('data-index')) : -1; + return { + firstVisibleItem: index(visibleItems[0]), + firstRenderedItem: index(items[0]), + lastRenderedItem: index(items[items.length - 1]), + renderedItemCount: items.length, + scrollHeight: container.scrollHeight, + scrollTop: container.scrollTop, + topCovered, + bottomCovered, + atScrollEnd: Math.abs(container.scrollTop + container.clientHeight - container.scrollHeight) <= 2, + }; + """); + + return new ViewportState( + Stage: string.Empty, + FirstVisibleItem: Convert.ToInt32(result["firstVisibleItem"], CultureInfo.InvariantCulture), + FirstRenderedItem: Convert.ToInt32(result["firstRenderedItem"], CultureInfo.InvariantCulture), + LastRenderedItem: Convert.ToInt32(result["lastRenderedItem"], CultureInfo.InvariantCulture), + RenderedItemCount: Convert.ToInt32(result["renderedItemCount"], CultureInfo.InvariantCulture), + ScrollHeight: Convert.ToDouble(result["scrollHeight"], CultureInfo.InvariantCulture), + ScrollTop: Convert.ToDouble(result["scrollTop"], CultureInfo.InvariantCulture), + TopCovered: Convert.ToBoolean(result["topCovered"], CultureInfo.InvariantCulture), + BottomCovered: Convert.ToBoolean(result["bottomCovered"], CultureInfo.InvariantCulture), + AtScrollEnd: Convert.ToBoolean(result["atScrollEnd"], CultureInfo.InvariantCulture)); + } + + private static void SetContainerHeight(IJavaScriptExecutor js, int height) + { + js.ExecuteScript("document.getElementById('scroll-container').style.height = `${arguments[0]}px`;", height); + } + + private readonly record struct ViewportState( + string Stage, + int FirstVisibleItem, + int FirstRenderedItem, + int LastRenderedItem, + int RenderedItemCount, + double ScrollHeight, + double ScrollTop, + bool TopCovered, + bool BottomCovered, + bool AtScrollEnd); + private static string InstallInitialSpacerCallbackRace(ChromeDriver chromeDriver) { var result = chromeDriver.ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor index 2cf5364d4716..e4f31ab487bb 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -14,7 +14,7 @@
-
+
@if (listLoaded) { @if (useItemsProvider) @@ -24,7 +24,7 @@
+ style="@GetItemStyle(item)">
Item @item.Index
@@ -42,7 +42,7 @@
+ style="@GetItemStyle(item)">
Item @item.Index
@@ -140,6 +140,24 @@ [Parameter] public int InitialItemIndex { get; set; } + [Parameter] + public int InitialItemCount { get; set; } = 1000; + + [Parameter] + public int InitialContainerHeight { get; set; } = 300; + + [Parameter] + public int InitialOverscanCount { get; set; } = 15; + + [Parameter] + public bool InitiallyUseItemsProvider { get; set; } + + [Parameter] + public bool InitiallyUseVariableHeight { get; set; } + + [Parameter] + public bool UseExactItemSize { get; set; } + private static readonly IReadOnlyDictionary _emptyComparerSplat = new Dictionary(); @@ -157,11 +175,20 @@ protected override void OnInitialized() { initialItemIndex = InitialItemIndex; - items = Enumerable.Range(0, 1000) + overscanCount = InitialOverscanCount; + useItemsProvider = InitiallyUseItemsProvider; + useVariableHeight = InitiallyUseVariableHeight; + items = Enumerable.Range(0, InitialItemCount) .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) .ToList(); + nextAppendIndex = InitialItemCount; } + 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) diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor index aade876ecf52..bed9308d719c 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor @@ -2,7 +2,18 @@ @using BasicTestApp @using TestContentPackage; -@if (TestInitialItemIndex) +@if (TestStaleMeasurement) +{ + +} +else if (TestInitialItemIndex) { } @@ -25,4 +36,20 @@ else [Parameter] [SupplyParameterFromQuery(Name = "test-initial-item-index")] public bool TestInitialItemIndex { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "test-stale-measurement")] + public bool TestStaleMeasurement { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "initial-item-index")] + public int ReproInitialItemIndex { get; set; } = 1990; + + [Parameter] + [SupplyParameterFromQuery(Name = "use-items-provider")] + public bool UseItemsProvider { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "use-variable-height")] + public bool UseVariableHeight { get; set; } } From fe7448115b811bb3a8284caf5981103074a7104b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:54:10 +0000 Subject: [PATCH 09/19] Initial plan Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> From 27d516f29b0211a03d72009331384b8afe53eaf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:25:49 +0000 Subject: [PATCH 10/19] Guard cancelled Virtualize alignment callbacks Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../Virtualization/IVirtualizeJsCallbacks.cs | 1 - .../Web/src/Virtualization/Virtualize.cs | 7 +- .../VirtualizeJsCallbacksTestExtensions.cs | 35 ----- .../Web/test/Virtualization/VirtualizeTest.cs | 134 +++++++++++------- 4 files changed, 89 insertions(+), 88 deletions(-) delete mode 100644 src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs diff --git a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 8ea0c24717f8..518251efb61a 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -5,7 +5,6 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization; internal interface IVirtualizeJsCallbacks { - long RenderedWindowVersion { get; } void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); void OnAlignmentCompleted(VirtualizeAlignmentResult result); diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 47e4b4a8e531..3825962b2c27 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -658,8 +658,6 @@ private void CancelInFlightScrollForUserInteraction() } } - long IVirtualizeJsCallbacks.RenderedWindowVersion => _renderedWindowVersion; - void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( float spacerSize, float spacerSeparation, @@ -788,6 +786,11 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible( void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult result) { + if (_initialIndex.Phase != InitialIndexPhase.Pending) + { + return; + } + var fillDirection = ProcessAlignmentResult(result); UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity); } diff --git a/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs b/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs deleted file mode 100644 index 367269f878c6..000000000000 --- a/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components.Web.Virtualization; - -namespace Microsoft.AspNetCore.Components.Virtualization; - -internal static class VirtualizeJsCallbacksTestExtensions -{ - public static void OnBeforeSpacerVisible( - this IVirtualizeJsCallbacks callbacks, - float spacerSize, - float spacerSeparation, - float containerSize, - SpacerVisibilityReason reason) - => callbacks.OnBeforeSpacerVisible( - spacerSize, - spacerSeparation, - containerSize, - reason, - callbacks.RenderedWindowVersion); - - public static void OnAfterSpacerVisible( - this IVirtualizeJsCallbacks callbacks, - float spacerSize, - float spacerSeparation, - float containerSize, - SpacerVisibilityReason reason) - => callbacks.OnAfterSpacerVisible( - spacerSize, - spacerSeparation, - containerSize, - reason, - callbacks.RenderedWindowVersion); -} diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index ef08441e7cfa..4021882feefc 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -94,7 +94,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 +125,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 +160,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 +171,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 +197,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 +221,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 +248,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 +281,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 +316,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 +353,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 +389,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 +425,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 +464,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 +488,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 +517,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 +526,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 +552,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 +584,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 +625,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 +666,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,7 +721,7 @@ 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, @@ -763,6 +766,37 @@ await renderer.Dispatcher.InvokeAsync(() => || 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() { @@ -1051,7 +1085,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; @@ -1060,7 +1094,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 @@ -1096,7 +1130,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; @@ -1105,7 +1139,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, @@ -1134,7 +1168,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); }); @@ -1152,7 +1186,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); }); @@ -1218,7 +1252,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(); @@ -1239,7 +1273,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(() => { @@ -1320,14 +1354,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); } @@ -1341,15 +1375,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}."); @@ -1393,7 +1427,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); } From e9127395a2fe5faf525d03f592218346225c66f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:26:32 +0000 Subject: [PATCH 11/19] Remove test-only callback extension Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../VirtualizeJsCallbacksTestExtensions.cs | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs diff --git a/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs b/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs deleted file mode 100644 index 367269f878c6..000000000000 --- a/src/Components/Web/test/Virtualization/VirtualizeJsCallbacksTestExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components.Web.Virtualization; - -namespace Microsoft.AspNetCore.Components.Virtualization; - -internal static class VirtualizeJsCallbacksTestExtensions -{ - public static void OnBeforeSpacerVisible( - this IVirtualizeJsCallbacks callbacks, - float spacerSize, - float spacerSeparation, - float containerSize, - SpacerVisibilityReason reason) - => callbacks.OnBeforeSpacerVisible( - spacerSize, - spacerSeparation, - containerSize, - reason, - callbacks.RenderedWindowVersion); - - public static void OnAfterSpacerVisible( - this IVirtualizeJsCallbacks callbacks, - float spacerSize, - float spacerSeparation, - float containerSize, - SpacerVisibilityReason reason) - => callbacks.OnAfterSpacerVisible( - spacerSize, - spacerSeparation, - containerSize, - reason, - callbacks.RenderedWindowVersion); -} From d9886e01952450575de3ee2968ffc9f22931b6a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:56:14 +0000 Subject: [PATCH 12/19] Remeasure Virtualize observer notifications Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 97 ++++++++------ src/Components/Web.JS/test/Virtualize.test.ts | 122 +++++++++++++++++- .../Web/src/Virtualization/Virtualize.cs | 56 +++++++- 3 files changed, 229 insertions(+), 46 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index b4007635939f..63a642bc2f5a 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -47,7 +47,7 @@ type AlignmentResult = { }; type IntersectionMeasurement = Omit & { - entry: IntersectionObserverEntry; + target: Element; spacerSize: number; }; @@ -634,7 +634,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac subscribeToScroll(); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); - let pendingCallbacks: Map = new Map(); + const pendingCallbacks: Set = new Set(); let callbackTimeout: ReturnType | null = null; let pendingAlignLocalIndex: number | null = null; @@ -675,18 +675,40 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return { spacerSeparation, containerSize, renderedWindowVersion }; } - function measureIntersectionEntry(entry: IntersectionObserverEntry): IntersectionMeasurement | null { - const measurement = measureRenderedWindow(entry.rootBounds?.height); + function measureIntersectionTargets(targets: Element[]): IntersectionMeasurement[] { + const measurement = measureRenderedWindow(); if (!measurement) { - return null; + return []; } const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); - const isBefore = entry.target === spacerBefore; - const spacerSize = isBefore - ? (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor - : (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; - return { entry, spacerSize, ...measurement }; + const viewport = getViewportBounds(scaleFactor); + 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); + const isZeroHeightIntersection = targetRect.height === 0 && targetIntersectionBottom === targetIntersectionTop; + if (targetIntersectionBottom < targetIntersectionTop + || (targetIntersectionBottom === targetIntersectionTop && !isZeroHeightIntersection)) { + 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. @@ -783,19 +805,20 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac }; function flushPendingCallbacks(): void { - if (pendingCallbacks.size === 0) return; - const measurements = Array.from(pendingCallbacks.values()); + if (pendingCallbacks.size === 0) { + return; + } + const targets = Array.from(pendingCallbacks); pendingCallbacks.clear(); + const measurements = measureIntersectionTargets(targets); + if (measurements.length === 0) { + return; + } processIntersectionEntries(measurements); } function intersectionCallback(entries: IntersectionObserverEntry[]): void { - entries.forEach(entry => { - const measurement = measureIntersectionEntry(entry); - if (measurement) { - pendingCallbacks.set(entry.target, measurement); - } - }); + entries.forEach(entry => pendingCallbacks.add(entry.target)); if (!callbackTimeout) { flushPendingCallbacks(); @@ -913,32 +936,24 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac updateAnchorSnapshot(); } - const bothSpacersIntersect = measurements.some(({ entry }) => entry.target === spacerBefore && entry.isIntersecting) - && measurements.some(({ entry }) => entry.target === spacerAfter && entry.isIntersecting); + const bothSpacersIntersect = measurements.some(({ target }) => target === spacerBefore) + && measurements.some(({ target }) => target === spacerAfter); - const intersectingMeasurements = measurements.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 (intersectingMeasurements.length === 0) { @@ -949,14 +964,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } intersectingMeasurements.forEach((measurement): void => { - const { entry } = measurement; 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) { @@ -979,7 +993,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac measurement.spacerSeparation, measurement.containerSize, reason, - measurement.renderedWindowVersion); + measurement.renderedWindowVersion + ); }); if (source === ScrollSource.AlignToItem) { diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 27039e90e73b..75266bd6326a 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,121 @@ describe('Virtualize exports', () => { expect(typeof Virtualize.restoreAnchor).toBe('function'); }); }); + +describe('Virtualize intersection measurements', () => { + let intersectionCallback: IntersectionObserverCallback; + let spacerSeparation: number; + + beforeEach(() => { + document.body.innerHTML = ''; + spacerSeparation = 600; + + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { supports: () => true }, + }); + Object.defineProperty(globalThis, 'IntersectionObserver', { + configurable: true, + value: class { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + + observe() {} + unobserve() {} + 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.restoreAllMocks(); + }); + + const invokeMethodAsync = jest.fn(); + 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, + 200, + 2, + 2); + }); +}); + +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/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 3825962b2c27..5f339f1e46e7 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -366,9 +366,20 @@ private async Task EnsureRenderCommittedAsync(bool refetchRequired, Cancellation return null; } + var initialItemSize = _itemSize; var result = await _jsInterop.AlignToItemAsync(localIndex, token); + var fillDirection = ProcessAlignmentResult(result, out var visibleItemCapacity, out var unusedItemCapacity); + if (_initialIndex.Phase == InitialIndexPhase.Pending + && _itemSize != initialItemSize + && _initialIndex.TryBeginRemeasure(_itemSize)) + { + StateHasChanged(); + return null; + } - return ProcessAlignmentResult(result); + _visibleItemCapacity = visibleItemCapacity; + _unusedItemCapacity = unusedItemCapacity; + return fillDirection; } private int ClampToItemRange(int requested) @@ -791,18 +802,41 @@ void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult resul return; } - var fillDirection = ProcessAlignmentResult(result); + var initialItemSize = _itemSize; + var fillDirection = ProcessAlignmentResult(result, out var visibleItemCapacity, out var unusedItemCapacity); + if (_itemSize != initialItemSize && _initialIndex.TryBeginRemeasure(_itemSize)) + { + StateHasChanged(); + return; + } + + _visibleItemCapacity = visibleItemCapacity; + _unusedItemCapacity = unusedItemCapacity; UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity); } - private ViewportFillDirection? ProcessAlignmentResult(VirtualizeAlignmentResult? result) + private ViewportFillDirection? ProcessAlignmentResult( + VirtualizeAlignmentResult? result, + out int visibleItemCapacity, + out int unusedItemCapacity) { + visibleItemCapacity = _visibleItemCapacity; + unusedItemCapacity = _unusedItemCapacity; if (result is null || result.RenderedWindowVersion != _renderedWindowVersion) { return null; } - UpdateItemSizeFromRenderedContent(0, result.SpacerSeparation, result.ContainerSize); + if (_initialIndex.Phase == InitialIndexPhase.Pending) + { + CalculateItemDistribution( + 0, + result.SpacerSeparation, + result.ContainerSize, + out _, + out visibleItemCapacity, + out unusedItemCapacity); + } return result.FillDirection; } @@ -1233,6 +1267,7 @@ private enum InitialIndexPhase private sealed class InitialIndexState { private float _alignItemSize; + private bool _hasRemeasured; public InitialIndexPhase Phase { get; private set; } @@ -1242,6 +1277,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() From a2d025ba5b5c984a66f98041bee7f72905702ab4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:09:58 +0000 Subject: [PATCH 13/19] Use viewport extent for window-root virtualization Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 6 ++-- src/Components/Web.JS/test/Virtualize.test.ts | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 63a642bc2f5a..80f93ebf5719 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -676,13 +676,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } function measureIntersectionTargets(targets: Element[]): IntersectionMeasurement[] { - const measurement = measureRenderedWindow(); + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + const viewport = getViewportBounds(scaleFactor); + const measurement = measureRenderedWindow(viewport.bottom - viewport.top); if (!measurement) { return []; } - const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); - const viewport = getViewportBounds(scaleFactor); const margin = rootMargin * scaleFactor; const intersectionTop = viewport.top - margin; const intersectionBottom = viewport.bottom + margin; diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 75266bd6326a..da59fd3f6c96 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -110,6 +110,38 @@ describe('Virtualize intersection measurements', () => { 2, 2); }); + + test('uses 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 IntersectionObserverEntry], {} as IntersectionObserver); + + expect(invokeMethodAsync).toHaveBeenCalledWith( + 'OnSpacerBeforeVisible', + 50, + 600, + 900, + 2, + 1); + }); }); function rect(top: number, height: number): DOMRect { From 2bca99187a8ff2b2cbd10477fb8f4f8a1834b1be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:12:44 +0000 Subject: [PATCH 14/19] Fix Virtualize observer test typing Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/test/Virtualize.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index da59fd3f6c96..0ca4b0b5c64e 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -132,7 +132,7 @@ describe('Virtualize intersection measurements', () => { intersectionCallback([{ target: spacerBefore, isIntersecting: true, - } as IntersectionObserverEntry], {} as IntersectionObserver); + } as unknown as IntersectionObserverEntry], {} as IntersectionObserver); expect(invokeMethodAsync).toHaveBeenCalledWith( 'OnSpacerBeforeVisible', From f1c525d67babcb35d879d8a4cca0a4b9d80d2f44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:25 +0000 Subject: [PATCH 15/19] Preserve Virtualize effective observer extent Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 17 +++-- src/Components/Web.JS/test/Virtualize.test.ts | 73 ++++++++++++++++++- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 80f93ebf5719..e8ed2f90d4bf 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -656,7 +656,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return el.getBoundingClientRect().top - containerTop; } - function measureRenderedWindow(containerExtent?: number): Omit | null { + 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) { @@ -667,18 +667,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return null; } - const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); rangeBetweenSpacers.setStartAfter(spacerBefore); rangeBetweenSpacers.setEndBefore(spacerAfter); const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor; - const containerSize = (containerExtent ?? scrollElement.getBoundingClientRect().height) / scaleFactor; + 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(viewport.bottom - viewport.top); + const measurement = measureRenderedWindow(getEffectiveRootExtent(viewport, scaleFactor), scaleFactor); if (!measurement) { return []; } @@ -731,7 +730,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } pendingAlignLocalIndex = null; - const measurement = measureRenderedWindow(); + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + const measurement = measureRenderedWindow(getEffectiveRootExtent(getViewportBounds(scaleFactor), scaleFactor), scaleFactor); if (!measurement) { return null; } @@ -760,6 +760,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); @@ -811,9 +815,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const targets = Array.from(pendingCallbacks); pendingCallbacks.clear(); const measurements = measureIntersectionTargets(targets); - if (measurements.length === 0) { - return; - } processIntersectionEntries(measurements); } diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 0ca4b0b5c64e..35a465c409ab 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -56,6 +56,7 @@ describe('Virtualize intersection measurements', () => { afterEach(() => { Virtualize.dispose(dotNetHelper); + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -106,12 +107,12 @@ describe('Virtualize intersection measurements', () => { 'OnSpacerBeforeVisible', 50, 500, - 200, + 300, 2, 2); }); - test('uses viewport height when the document is the scroll root', () => { + 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'); @@ -138,7 +139,73 @@ describe('Virtualize intersection measurements', () => { 'OnSpacerBeforeVisible', 50, 600, - 900, + 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); + intersectionCallback([entry], {} as IntersectionObserver); + setElementMetrics(spacerBefore, rect(-200, 20), 20); + jest.advanceTimersByTime(50); + + setElementMetrics(spacerBefore, rect(-10, 20), 20); + intersectionCallback([entry], {} as IntersectionObserver); + + expect(invokeMethodAsync).toHaveBeenCalledWith( + 'OnSpacerBeforeVisible', + 0, + 600, + 400, 2, 1); }); From d5c11ad6a7ce408121a594fc4ec84355eec1abd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:35:46 +0000 Subject: [PATCH 16/19] Capture Virtualize measurements before throttling Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 10 ++-- src/Components/Web.JS/test/Virtualize.test.ts | 47 ++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index e8ed2f90d4bf..82bce6ecd862 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -634,7 +634,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac subscribeToScroll(); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); - const pendingCallbacks: Set = new Set(); + const pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; let pendingAlignLocalIndex: number | null = null; @@ -812,14 +812,16 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (pendingCallbacks.size === 0) { return; } - const targets = Array.from(pendingCallbacks); + const measurements = Array.from(pendingCallbacks.values()) + .filter((measurement): measurement is IntersectionMeasurement => measurement !== null); pendingCallbacks.clear(); - const measurements = measureIntersectionTargets(targets); processIntersectionEntries(measurements); } function intersectionCallback(entries: IntersectionObserverEntry[]): void { - entries.forEach(entry => pendingCallbacks.add(entry.target)); + 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(); diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 35a465c409ab..043b09925d12 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -194,8 +194,8 @@ describe('Virtualize intersection measurements', () => { invokeMethodAsync.mockClear(); Virtualize.beginProgrammaticScroll(dotNetHelper); - intersectionCallback([entry], {} as IntersectionObserver); setElementMetrics(spacerBefore, rect(-200, 20), 20); + intersectionCallback([entry], {} as IntersectionObserver); jest.advanceTimersByTime(50); setElementMetrics(spacerBefore, rect(-10, 20), 20); @@ -209,6 +209,51 @@ describe('Virtualize intersection measurements', () => { 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); + }); }); function rect(top: number, height: number): DOMRect { From e2aa644e24ebefc7efbab783d3b48c747e10bac5 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 24 Aug 2026 14:57:02 +0200 Subject: [PATCH 17/19] Initial resolving of the review in E2E tests. --- .../VirtualizationRenderModesTest.cs | 134 ------------------ .../test/E2ETest/Tests/VirtualizationTest.cs | 95 ++++++++++++- .../VirtualizationAnchorMode.razor | 46 +++--- ...tualizationTransitionToInteractivity.razor | 29 +--- 4 files changed, 112 insertions(+), 192 deletions(-) diff --git a/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs index 0b6d2310120c..4c8641e7064b 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs @@ -83,44 +83,6 @@ public void InitialItemIndex_IsAppliedOnPrerenderedInteractiveServerLoad() } } - [Fact] - public void InitialItemIndex_NearEndResize_DoesNotApplyStaleRenderedWindowMeasurement() - { - Browser.Manage().Logs.GetLog(LogType.Browser); - Navigate($"{ServerPathBase}/interactivity/virtualization?test-stale-measurement=true&initial-item-index=1990"); - - Browser.Exists(By.Id("scroll-container")); - Browser.Exists(By.Id("blazor-started")); - var js = (IJavaScriptExecutor)Browser; - - var states = new List - { - WaitForViewportToSettle(js, "initial 2500px"), - }; - - for (var cycle = 1; cycle <= 3; cycle++) - { - SetContainerHeight(js, 2000); - states.Add(WaitForViewportToSettle(js, $"cycle {cycle} at 2000px")); - SetContainerHeight(js, 2500); - states.Add(WaitForViewportToSettle(js, $"cycle {cycle} at 2500px")); - } - - foreach (var state in states) - { - Assert.True(state.FirstVisibleItem == 1950 - && state.TopCovered - && (state.BottomCovered || state.AtScrollEnd) - && Math.Abs(state.ScrollHeight - 100_000) <= 2, - $"Unexpected viewport state after {state.Stage}: {state}"); - } - - var severeLogs = Browser.Manage().Logs.GetLog(LogType.Browser) - .Where(entry => entry.Level == LogLevel.Severe) - .ToArray(); - Assert.Empty(severeLogs); - } - [Theory] [InlineData(false)] [InlineData(true)] @@ -225,102 +187,6 @@ private static void ScrollTopToEnd(IWebDriver browser, IWebElement elem) js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", elem); } - private ViewportState WaitForViewportToSettle(IJavaScriptExecutor js, string stage) - { - ViewportState? previous = null; - var stableReads = 0; - var current = default(ViewportState); - - Browser.True(() => - { - current = GetViewportState(js) with { Stage = stage }; - if (previous is { } previousValue - && current.FirstVisibleItem == previousValue.FirstVisibleItem - && current.FirstRenderedItem == previousValue.FirstRenderedItem - && current.LastRenderedItem == previousValue.LastRenderedItem - && current.RenderedItemCount == previousValue.RenderedItemCount - && current.ScrollHeight == previousValue.ScrollHeight - && current.ScrollTop == previousValue.ScrollTop) - { - stableReads++; - } - else - { - stableReads = 0; - } - - previous = current; - return stableReads >= 5; - }, TimeSpan.FromSeconds(15), $"Viewport did not settle after {stage}. Last state: {current}"); - - return current; - } - - private static ViewportState GetViewportState(IJavaScriptExecutor js) - { - var result = (Dictionary)js.ExecuteScript( - """ - const container = document.getElementById('scroll-container'); - const containerRect = container.getBoundingClientRect(); - const viewportTop = containerRect.top + container.clientTop; - const viewportBottom = viewportTop + container.clientHeight; - const items = Array.from(container.querySelectorAll('.item[data-index]')); - const visibleItems = items.filter(item => { - const rect = item.getBoundingClientRect(); - return rect.bottom > viewportTop + 1 && rect.top < viewportBottom - 1; - }); - const topCovered = items.some(item => { - const rect = item.getBoundingClientRect(); - return rect.top <= viewportTop + 1 && rect.bottom > viewportTop + 1; - }); - const bottomCovered = items.some(item => { - const rect = item.getBoundingClientRect(); - return rect.top < viewportBottom - 1 && rect.bottom >= viewportBottom - 1; - }); - const index = item => item ? Number(item.getAttribute('data-index')) : -1; - return { - firstVisibleItem: index(visibleItems[0]), - firstRenderedItem: index(items[0]), - lastRenderedItem: index(items[items.length - 1]), - renderedItemCount: items.length, - scrollHeight: container.scrollHeight, - scrollTop: container.scrollTop, - topCovered, - bottomCovered, - atScrollEnd: Math.abs(container.scrollTop + container.clientHeight - container.scrollHeight) <= 2, - }; - """); - - return new ViewportState( - Stage: string.Empty, - FirstVisibleItem: Convert.ToInt32(result["firstVisibleItem"], CultureInfo.InvariantCulture), - FirstRenderedItem: Convert.ToInt32(result["firstRenderedItem"], CultureInfo.InvariantCulture), - LastRenderedItem: Convert.ToInt32(result["lastRenderedItem"], CultureInfo.InvariantCulture), - RenderedItemCount: Convert.ToInt32(result["renderedItemCount"], CultureInfo.InvariantCulture), - ScrollHeight: Convert.ToDouble(result["scrollHeight"], CultureInfo.InvariantCulture), - ScrollTop: Convert.ToDouble(result["scrollTop"], CultureInfo.InvariantCulture), - TopCovered: Convert.ToBoolean(result["topCovered"], CultureInfo.InvariantCulture), - BottomCovered: Convert.ToBoolean(result["bottomCovered"], CultureInfo.InvariantCulture), - AtScrollEnd: Convert.ToBoolean(result["atScrollEnd"], CultureInfo.InvariantCulture)); - } - - private static void SetContainerHeight(IJavaScriptExecutor js, int height) - { - js.ExecuteScript("document.getElementById('scroll-container').style.height = `${arguments[0]}px`;", height); - } - - private readonly record struct ViewportState( - string Stage, - int FirstVisibleItem, - int FirstRenderedItem, - int LastRenderedItem, - int RenderedItemCount, - double ScrollHeight, - double ScrollTop, - bool TopCovered, - bool BottomCovered, - bool AtScrollEnd); - private static string InstallInitialSpacerCallbackRace(ChromeDriver chromeDriver) { var result = chromeDriver.ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 3344c3c3b2d0..3c7937036b6f 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -4990,7 +4990,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 +4998,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 +5020,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 +5045,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 +5060,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 +5570,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 e4f31ab487bb..d9c36b6d6db3 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -14,7 +14,7 @@
-
+
@if (listLoaded) { @if (useItemsProvider) @@ -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; @@ -140,24 +142,6 @@ [Parameter] public int InitialItemIndex { get; set; } - [Parameter] - public int InitialItemCount { get; set; } = 1000; - - [Parameter] - public int InitialContainerHeight { get; set; } = 300; - - [Parameter] - public int InitialOverscanCount { get; set; } = 15; - - [Parameter] - public bool InitiallyUseItemsProvider { get; set; } - - [Parameter] - public bool InitiallyUseVariableHeight { get; set; } - - [Parameter] - public bool UseExactItemSize { get; set; } - private static readonly IReadOnlyDictionary _emptyComparerSplat = new Dictionary(); @@ -175,17 +159,13 @@ protected override void OnInitialized() { initialItemIndex = InitialItemIndex; - overscanCount = InitialOverscanCount; - useItemsProvider = InitiallyUseItemsProvider; - useVariableHeight = InitiallyUseVariableHeight; - items = Enumerable.Range(0, InitialItemCount) + items = Enumerable.Range(0, 1000) .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) .ToList(); - nextAppendIndex = InitialItemCount; } private string GetItemStyle(DynamicItem item) - => UseExactItemSize + => useExactItemSize ? $"height: {item.Height}px;" : $"border: 1px solid gray; padding: 5px; min-height: {item.Height}px;"; @@ -374,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/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor index bed9308d719c..aade876ecf52 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor @@ -2,18 +2,7 @@ @using BasicTestApp @using TestContentPackage; -@if (TestStaleMeasurement) -{ - -} -else if (TestInitialItemIndex) +@if (TestInitialItemIndex) { } @@ -36,20 +25,4 @@ else [Parameter] [SupplyParameterFromQuery(Name = "test-initial-item-index")] public bool TestInitialItemIndex { get; set; } - - [Parameter] - [SupplyParameterFromQuery(Name = "test-stale-measurement")] - public bool TestStaleMeasurement { get; set; } - - [Parameter] - [SupplyParameterFromQuery(Name = "initial-item-index")] - public int ReproInitialItemIndex { get; set; } = 1990; - - [Parameter] - [SupplyParameterFromQuery(Name = "use-items-provider")] - public bool UseItemsProvider { get; set; } - - [Parameter] - [SupplyParameterFromQuery(Name = "use-variable-height")] - public bool UseVariableHeight { get; set; } } From 5221098b15ba6a45ee2e599d92c3db790f2e9380 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 24 Aug 2026 15:28:08 +0200 Subject: [PATCH 18/19] Centralize alignment measurement processing in ProcessAlignmentResult. --- src/Components/Web.JS/src/Virtualize.ts | 1 - .../Virtualization/SpacerVisibilityReason.cs | 6 -- .../Web/src/Virtualization/Virtualize.cs | 69 ++++++------------- 3 files changed, 21 insertions(+), 55 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 82bce6ecd862..fdb344c7862d 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -22,7 +22,6 @@ const SpacerVisibilityReason = { UserScroll: 0, ProgrammaticScroll: 1, ViewportFill: 2, - RenderedContentMeasurement: 3, } as const; const ViewportFillDirection = { 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 5f339f1e46e7..3785211b2d13 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -366,20 +366,8 @@ private async Task EnsureRenderCommittedAsync(bool refetchRequired, Cancellation return null; } - var initialItemSize = _itemSize; var result = await _jsInterop.AlignToItemAsync(localIndex, token); - var fillDirection = ProcessAlignmentResult(result, out var visibleItemCapacity, out var unusedItemCapacity); - if (_initialIndex.Phase == InitialIndexPhase.Pending - && _itemSize != initialItemSize - && _initialIndex.TryBeginRemeasure(_itemSize)) - { - StateHasChanged(); - return null; - } - - _visibleItemCapacity = visibleItemCapacity; - _unusedItemCapacity = unusedItemCapacity; - return fillDirection; + return ProcessAlignmentResult(result, updateWindowFromViewport: false); } private int ClampToItemRange(int requested) @@ -648,16 +636,6 @@ private string GetSpacerHeightPx(int itemCount) 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(); @@ -681,11 +659,6 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( return; } - if (reason == SpacerVisibilityReason.RenderedContentMeasurement) - { - UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); - return; - } if (_pendingAnchorRestore) { return; @@ -743,11 +716,6 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible( return; } - if (reason == SpacerVisibilityReason.RenderedContentMeasurement) - { - UpdateItemSizeFromRenderedContent(spacerSize, spacerSeparation, containerSize); - return; - } if (_pendingAnchorRestore || reason == SpacerVisibilityReason.ProgrammaticScroll) { return; @@ -802,31 +770,21 @@ void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult resul return; } - var initialItemSize = _itemSize; - var fillDirection = ProcessAlignmentResult(result, out var visibleItemCapacity, out var unusedItemCapacity); - if (_itemSize != initialItemSize && _initialIndex.TryBeginRemeasure(_itemSize)) - { - StateHasChanged(); - return; - } - - _visibleItemCapacity = visibleItemCapacity; - _unusedItemCapacity = unusedItemCapacity; - UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity); + ProcessAlignmentResult(result, updateWindowFromViewport: true); } private ViewportFillDirection? ProcessAlignmentResult( VirtualizeAlignmentResult? result, - out int visibleItemCapacity, - out int unusedItemCapacity) + bool updateWindowFromViewport) { - visibleItemCapacity = _visibleItemCapacity; - unusedItemCapacity = _unusedItemCapacity; if (result is null || result.RenderedWindowVersion != _renderedWindowVersion) { return null; } + var initialItemSize = _itemSize; + var visibleItemCapacity = _visibleItemCapacity; + var unusedItemCapacity = _unusedItemCapacity; if (_initialIndex.Phase == InitialIndexPhase.Pending) { CalculateItemDistribution( @@ -838,6 +796,21 @@ void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult resul 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; } From 683444fa6a0b89ce7c78e6161e6ef53ba80cc2d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:06:31 +0000 Subject: [PATCH 19/19] Correct Virtualize measurement ownership edge cases Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 12 +- src/Components/Web.JS/test/Virtualize.test.ts | 87 ++++++++- .../Virtualization/IVirtualizeJsCallbacks.cs | 4 +- .../Web/src/Virtualization/Virtualize.cs | 106 ++++++++--- .../src/Virtualization/VirtualizeJsInterop.cs | 8 +- .../Web/test/Virtualization/VirtualizeTest.cs | 179 +++++++++++++++++- .../test/E2ETest/Tests/VirtualizationTest.cs | 93 +++++++++ 7 files changed, 447 insertions(+), 42 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index fdb344c7862d..e2e05493d7da 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -694,9 +694,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const targetRect = target.getBoundingClientRect(); const targetIntersectionTop = Math.max(targetRect.top, intersectionTop); const targetIntersectionBottom = Math.min(targetRect.bottom, intersectionBottom); - const isZeroHeightIntersection = targetRect.height === 0 && targetIntersectionBottom === targetIntersectionTop; - if (targetIntersectionBottom < targetIntersectionTop - || (targetIntersectionBottom === targetIntersectionTop && !isZeroHeightIntersection)) { + if (targetIntersectionBottom < targetIntersectionTop) { continue; } @@ -791,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(); @@ -989,7 +988,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } const methodName = isBefore ? 'OnSpacerBeforeVisible' : 'OnSpacerAfterVisible'; - dotNetHelper.invokeMethodAsync( + const callback = dotNetHelper.invokeMethodAsync( methodName, measurement.spacerSize, measurement.spacerSeparation, @@ -997,6 +996,11 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac reason, measurement.renderedWindowVersion ); + void Promise.resolve(callback).then(isCurrentMeasurement => { + if (isCurrentMeasurement === false) { + reobserveSpacers(); + } + }); }); if (source === ScrollSource.AlignToItem) { diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 043b09925d12..922ae917a431 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -17,10 +17,15 @@ describe('Virtualize exports', () => { 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, @@ -33,8 +38,8 @@ describe('Virtualize intersection measurements', () => { intersectionCallback = callback; } - observe() {} - unobserve() {} + observe(target: Element) { observe(target); } + unobserve(target: Element) { unobserve(target); } disconnect() {} }, }); @@ -60,7 +65,7 @@ describe('Virtualize intersection measurements', () => { jest.restoreAllMocks(); }); - const invokeMethodAsync = jest.fn(); + const invokeMethodAsync = jest.fn<(...args: unknown[]) => Promise>(); const dotNetHelper = { _callDispatcher: {}, _id: 1, @@ -254,6 +259,82 @@ describe('Virtualize intersection measurements', () => { 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 { diff --git a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 518251efb61a..86a2b2273af6 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -5,7 +5,7 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization; internal interface IVirtualizeJsCallbacks { - void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); - void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion); + 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/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 3785211b2d13..cb7ddae87a97 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -44,6 +44,12 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal long _renderedWindowVersion; + private long _contentRevision; + + private RenderedWindowIdentity? _lastRenderedWindowIdentity; + + private TItem[]? _lastLoadedItemsSnapshot; + internal float _itemSize; private float _lastSetItemSize; @@ -546,7 +552,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) throw oldRefreshException; } - var renderedWindowVersion = ++_renderedWindowVersion; + var renderedWindowIdentity = GetRenderedWindowIdentity(); + if (renderedWindowIdentity != _lastRenderedWindowIdentity) + { + _renderedWindowVersion++; + } + var renderedWindowVersion = _renderedWindowVersion; builder.OpenElement(0, SpacerElement); builder.AddAttribute(1, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(_itemsBefore)); @@ -628,8 +639,26 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) 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); @@ -647,39 +676,39 @@ private void CancelInFlightScrollForUserInteraction() } } - void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( + bool IVirtualizeJsCallbacks.OnBeforeSpacerVisible( float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion) { - if (renderedWindowVersion != _renderedWindowVersion) - { - 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; } @@ -692,7 +721,7 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( ViewportFillDirection.Before, visibleItemCapacity, unusedItemCapacity); - return; + return true; } // Slide window up by at least one if spacer is visible but position unchanged. @@ -702,33 +731,33 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible( } UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); + return true; } - void IVirtualizeJsCallbacks.OnAfterSpacerVisible( + bool IVirtualizeJsCallbacks.OnAfterSpacerVisible( float spacerSize, float spacerSeparation, float containerSize, SpacerVisibilityReason reason, long renderedWindowVersion) { - if (renderedWindowVersion != _renderedWindowVersion) - { - 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); @@ -738,7 +767,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible( ViewportFillDirection.After, visibleItemCapacity, unusedItemCapacity); - return; + return true; } var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity); @@ -761,6 +790,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible( } UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); + return true; } void IVirtualizeJsCallbacks.OnAlignmentCompleted(VirtualizeAlignmentResult result) @@ -1083,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 @@ -1106,6 +1144,7 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess, CancellationT StateHasChanged(); } } + catch (Exception e) { if (e is OperationCanceledException oce && oce.CancellationToken == cancellationToken) @@ -1230,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, diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index 2bd678a7d1b6..44a0bb6cf3c1 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -32,15 +32,15 @@ public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementRef } [JSInvokable] - public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) + public bool OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); + return _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); } [JSInvokable] - public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) + public bool OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, int reason, long renderedWindowVersion) { - _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); + return _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason, renderedWindowVersion); } [JSInvokable] diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 4021882feefc..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; @@ -876,6 +877,177 @@ await renderer.Dispatcher.InvokeAsync(() => 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, @@ -892,7 +1064,8 @@ private static VirtualizeState GetVirtualizeState(Virtualize virtualize) int totalItems, ItemsProviderDelegate customProvider = null, RenderFragment childContent = null, - int initialItemIndex = 0) + int initialItemIndex = 0, + bool useItemsProvider = true) { Virtualize renderedVirtualize = null; @@ -905,8 +1078,8 @@ private static VirtualizeState GetVirtualizeState(Virtualize virtualize) { InnerContent = BuildVirtualize( itemSize, - provider, - null, + useItemsProvider ? provider : null, + useItemsProvider ? null : Enumerable.Range(0, totalItems).ToList(), virtualize => renderedVirtualize = virtualize, childContent, initialItemIndex) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 3c7937036b6f..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() {