diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index cc6ed8873a41..00630ac223b3 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -357,6 +357,16 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return; } + // Re-render overwrites the entire style attribute that init/refreshObservedElements applied. Re-apply it. + if (isTable) { + if (spacerBefore.style.display !== 'table-row') { + spacerBefore.style.display = 'table-row'; + } + if (spacerAfter.style.display !== 'table-row') { + spacerAfter.style.display = 'table-row'; + } + } + const intersectingEntries = entries.filter(entry => { if (entry.isIntersecting) { if (entry.target === spacerAfter) { @@ -382,7 +392,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac rangeBetweenSpacers.setStartAfter(spacerBefore); rangeBetweenSpacers.setEndBefore(spacerAfter); - const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor; + let spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor; + + if (isTable && spacerSeparation > 0) { + const firstTr = spacerBefore.nextElementSibling; + const lastTr = spacerAfter.previousElementSibling; + const fc = firstTr && firstTr !== spacerAfter ? firstTr.querySelector('td,th') as HTMLElement | null : null; + const lc = lastTr && lastTr !== spacerBefore ? lastTr.querySelector('td,th') as HTMLElement | null : null; + if (fc && lc) { + spacerSeparation = (lc.getBoundingClientRect().bottom - fc.getBoundingClientRect().top) / scaleFactor; + } + } intersectingEntries.forEach((entry): void => { const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index bc0560a65671..1662655652a5 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1729,6 +1729,68 @@ public void QuickGrid_CanJumpToEndAndStart() Browser.True(isFirstRowId1); } + [Fact] + public void QuickGrid_AspireLikeThumbScrollToEnd_DoesNotLeaveBlankTail() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("aspire-like-grid")); + var totalItems = Browser.Exists(By.Id("aspire-total-items")); + var maxDepth = Browser.Exists(By.Id("aspire-max-depth")); + var js = (IJavaScriptExecutor)Browser; + + Browser.Equal("Total items: 10000", () => totalItems.Text); + Browser.True(() => int.Parse(maxDepth.Text.Replace("Max depth: ", string.Empty, StringComparison.Ordinal), CultureInfo.InvariantCulture) >= 40); + WaitForQuickGridDataRows(container); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + + var diagnostics = new System.Text.StringBuilder(); + var pollCount = 0; + Browser.True(() => + { + pollCount++; + var metrics = (IReadOnlyDictionary)js.ExecuteScript( + "var c = arguments[0];" + + "var spacers = c.querySelectorAll('[aria-hidden]');" + + "var rows = c.querySelectorAll('tbody tr:not([aria-hidden])');" + + "var firstId = -1;" + + "var lastId = -1;" + + "for (var i = 0; i < rows.length; i++) {" + + " var marker = rows[i].querySelector('.aspire-row-id');" + + " if (marker) { var parsed = parseInt(marker.textContent, 10); if (!Number.isNaN(parsed)) { firstId = parsed; break; } }" + + "}" + + "for (var i = rows.length - 1; i >= 0; i--) {" + + " var marker = rows[i].querySelector('.aspire-row-id');" + + " if (marker) { var parsed = parseInt(marker.textContent, 10); if (!Number.isNaN(parsed)) { lastId = parsed; break; } }" + + "}" + + "return {" + + " remaining: c.scrollHeight - c.scrollTop - c.clientHeight," + + " scrollTop: c.scrollTop," + + " scrollHeight: c.scrollHeight," + + " clientHeight: c.clientHeight," + + " spacerBefore: spacers.length >= 1 ? spacers[0].offsetHeight : -1," + + " spacerAfter: spacers.length >= 2 ? spacers[spacers.length - 1].offsetHeight : -1," + + " firstId: firstId," + + " lastId: lastId" + + "};", + container); + + var remaining = Convert.ToDouble(metrics["remaining"], CultureInfo.InvariantCulture); + var scrollTop = Convert.ToDouble(metrics["scrollTop"], CultureInfo.InvariantCulture); + var scrollHeight = Convert.ToDouble(metrics["scrollHeight"], CultureInfo.InvariantCulture); + var clientHeight = Convert.ToDouble(metrics["clientHeight"], CultureInfo.InvariantCulture); + var spacerBefore = Convert.ToDouble(metrics["spacerBefore"], CultureInfo.InvariantCulture); + var spacerAfter = Convert.ToDouble(metrics["spacerAfter"], CultureInfo.InvariantCulture); + var firstId = Convert.ToInt32(metrics["firstId"], CultureInfo.InvariantCulture); + var lastId = Convert.ToInt32(metrics["lastId"], CultureInfo.InvariantCulture); + + diagnostics.AppendLine(CultureInfo.InvariantCulture, $"poll #{pollCount}: scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}, remaining={remaining:F1}, spacerBefore={spacerBefore:F1}, spacerAfter={spacerAfter:F1}, firstId={firstId}, lastId={lastId}"); + + return spacerAfter < 1 && firstId > 9950 && lastId == 10000; + }, $"Aspire-like QuickGrid should not get stranded in a large blank tail after dragging the scrollbar thumb to the bottom.{Environment.NewLine}{diagnostics}"); + } + private void WaitForQuickGridDataRows(IWebElement container) => Browser.True(() => CheckQuickGridFirstRow(container, text => int.TryParse(text, out _))); diff --git a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridAspireLikeComponent.razor b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridAspireLikeComponent.razor new file mode 100644 index 000000000000..e1d92277a9fa --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridAspireLikeComponent.razor @@ -0,0 +1,117 @@ +@using System.Globalization +@using System.Linq +@using System.Reflection +@using Microsoft.AspNetCore.Components.QuickGrid +@using Microsoft.AspNetCore.Components.Web.Virtualization + + + +

Total items: @_totalItemCount

+

Max depth: @_maxDepth

+ +
+ + + + @context.Name + + + + +
+ +@code { + private static readonly IReadOnlyList s_allRows = CreateRows(); + + private QuickGrid _grid = default!; + private GridItemsProvider _itemsProvider = default!; + private int _totalItemCount = s_allRows.Count; + private int _maxDepth = s_allRows.Max(r => r.Depth); + + internal sealed class AspireLikeRow + { + public int Id { get; set; } + public int Depth { get; set; } + public bool HasChildren { get; set; } + public string Name { get; set; } = string.Empty; + public string Resource { get; set; } = string.Empty; + public string Duration { get; set; } = string.Empty; + } + + protected override void OnInitialized() + { + _itemsProvider = async request => + { + await Task.Yield(); + + var items = s_allRows + .Skip(request.StartIndex) + .Take(request.Count ?? 100) + .ToList(); + + return GridItemsProviderResult.From(items, totalItemCount: _totalItemCount); + }; + } + + protected override void OnAfterRender(bool firstRender) + { + if (_grid is null) + { + return; + } + + var virtualizeField = typeof(QuickGrid).GetField("_virtualizeComponent", BindingFlags.Instance | BindingFlags.NonPublic); + if (virtualizeField?.GetValue(_grid) is not Virtualize<(int, AspireLikeRow)> virtualize) + { + return; + } + + if (virtualize.MaxItemCount != 10_000) + { + virtualize.MaxItemCount = 10_000; + StateHasChanged(); + } + } + + private static List CreateRows() + { + const int targetItemCount = 10_000; + var rows = new List(targetItemCount); + var nextId = 1; + + for (var traceGroup = 0; rows.Count < targetItemCount; traceGroup++) + { + rows.Add(new AspireLikeRow + { + Id = nextId++, + Depth = 1, + HasChildren = true, + Name = $"GET /big-trace/{traceGroup}", + Resource = "Stress.ApiService", + Duration = "46.37s", + }); + + var depthLimit = 20 + (traceGroup % 28); + + for (var depth = 1; depth <= depthLimit && rows.Count < targetItemCount; depth++) + { + rows.Add(new AspireLikeRow + { + Id = nextId++, + Depth = depth + 1, + HasChildren = depth < depthLimit, + Name = $"bigtrace-Span-{traceGroup}-{depth}", + Resource = depth % 5 == 0 ? "Stress.Worker" : "Stress.ApiService", + Duration = FormattableString.Invariant($"{Math.Max(0.03, 4.25 - (depth * 0.07)):0.00}s"), + }); + } + } + + return rows; + } +}