From 66a6a347fbf6e9a77408894c46977777cee03d49 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 21 Aug 2026 21:55:04 -0500 Subject: [PATCH 1/2] Keep Virtualize spacer callbacks programmatic while an align is pending alignToItemAt returns without scrolling when the target item is not in the committed window yet, recording pendingAlignLocalIndex so a later render can retry. processIntersectionEntries cleared the AlignToItem scroll activity regardless, which downgraded subsequent spacer callbacks from ProgrammaticScroll (ignored by C#) to ViewportFill. Virtualize also clears _currentScrollCts as soon as AlignToItemAsync returns, so those ViewportFill callbacks arrive with no guard left and C# redistributes the window while InitialIndexPhase is still Pending. The alignment is abandoned with scrollTop never applied, and the ordinary end-of-list fill then parks the window at _itemCount - visibleItemCapacity, leaving the viewport covered by the before-spacer and no items rendered in it. Only end the align activity once the alignment has actually landed. A completed alignment still hands control back, so viewport fill can top up the window. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01389ba9-a838-43b0-81d5-aa36f1f7f82a --- src/Components/Web.JS/src/Virtualize.ts | 13 +- .../test/VirtualizePendingAlign.test.ts | 171 ++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 src/Components/Web.JS/test/VirtualizePendingAlign.test.ts diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 4b6fe3d7084a..c04de2eb3c5d 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -891,7 +891,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac }); if (intersectingEntries.length === 0) { - if (source === ScrollSource.AlignToItem) { + if (canEndAlignActivity(source)) { scrollActivity.clear(); } return; @@ -934,11 +934,20 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac dotNetHelper.invokeMethodAsync(methodName, spacerSize, spacerSeparation, containerSize, reason); }); - if (source === ScrollSource.AlignToItem) { + if (canEndAlignActivity(source)) { scrollActivity.clear(); } } + // An alignment that could not measure its target yet stays pending until a later render + // retries it, and until then scrollTop has not moved. Ending the align activity early would + // downgrade the spacer callbacks from ProgrammaticScroll (ignored by C#) to ViewportFill, + // letting C# redistribute the window out from under the alignment and strand it at the + // pre-alignment scroll position. + function canEndAlignActivity(source: ScrollSource): boolean { + return source === ScrollSource.AlignToItem && pendingAlignLocalIndex === null; + } + function isValidTableElement(element: HTMLElement | null): boolean { if (element === null) { return false; diff --git a/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts b/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts new file mode 100644 index 000000000000..124571b99cab --- /dev/null +++ b/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts @@ -0,0 +1,171 @@ +import { expect, test, describe, beforeEach, afterEach, jest } from '@jest/globals'; +import { Virtualize } from '../src/Virtualize'; + +const SpacerVisibilityReason = { + UserScroll: 0, + ProgrammaticScroll: 1, + ViewportFill: 2, + RenderedContentMeasurement: 3, +}; + +let intersectionCallback: (entries: any[]) => void; + +function rect(top: number, height: number) { + return { + top, height, bottom: top + height, left: 0, right: 0, width: 100, x: 0, y: top, + toJSON() { return this; }, + }; +} + +function stubGlobals() { + (global as any).CSS = { supports: () => true }; + + (global as any).IntersectionObserver = class { + constructor(cb: (entries: any[]) => void) { + intersectionCallback = cb; + } + observe() { /* no-op */ } + unobserve() { /* no-op */ } + disconnect() { /* no-op */ } + takeRecords() { return []; } + }; + + (global as any).ResizeObserver = class { + observe() { /* no-op */ } + unobserve() { /* no-op */ } + disconnect() { /* no-op */ } + }; + + // jsdom has no layout, so Range reports no geometry. + (global as any).Range.prototype.getBoundingClientRect = () => rect(127588, 390); + (global as any).Range.prototype.getClientRects = () => []; + + // jsdom's computed style does not reflect inline overflow-y, which is what + // findClosestScrollContainer relies on to locate the scroll container. + (global as any).getComputedStyle = (el: Element) => ({ + overflowY: (el as HTMLElement).style.overflowY || 'visible', + }); +} + +// Mirrors the near-end InitialItemIndex layout: a tall before-spacer covering the whole +// viewport, a few rendered items far below it, and an empty after-spacer. +function buildDom() { + document.body.innerHTML = ''; + const container = document.createElement('div'); + container.id = 'scroll-container'; + container.style.overflowY = 'auto'; + + const before = document.createElement('div'); + const after = document.createElement('div'); + container.appendChild(before); + + const items: HTMLElement[] = []; + for (let i = 0; i < 3; i++) { + const item = document.createElement('div'); + item.className = 'item'; + container.appendChild(item); + items.push(item); + } + container.appendChild(after); + document.body.appendChild(container); + + Object.defineProperty(before, 'offsetHeight', { value: 127588, configurable: true }); + Object.defineProperty(after, 'offsetHeight', { value: 0, configurable: true }); + before.getBoundingClientRect = () => rect(0, 127588) as any; + after.getBoundingClientRect = () => rect(127588, 0) as any; + items.forEach((item, i) => { + item.getBoundingClientRect = () => rect(127588 + i * 130, 130) as any; + }); + container.getBoundingClientRect = () => rect(0, 2000) as any; + Object.defineProperty(container, 'clientHeight', { value: 2000, configurable: true }); + Object.defineProperty(container, 'scrollHeight', { value: 130000, configurable: true }); + Object.defineProperty(container, 'clientTop', { value: 0, configurable: true }); + container.scrollTo = () => { /* jsdom has no scrolling */ }; + + return { container, before, after }; +} + +function ioEntry(target: Element, isIntersecting: boolean) { + return { + target, + isIntersecting, + intersectionRect: rect(0, isIntersecting ? 2000 : 0), + boundingClientRect: target.getBoundingClientRect(), + rootBounds: rect(0, 2000), + }; +} + +function createHelper() { + return { + _id: 1, + _callDispatcher: {}, + invokeMethodAsync: jest.fn(() => Promise.resolve()), + } as any; +} + +function spacerReasons(helper: any): number[] { + return helper.invokeMethodAsync.mock.calls + .filter((c: any[]) => c[0] === 'OnSpacerBeforeVisible' || c[0] === 'OnSpacerAfterVisible') + .map((c: any[]) => c[4] as number); +} + +function raiseSpacerIntersection(before: Element, after: Element) { + intersectionCallback([ioEntry(before, true), ioEntry(after, false)]); + jest.advanceTimersByTime(100); +} + +describe('Virtualize programmatic alignment', () => { + beforeEach(() => { + jest.useFakeTimers(); + stubGlobals(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('spacer callbacks stay ProgrammaticScroll while an alignment is pending', () => { + const { container, before, after } = buildDom(); + const helper = createHelper(); + + Virtualize.init(helper, before, after); + Virtualize.beginProgrammaticScroll(helper); + + // The target is outside the committed window, so the alignment defers to a later render + // and scrollTop has not moved. + expect(Virtualize.alignToItem(helper, 950) ?? null).toBeNull(); + expect(container.scrollTop).toBe(0); + + raiseSpacerIntersection(before, after); + helper.invokeMethodAsync.mockClear(); + + container.dispatchEvent(new Event('scroll')); + raiseSpacerIntersection(before, after); + + // ViewportFill and UserScroll both cause C# to move the window or abandon the initial + // index, which would strand the list at the pre-alignment scroll position. + const reasons = spacerReasons(helper); + expect(reasons).not.toContain(SpacerVisibilityReason.ViewportFill); + expect(reasons).not.toContain(SpacerVisibilityReason.UserScroll); + expect(reasons).toContain(SpacerVisibilityReason.ProgrammaticScroll); + }); + + test('spacer callbacks resume ViewportFill once the alignment has landed', () => { + const { before, after } = buildDom(); + const helper = createHelper(); + + Virtualize.init(helper, before, after); + Virtualize.beginProgrammaticScroll(helper); + + // The target is inside the committed window, so the alignment completes immediately. + expect(Virtualize.alignToItem(helper, 0)).not.toBeNull(); + + raiseSpacerIntersection(before, after); + helper.invokeMethodAsync.mockClear(); + + raiseSpacerIntersection(before, after); + + // A completed alignment must still hand control back so viewport-fill can top up the window. + expect(spacerReasons(helper)).toContain(SpacerVisibilityReason.ViewportFill); + }); +}); From f44d75cdb96b6ba6bfa4bc3185c5edebbb4a359c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 21 Aug 2026 22:13:55 -0500 Subject: [PATCH 2/2] Flush Virtualize intersection throttle without a hard-coded delay Use runOnlyPendingTimers so the test does not depend on THROTTLE_MS. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01389ba9-a838-43b0-81d5-aa36f1f7f82a --- src/Components/Web.JS/test/VirtualizePendingAlign.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts b/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts index 124571b99cab..34e8f14dcb66 100644 --- a/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts +++ b/src/Components/Web.JS/test/VirtualizePendingAlign.test.ts @@ -111,7 +111,9 @@ function spacerReasons(helper: any): number[] { function raiseSpacerIntersection(before: Element, after: Element) { intersectionCallback([ioEntry(before, true), ioEntry(after, false)]); - jest.advanceTimersByTime(100); + // Virtualize throttles its intersection callbacks behind a single setTimeout, so flush + // whatever is pending rather than coupling the test to the throttle duration. + jest.runOnlyPendingTimers(); } describe('Virtualize programmatic alignment', () => {