Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ee59496
Initial plan
Copilot Aug 21, 2026
de8ed19
[Kestrel] Fix h3 connection-level and stream-level abort locking (#68…
cincuranet Aug 21, 2026
60f8f1f
Initial plan
Copilot Aug 21, 2026
6382486
Reject stale Virtualize viewport measurements
Copilot Aug 21, 2026
8658221
Merge remote progress branch
Copilot Aug 21, 2026
2b236ff
[main] (deps): Bump dotnet/arcade/.github/workflows/inter-branch-merg…
dependabot[bot] Aug 21, 2026
62c4132
[main] (deps): Bump dotnet/arcade/.github/workflows/backport-base.yml…
dependabot[bot] Aug 21, 2026
65596c0
Initial plan
Copilot Aug 21, 2026
7cf633c
Reject stale Virtualize viewport measurements
Copilot Aug 21, 2026
fe74481
Initial plan
Copilot Aug 21, 2026
27d516f
Guard cancelled Virtualize alignment callbacks
Copilot Aug 21, 2026
53bb819
Merge remote progress branch
Copilot Aug 21, 2026
e912739
Remove test-only callback extension
Copilot Aug 21, 2026
d9886e0
Remeasure Virtualize observer notifications
Copilot Aug 21, 2026
a2d025b
Use viewport extent for window-root virtualization
Copilot Aug 21, 2026
2bca991
Fix Virtualize observer test typing
Copilot Aug 21, 2026
f1c525d
Preserve Virtualize effective observer extent
Copilot Aug 21, 2026
d5c11ad
Capture Virtualize measurements before throttling
Copilot Aug 22, 2026
e2aa644
Initial resolving of the review in E2E tests.
ilonatommy Aug 24, 2026
5221098
Centralize alignment measurement processing in ProcessAlignmentResult.
ilonatommy Aug 24, 2026
683444f
Correct Virtualize measurement ownership edge cases
Copilot Aug 24, 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
2 changes: 1 addition & 1 deletion .github/workflows/backport.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/inter-branch-merge-flow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
169 changes: 119 additions & 50 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ export const Virtualize = {

const dispatcherObserversByDotNetIdPropname = Symbol();
const THROTTLE_MS = 50;
const renderedWindowVersionAttribute = 'data-blazor-virtualize-rendered-window-version';
const SpacerVisibilityReason = {
UserScroll: 0,
ProgrammaticScroll: 1,
ViewportFill: 2,
RenderedContentMeasurement: 3,
} as const;

const ViewportFillDirection = {
Expand All @@ -38,6 +38,18 @@ const ScrollSource = {
} as const;
type ScrollSource = typeof ScrollSource[keyof typeof ScrollSource];

type AlignmentResult = {
fillDirection: number;
spacerSeparation: number;
containerSize: number;
renderedWindowVersion: number;
};

type IntersectionMeasurement = Omit<AlignmentResult, 'fillDirection'> & {
target: Element;
spacerSize: number;
};

function findClosestScrollContainer(element: HTMLElement | null): HTMLElement | null {
// If we recurse up as far as body or the document root, return null so that the
// IntersectionObserver observes intersection with the top-level scroll viewport
Expand Down Expand Up @@ -445,7 +457,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
if (pendingAlignLocalIndex !== null) {
const pending = pendingAlignLocalIndex;
pendingAlignLocalIndex = null;
alignToItemAt(pending);
const result = alignToItemAt(pending);
if (result) {
dotNetHelper.invokeMethodAsync('OnAlignmentCompleted', result);
}
return;
}

Expand Down Expand Up @@ -618,7 +633,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
subscribeToScroll();

const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
let pendingCallbacks: Map<Element, IntersectionObserverEntry> = new Map();
const pendingCallbacks: Map<Element, IntersectionMeasurement | null> = new Map();
let callbackTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingAlignLocalIndex: number | null = null;

Expand All @@ -640,17 +655,60 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return el.getBoundingClientRect().top - containerTop;
}

function reportRenderedContentMeasurement(): void {
const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);
function measureRenderedWindow(containerExtent: number, scaleFactor: number): Omit<AlignmentResult, 'fillDirection'> | null {
const beforeVersion = spacerBefore.getAttribute(renderedWindowVersionAttribute);
const afterVersion = spacerAfter.getAttribute(renderedWindowVersionAttribute);
if (beforeVersion === null || afterVersion === null || beforeVersion !== afterVersion) {
return null;
}
const renderedWindowVersion = Number(beforeVersion);
if (!Number.isSafeInteger(renderedWindowVersion)) {
return null;
}

rangeBetweenSpacers.setStartAfter(spacerBefore);
rangeBetweenSpacers.setEndBefore(spacerAfter);
const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor;
const containerSize = scrollElement.getBoundingClientRect().height / scaleFactor;
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', 0, spacerSeparation, containerSize, SpacerVisibilityReason.RenderedContentMeasurement);
const containerSize = containerExtent / scaleFactor;
return { spacerSeparation, containerSize, renderedWindowVersion };
}

function measureIntersectionTargets(targets: Element[]): IntersectionMeasurement[] {
const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);
const viewport = getViewportBounds(scaleFactor);
const measurement = measureRenderedWindow(getEffectiveRootExtent(viewport, scaleFactor), scaleFactor);
if (!measurement) {
return [];
}

const margin = rootMargin * scaleFactor;
const intersectionTop = viewport.top - margin;
const intersectionBottom = viewport.bottom + margin;
const measurements: IntersectionMeasurement[] = [];

for (const target of targets) {
if (!target.isConnected || (target !== spacerBefore && target !== spacerAfter)) {
continue;
}

const targetRect = target.getBoundingClientRect();
const targetIntersectionTop = Math.max(targetRect.top, intersectionTop);
const targetIntersectionBottom = Math.min(targetRect.bottom, intersectionBottom);
if (targetIntersectionBottom < targetIntersectionTop) {
continue;
}

const spacerSize = target === spacerBefore
? (targetIntersectionTop - targetRect.top) / scaleFactor
: (targetRect.bottom - targetIntersectionBottom) / scaleFactor;
measurements.push({ target, spacerSize, ...measurement });
}

return measurements;
}

// Measures the target's viewport-relative top and aligns it to containerTop.
function alignToItemAt(localIndex: number): number | null {
function alignToItemAt(localIndex: number): AlignmentResult | null {
function beginAlign(): void {
scrollActivity.ignoreNextScroll();
scrollActivity.source = ScrollSource.AlignToItem;
Expand All @@ -669,7 +727,11 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}
pendingAlignLocalIndex = null;

reportRenderedContentMeasurement();
const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);
const measurement = measureRenderedWindow(getEffectiveRootExtent(getViewportBounds(scaleFactor), scaleFactor), scaleFactor);
if (!measurement) {
return null;
}

if (Math.abs(delta) > 0.5) {
beginAlign();
Expand All @@ -678,7 +740,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
scrollElement.scrollTo({ top: scrollElement.scrollTop + delta, behavior: 'instant' });
}

return getViewportFillDirection();
return {
fillDirection: getViewportFillDirection(),
...measurement,
};
}

function getViewportBounds(scaleFactor: number): { top: number; bottom: number } {
Expand All @@ -692,6 +757,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return { top: viewportTop, bottom: viewportBottom };
}

function getEffectiveRootExtent(viewport: { top: number; bottom: number }, scaleFactor: number): number {
return viewport.bottom - viewport.top + (2 * rootMargin * scaleFactor);
}

function occupiesViewport(spacer: HTMLElement, viewport: { top: number; bottom: number }): boolean {
const spacerRect = spacer.getBoundingClientRect();
return Math.min(spacerRect.bottom, viewport.bottom) > Math.max(spacerRect.top, viewport.top);
Expand Down Expand Up @@ -720,6 +789,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
restoreAnchor: restoreAnchorForShift,
alignToItem: alignToItemAt,
beginProgrammaticScroll: beginProgrammaticScroll,
reobserveSpacers,
anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null,
onDispose: () => {
mutationObserver.disconnect();
Expand All @@ -737,14 +807,19 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
};

function flushPendingCallbacks(): void {
if (pendingCallbacks.size === 0) return;
const entries = Array.from(pendingCallbacks.values());
if (pendingCallbacks.size === 0) {
return;
}
const measurements = Array.from(pendingCallbacks.values())
.filter((measurement): measurement is IntersectionMeasurement => measurement !== null);
pendingCallbacks.clear();
processIntersectionEntries(entries);
processIntersectionEntries(measurements);
}

function intersectionCallback(entries: IntersectionObserverEntry[]): void {
entries.forEach(entry => pendingCallbacks.set(entry.target, entry));
const measurements = measureIntersectionTargets(entries.map(entry => entry.target));
const measurementsByTarget = new Map(measurements.map(measurement => [measurement.target, measurement]));
entries.forEach(entry => pendingCallbacks.set(entry.target, measurementsByTarget.get(entry.target) ?? null));

if (!callbackTimeout) {
flushPendingCallbacks();
Expand Down Expand Up @@ -843,7 +918,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
observersByDotNetObjectId[id].anchorSnapshot = null;
}

function processIntersectionEntries(entries: IntersectionObserverEntry[]): void {
function processIntersectionEntries(measurements: IntersectionMeasurement[]): void {
// Check if the spacers are still in the DOM. They may have been removed if the component was disposed.
if (!spacerBefore.isConnected || !spacerAfter.isConnected) {
return;
Expand All @@ -862,56 +937,41 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
updateAnchorSnapshot();
}

const bothSpacersIntersect = entries.some(entry => entry.target === spacerBefore && entry.isIntersecting)
&& entries.some(entry => entry.target === spacerAfter && entry.isIntersecting);
const bothSpacersIntersect = measurements.some(({ target }) => target === spacerBefore)
&& measurements.some(({ target }) => target === spacerAfter);

const intersectingEntries = entries.filter(entry => {
if (bothSpacersIntersect && entry.target === spacerAfter) {
const intersectingMeasurements = measurements.filter(({ target }) => {
if (bothSpacersIntersect && target === spacerAfter) {
// When both spacers are visible, report only the before spacer to avoid conflicting callbacks.
return false;
}

if (entry.isIntersecting) {
if (!isSelfScroll) {
// Convergence to the top/bottom edge should not fight with self scroll.
if (entry.target === spacerAfter) {
updateBottomConvergence(source === ScrollSource.UserScroll);
} else if (entry.target === spacerBefore) {
updateTopConvergence();
}
if (!isSelfScroll) {
// Convergence to the top/bottom edge should not fight with self scroll.
if (target === spacerAfter) {
updateBottomConvergence(source === ScrollSource.UserScroll);
} else if (target === spacerBefore) {
updateTopConvergence();
}
return true;
}
if (entry.target === spacerAfter && convergence.bottom && spacerAfter.offsetHeight > 0) {
scrollElement.scrollTop = scrollElement.scrollHeight;
} else if (entry.target === spacerBefore && convergence.top && spacerBefore.offsetHeight > 0) {
scrollElement.scrollTop = 0;
}
return false;
return true;
});

if (intersectingEntries.length === 0) {
if (intersectingMeasurements.length === 0) {
if (source === ScrollSource.AlignToItem) {
scrollActivity.clear();
}
return;
}

const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);

rangeBetweenSpacers.setStartAfter(spacerBefore);
rangeBetweenSpacers.setEndBefore(spacerAfter);
const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor;

intersectingEntries.forEach((entry): void => {
const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor;
intersectingMeasurements.forEach((measurement): void => {
const reason = source === ScrollSource.UserScroll
? SpacerVisibilityReason.UserScroll
: (isSelfScroll && (entry.target === spacerBefore || source === ScrollSource.RestoreSnapshot))
: (isSelfScroll && (measurement.target === spacerBefore || source === ScrollSource.RestoreSnapshot))
? SpacerVisibilityReason.ProgrammaticScroll
: SpacerVisibilityReason.ViewportFill;

const isBefore = entry.target === spacerBefore;
const isBefore = measurement.target === spacerBefore;
const spacer = isBefore ? spacerBefore : spacerAfter;

if (!isBefore && spacer.offsetHeight === 0) {
Expand All @@ -927,11 +987,20 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}
}

const spacerSize = isBefore
? (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor
: (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor;
const methodName = isBefore ? 'OnSpacerBeforeVisible' : 'OnSpacerAfterVisible';
dotNetHelper.invokeMethodAsync(methodName, spacerSize, spacerSeparation, containerSize, reason);
const callback = dotNetHelper.invokeMethodAsync(
methodName,
measurement.spacerSize,
measurement.spacerSeparation,
measurement.containerSize,
reason,
measurement.renderedWindowVersion
);
void Promise.resolve(callback).then(isCurrentMeasurement => {
if (isCurrentMeasurement === false) {
reobserveSpacers();
}
});
});

if (source === ScrollSource.AlignToItem) {
Expand Down Expand Up @@ -976,7 +1045,7 @@ function restoreAnchor(dotNetHelper: DotNet.DotNetObject): void {
entry?.restoreAnchor?.();
}

function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): number | null {
function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): AlignmentResult | null {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
return observersByDotNetObjectId[id]?.alignToItem?.(localIndex) ?? null;
}
Expand Down
Loading
Loading