Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1b2a0b5
Introduce a programmatic API to scroll a Virtualize<TItem> component …
ilonatommy May 4, 2026
7b3ac1f
Rename: Item -> Index.
ilonatommy May 4, 2026
93ffce6
Simplify.
ilonatommy May 20, 2026
746238b
Cleanup.
ilonatommy May 20, 2026
244e4ed
Nullable state duplicated 0-th state. Remove nullable.
ilonatommy May 20, 2026
fde7794
Use the pattern from NavigationManager or MAUI's CollectionView.Scrol…
ilonatommy May 20, 2026
d98775d
Apply feedback.
ilonatommy May 20, 2026
29006c2
Empty list: nothing will ever render, so unblock the awaiter.
ilonatommy May 20, 2026
f1d72f8
Fix InitialIndex re-applying when changed from 0 to a non-zero value …
ilonatommy May 21, 2026
5191c02
Cancel in-flight refresh on user scroll during ScrollToIndexAsync and…
ilonatommy May 21, 2026
2850ba9
Feedback: Surface the exception.
ilonatommy May 21, 2026
da829fc
Fix AnchorMode interfering with InitialIndex/ScrollToIndex scrolling,…
ilonatommy May 26, 2026
5e5aa4b
Tmp: logging.
ilonatommy May 27, 2026
39c389d
Wait for async provider in ScrollDoesNotFlash test to fix CI.
ilonatommy May 27, 2026
e413015
Fix: values over the range should be capped same as negative.
ilonatommy May 27, 2026
0331e71
Revert the tmp logging - test is passing now.
ilonatommy May 27, 2026
ae06b29
Stabilize test on CI.
ilonatommy May 28, 2026
99a7d6b
Add 1 px tolerance to test.
ilonatommy May 28, 2026
5f0186d
Merge branch 'main' into scroll-to-index
ilonatommy May 28, 2026
8269b8f
Merge branch 'main' into scroll-to-index
ilonatommy Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 89 additions & 13 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const Virtualize = {
refreshObservers,
setAnchorMode,
restoreAnchor,
alignToItem,
beginProgrammaticScroll,
};

const dispatcherObserversByDotNetIdPropname = Symbol();
Expand Down Expand Up @@ -110,6 +112,15 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
intersectionObserver.observe(spacerAfter);
}

// Called by C# at the start of a programmatic ScrollToIndex. Suppresses spacer-IO
// callbacks (which would otherwise be misinterpreted as a "user scroll") until
// either alignToItemAt completes or a real user scroll fires.
function beginProgrammaticScrollSuppression(): void {
suppressSpacerCallbacks = true;
pendingCallbacks.delete(spacerBefore);
pendingCallbacks.delete(spacerAfter);
}

function getObservedHeight(entry: ResizeObserverEntry): number {
return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
}
Expand Down Expand Up @@ -292,6 +303,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return;
}

// Retry a pending programmatic alignment now that items may be in DOM.
if (pendingAlignLocalIndex !== null) {
const pending = pendingAlignLocalIndex;
pendingAlignLocalIndex = null;
alignToItemAt(pending);
return;
}

// Beginning mode at the very top: show new items by converging to top.
if ((anchorMode & 1) && snapshot.scrollTop < 1) {
convergingToTop = true;
Expand All @@ -300,19 +319,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return;
}

let current = spacerBefore.nextElementSibling;
for (let i = 0; i < snapshot.anchorItemIndex && current && current !== spacerAfter; i++) {
current = current.nextElementSibling;
}

if (!current || current === spacerAfter) {
const newOffset = measureLocalChildOffset(snapshot.anchorItemIndex);
if (Number.isNaN(newOffset)) {
return;
}

const containerTop = scrollContainer
? scrollContainer.getBoundingClientRect().top
: 0;
const newOffset = current.getBoundingClientRect().top - containerTop;
const delta = newOffset - snapshot.anchorOffset;

// Suppress spacer IO until next user scroll. Save anchor for drift correction.
Expand All @@ -335,8 +345,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac

// Save anchor offset AFTER scrollTop adjustment for drift correction.
if (pendingScrollCorrection) {
const containerTop = scrollContainer ? scrollContainer.getBoundingClientRect().top : 0;
scrollCorrectionOffset = current.getBoundingClientRect().top - containerTop;
const correctedOffset = measureLocalChildOffset(snapshot.anchorItemIndex);
if (!Number.isNaN(correctedOffset)) {
scrollCorrectionOffset = correctedOffset;
}
}

if (preserveWasAtBottom) {
Expand Down Expand Up @@ -426,6 +438,58 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
let pendingCallbacks: Map<Element, IntersectionObserverEntry> = new Map();
let callbackTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingAlignLocalIndex: number | null = null;

// Walks `localIndex` siblings forward from spacerBefore to find the rendered child,
// returning its viewport-relative top measured against the scroll container (or 0 for
// the window-scroll case). Returns NaN when the slot is missing — e.g., the row hasn't
// rendered yet, or the local index falls outside the currently rendered window.
function measureLocalChildOffset(localIndex: number): number {
let el: Element | null = spacerBefore.nextElementSibling;
for (let i = 0; i < localIndex && el && el !== spacerAfter; i++) {
el = el.nextElementSibling;
}
if (!el || el === spacerAfter) {
return Number.NaN;
}
const containerTop = scrollElement === document.documentElement
? 0
: scrollElement.getBoundingClientRect().top;
return el.getBoundingClientRect().top - containerTop;
}

// Measures the target's viewport-relative top and aligns it to containerTop.
function alignToItemAt(localIndex: number): void {
const delta = measureLocalChildOffset(localIndex);
if (Number.isNaN(delta)) {
// Items aren't in DOM yet. Retry after the next render commit.
pendingAlignLocalIndex = localIndex;
ignoreAnchorScroll = true;
suppressSpacerCallbacks = true;
observersByDotNetObjectId[id].anchorSnapshot = null;
if (convergingToTop || convergingToBottom) {
convergingToTop = false;
convergingToBottom = false;
stopConvergenceObserving();
}
return;
}
pendingAlignLocalIndex = null;
if (Math.abs(delta) > 0.5) {
ignoreAnchorScroll = true;
suppressSpacerCallbacks = true;
// Programmatic scroll establishes a new explicit position — invalidate any pending anchor snapshot and cancel in-progress convergence.
observersByDotNetObjectId[id].anchorSnapshot = null;
if (convergingToTop || convergingToBottom) {
convergingToTop = false;
convergingToBottom = false;
stopConvergenceObserving();
}
pendingJumpToStart = false;
pendingJumpToEnd = false;
scrollElement.scrollTo({ top: scrollElement.scrollTop + delta, behavior: 'instant' });
Comment thread
ilonatommy marked this conversation as resolved.
}
}

observersByDotNetObjectId[id] = {
intersectionObserver,
Expand All @@ -436,6 +500,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
setConvergingToBottom: () => { convergingToBottom = true; },
setAnchorMode: (mode: number) => { anchorMode = mode; },
restoreAnchor: restoreAnchorForShift,
alignToItem: alignToItemAt,
beginProgrammaticScroll: beginProgrammaticScrollSuppression,
anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null,
onDispose: () => {
stopConvergenceObserving();
Expand Down Expand Up @@ -659,6 +725,16 @@ function restoreAnchor(dotNetHelper: DotNet.DotNetObject): void {
entry?.restoreAnchor?.();
}

function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): void {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
observersByDotNetObjectId[id]?.alignToItem?.(localIndex);
}

function beginProgrammaticScroll(dotNetHelper: DotNet.DotNetObject): void {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
observersByDotNetObjectId[id]?.beginProgrammaticScroll?.();
}

function getObserversMapEntry(dotNetHelper: DotNet.DotNetObject): { observersByDotNetObjectId: {[id: number]: any }, id: number } {
const dotNetHelperDispatcher = dotNetHelper['_callDispatcher'];
const dotNetHelperId = dotNetHelper['_id'];
Expand Down
3 changes: 3 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.Name.set
Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.SupplyParameterFromSessionAttribute() -> void
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.AnchorMode.get -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.AnchorMode.set -> void
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.InitialIndex.get -> int
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.InitialIndex.set -> void
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.ItemComparer.get -> System.Collections.Generic.IEqualityComparer<TItem>!
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.ItemComparer.set -> void
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.ScrollToIndexAsync(int itemIndex, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.Beginning = 1 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.End = 2 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Expand Down
Loading
Loading