Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 7 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Holding the trailing side instead means a reversal is never worse than a symmetr

It is a plain `Func<Task>` rather than an `EventCallback` on purpose: `EventCallback` calls `StateHasChanged` on the receiver after the handler returns, which on a page with a debounced search input re-renders the bound input with a stale value and clobbers in-flight keystrokes.

## Coverage vs. @tanstack/virtual-core 3.19.8
## Coverage vs. @tanstack/virtual-core 3.19.11

**Complete.** Every `VirtualizerOptions` member is reachable, and every `Virtualizer` member that means something to a .NET caller is surfaced.

Expand Down Expand Up @@ -184,24 +184,20 @@ This is a real coupling: a virtual-core release could change them without it bei

## Stopgaps for upstream fixes

Behaviour that belongs in virtual-core, carried here only until the upstream fix ships:
Behaviour that belongs in virtual-core is carried here only until the upstream fix ships, and listed here with the upstream PR and the test that pins it. There are none in this release: the three that 3.19.8 carried - the Android touch-fling stop (https://github.com/TanStack/virtual/pull/1347), the negated `IsRtl` scroll write (https://github.com/TanStack/virtual/pull/1291) and the measurement refresh before `getOffsetForIndex` (https://github.com/TanStack/virtual/pull/1363) - shipped in virtual-core 3.19.9 and 3.19.11. Their tests stay, and now pin virtual-core's own behaviour.

| Behaviour | Upstream | Stopgap |
|---|---|---|
| A `ScrollToIndex`, `ScrollToOffset`, `ScrollToEnd` or `ScrollBy` issued by code while a touch fling is still moving the list overshoots its target on Android: Chrome carries the fling on from the written position. virtual-core stops the fling first only on iOS 27. | https://github.com/TanStack/virtual/pull/1347 (issue https://github.com/TanStack/virtual/issues/1346) | `runScrollCommand` in `wwwroot/adaptive-virtualize-interop.js` hides the scroll axis's overflow for two frames before the write. Pinned by `ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentumBeforeWriting`. |
| Scroll commands in a horizontal `IsRtl` list go nowhere: virtual-core reads `scrollLeft` negated, but writes the offset back positive, and a right-to-left container clamps that to its start. | https://github.com/TanStack/virtual/pull/1291 (issue https://github.com/TanStack/virtual/issues/1065) | `buildVirtualizerOptions` in `wwwroot/adaptive-virtualize-interop.js` wraps `scrollToFn` to negate the write. Pinned by `IsRtl_ScrollToIndex_BringsTheItemOnScreen`, which fails once virtual-core negates the write itself and this negates it a second time. |
| `getOffsetForIndex` answers from the layout before the latest size change until something else reads the items. | https://github.com/TanStack/virtual/pull/1363 (issue https://github.com/TanStack/virtual/issues/1361) | `getOffsetForIndex`, `takeSnapshot` and `getSnapshot` call `refreshMeasurements()` first. The refresh before `getOffsetForIndex` goes with the fix; the others stay, because they cover this wrapper's own direct cache writes as well. Pinned by `GetOffsetForIndex_RightAfterARowAboveChanges_UsesTheSizesTheVirtualizerUses`. |
⚠️ **Known virtual-core issue, carried without a stopgap: on a touchscreen laptop, a scroll command issued mid-fling can be lost.** With a classic scrollbar, one that takes layout space, a `ScrollToIndex`, `ScrollToOffset` or `ScrollToEnd` issued while a touch fling is still moving the list is dropped when the fling moves on after it and then stops - at the end of the list, or fading out. virtual-core cannot stop that fling, because hiding the scrollbar would reflow the list, so it holds the command until the fling ends. It then reads the fling's last speed as a finger having caught the fling and drops the command - a check written for iOS, where a finger that catches a fling fires no touch event; elsewhere it does. Phones are not affected: their overlay scrollbars let virtual-core stop the fling and write the command.

⚠️ **Remove the stopgap together with the bump to the virtual-core release that contains the fix.** Until then it also runs on iOS, where it is not needed and costs its two frames, because telling the platforms apart would mean copying virtual-core's private user-agent checks.
⚠️ **Remove a stopgap together with the bump to the virtual-core release that contains the fix.** Kept past it, a stopgap applies the fix a second time - the `IsRtl` negation would have sent every write back to the start.

## Vendored library provenance

| | |
|---|---|
| Version | @tanstack/virtual-core 3.19.8 |
| Version | @tanstack/virtual-core 3.19.11 |
| File | `wwwroot/tanstack-virtual-core.js` |
| Source | jsDelivr rollup bundle of npm `@tanstack/virtual-core@3.19.8` (`+esm`) |
| SHA-256 | `52c91d57d1bf1bd22bea2c3a64cd4cfcceea1808b747cbfd3bbbc13ea0ca487a` |
| Source | jsDelivr rollup bundle of npm `@tanstack/virtual-core@3.19.11` (`+esm`) |
| SHA-256 | `a293372e09c2a90fb1d5d275e5ccd4ded1c65e1e78f3157ee1cce4b2a2e6b995` |

⚠️ **Deliberately the bundled build, not the npm tarball's `dist/esm/index.js`.**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@
<PackageIcon>icon.png</PackageIcon>
<PackageTags>blazor;virtualization;virtual-scroll;tanstack;variable-height;list;performance</PackageTags>
<!-- First three parts track the bundled @tanstack/virtual-core release; the fourth is this wrapper's own revision. -->
<Version>3.19.8.4</Version>
<PackageReleaseNotes>3.19.8.4: the rows an Items change moves are no longer painted hidden, and InvalidateItemSize keeps the size a row is rendered at.
<Version>3.19.11</Version>
<PackageReleaseNotes>3.19.11: @tanstack/virtual-core 3.19.8 -&gt; 3.19.11, and the three stopgaps it makes redundant are gone.

virtual-core now does itself what this package did in its place: it stops a touch fling before a ScrollToIndex, ScrollToOffset or ScrollToEnd issued during it on Android too, negates the scroll write of a horizontal IsRtl list, and computes GetOffsetForIndex - and the first write of ScrollToIndex - from the current measurements. Behaviour that changes with it: with a classic scrollbar that takes layout space (a touchscreen laptop), a command issued mid-fling now waits for the fling to end, about 150 ms after its last frame, instead of being written at once - and is dropped when the fling moves on after the command, a virtual-core issue described in the README; a ScrollBy issued mid-fling is written at once without stopping the fling, because its target is relative to where the fling is; and under IsRtl in window-scroller mode the write is no longer negated, which matches how virtual-core reads the window's offset. Also from virtual-core: rows that a render inside a ResizeObserver callback mounts are observed from the next frame, and a scroll offset left past the end of a list that just shrank keeps a full rendered range instead of only the last row.

3.19.8.4: the rows an Items change moves are no longer painted hidden, and InvalidateItemSize keeps the size a row is rendered at.

setOptions and setItems now go out from OnParametersSet, in that order, ahead of the render that shows the change, instead of from OnAfterRenderAsync after it. That render draws the rows an Items change moves hidden until the JS side holds the set, and on a WebView bridge the set used to arrive a round trip later: on a Samsung A53 every row below an expanding group was painted hidden for up to 220 ms. It now arrives first, and the rows are placed and shown before the frame is painted. InvalidateItemSize reads a rendered row's size off the row instead of dropping it, so a set arriving before the row's content changes no longer moves the rows below to the estimate; the ResizeObserver takes the new size when the content changes, and a row that is not rendered, or any row under UseCachedMeasurements, still falls back to the estimate. A row of the replaced item set that resizes before the render lands keeps its new size under its own key instead of losing it.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ function compact(source) {
return result;
}

const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve()));

/**
* The key of the item a row renders. Contract with AdaptiveVirtualize.razor, which writes it on every
* row; syncRenderedRows and the resize measurement read it to tell whether a row belongs to the item
Expand Down Expand Up @@ -94,22 +92,6 @@ class AdaptiveVirtualizeInstance {
// past the item count - so the measure pass after that render reads these off the rows.
this.keysToRemeasure = new Set();

// What runScrollCommand needs to know about the user's input. Listened for on the window, so a
// tap on a button outside the list counts too - that tap is how a jump gets issued mid-fling.
this.touchActive = false;
this.lastInputWasTouch = false;
this.momentumStop = null;
this.inputListeners = {
pointerdown: event => { this.lastInputWasTouch = event.pointerType === 'touch'; },
wheel: () => { this.lastInputWasTouch = false; },
touchstart: () => { this.touchActive = true; },
touchend: () => { this.touchActive = false; },
touchcancel: () => { this.touchActive = false; },
};
for (const [type, listener] of Object.entries(this.inputListeners)) {
window.addEventListener(type, listener, { capture: true, passive: true });
}

this.virtualizer = new Virtualizer(this.buildVirtualizerOptions());

this.virtualizer._willUpdate();
Expand Down Expand Up @@ -223,16 +205,6 @@ class AdaptiveVirtualizeInstance {
resolved.scrollToFn = elementScroll;
}

// virtual-core negates scrollLeft when it reads the offset under isRtl, but writes the offset
// back as is - and in a right-to-left container scrollLeft runs from 0 down to negative
// values, so a positive write clamps to the start and every scroll command goes nowhere.
// Negating it on the way out matches the read.
if (passThrough.isRtl && passThrough.horizontal) {
const write = resolved.scrollToFn;
resolved.scrollToFn = (offset, { adjustments = 0, behavior }, instance) =>
write(-(offset + adjustments), { behavior }, instance);
}

// Enums cross interop as their C# names; virtual-core wants its own lowercase literals.
if (laneAssignmentMode) {
resolved.laneAssignmentMode = laneAssignmentMode.toLowerCase();
Expand Down Expand Up @@ -706,76 +678,19 @@ class AdaptiveVirtualizeInstance {
}

scrollToIndex(index, align, behavior) {
return this.runScrollCommand(() => {
// virtual-core aims the write through getOffsetForIndex, which reads measurementsCache.
this.refreshMeasurements();
this.virtualizer.scrollToIndex(index, { align, behavior });
});
this.virtualizer.scrollToIndex(index, { align, behavior });
}

scrollToOffset(offset, align, behavior) {
return this.runScrollCommand(() => this.virtualizer.scrollToOffset(offset, { align, behavior }));
this.virtualizer.scrollToOffset(offset, { align, behavior });
}

scrollToEnd(behavior) {
return this.runScrollCommand(() => this.virtualizer.scrollToEnd({ behavior }));
this.virtualizer.scrollToEnd({ behavior });
}

scrollBy(delta, behavior) {
return this.runScrollCommand(() => this.virtualizer.scrollBy(delta, { behavior }));
}

/**
* Runs a scroll command, first stopping a touch fling that is still moving the list. Android
* Chrome carries a fling on after a programmatic scroll write, so a jump issued mid-fling lands
* wherever the momentum takes it instead of on its target. virtual-core stops a fling before a
* command only on iOS 27; this stands in for the rest until
* https://github.com/TanStack/virtual/pull/1347 ships, and goes away with it.
*
* Hiding the scroll axis's overflow for two frames is what stops the momentum - the same trick
* virtual-core uses on iOS 27. It runs on iOS too, where it is not needed (before 27 the write
* stops the fling itself, and on 27 virtual-core does) and costs those two frames: telling the
* platforms apart would take copying virtual-core's private user-agent checks. Its own
* _stopMomentum is not reused: it is private, and it drops the callback of a command superseded
* mid-stop, which would leave that command's caller awaiting forever. Without a touch there is
* no fling to stop, and on a desktop hiding the overflow would also hide a classic scrollbar and
* reflow the list, so the write goes out at once.
*/
async runScrollCommand(command) {
const isTouchFling = this.lastInputWasTouch && !this.touchActive
&& this.virtualizer.isScrolling === true;
if (isTouchFling) {
// Shared by every command issued during the same stop, so none of them reads back the
// "hidden" another one set as the value to restore.
this.momentumStop ??= this.stopMomentum()?.finally(() => { this.momentumStop = null; }) ?? null;
if (this.momentumStop) {
await this.momentumStop;
if (!this.cleanupMount) {
return;
}
}
}

command();
}

/** Hides the scroll axis's overflow for two frames; null when it had to be put back at once. */
stopMomentum() {
const element = this.isWindowScroller ? this.findScrollContainer(this.rootElement) : this.scrollContainer;
const property = this.options.horizontal ? 'overflow-x' : 'overflow-y';
const { style } = element;
const value = style.getPropertyValue(property);
const priority = style.getPropertyPriority(property);
const crossSize = () => this.options.horizontal ? element.clientHeight : element.clientWidth;
const crossSizeBefore = crossSize();
style.setProperty(property, 'hidden', 'important');
// A classic scrollbar takes layout space, so hiding it would reflow the list for two frames
// (a touchscreen laptop). Put it back before anything paints and write at once, as before.
if (crossSize() !== crossSizeBefore) {
style.setProperty(property, value, priority);
return null;
}
return nextFrame().then(nextFrame).then(() => style.setProperty(property, value, priority));
this.virtualizer.scrollBy(delta, { behavior });
}

cancelScroll() {
Expand All @@ -801,8 +716,6 @@ class AdaptiveVirtualizeInstance {
}

getOffsetForIndex(index, align) {
// virtual-core computes the offset from measurementsCache.
this.refreshMeasurements();
// Upstream returns [offset, alignment]; only the offset is meaningful to a caller.
const result = this.virtualizer.getOffsetForIndex(index, align);
return Array.isArray(result) ? result[0] : result;
Expand Down Expand Up @@ -863,9 +776,6 @@ class AdaptiveVirtualizeInstance {
this.containerObserver?.disconnect();
this.rowObserver?.disconnect();
clearTimeout(this.pendingNotification);
for (const [type, listener] of Object.entries(this.inputListeners)) {
window.removeEventListener(type, listener, { capture: true });
}
if (this.cleanupMount) {
try {
this.cleanupMount();
Expand Down

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -675,13 +675,14 @@ public async Task ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentum
list.viewport.scrollTop = 600;
await nextFrame();

const command = list.instance.scrollToIndex(100, 'start', 'auto');
list.instance.scrollToIndex(100, 'start', 'auto');
const sample = {
overflowWhenIssued: list.viewport.style.getPropertyValue('overflow-y'),
scrollTopWhenIssued: list.viewport.scrollTop,
};
await command;
await delay(100);
// Past the ~150 ms after the last fling frame at which virtual-core writes a
// command it could not stop the fling for.
await delay(300);
sample.overflowAfterwards = list.viewport.style.getPropertyValue('overflow-y');
sample.scrollTopAfterwards = list.viewport.scrollTop;
list.instance.dispose();
Expand All @@ -707,11 +708,13 @@ public async Task ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentum
Assert.Equal(5000, wheel.ScrollTopWhenIssued);

// A scrollbar that takes layout space would vanish for two frames and reflow the list (a
// touchscreen laptop), so the overflow is put back before anything paints and the write
// goes out at once.
// touchscreen laptop), so the overflow is put back before anything paints. The fling is not
// stopped, so virtual-core holds the write until the fling has ended.
var classicScrollbar = runs[2];
Assert.Equal("auto", classicScrollbar.OverflowWhenIssued);
Assert.Equal(5000, classicScrollbar.ScrollTopWhenIssued);
Assert.Equal(600, classicScrollbar.ScrollTopWhenIssued);
Assert.Equal("auto", classicScrollbar.OverflowAfterwards);
Assert.Equal(5000, classicScrollbar.ScrollTopAfterwards);
_fixture.AssertNoJsErrors();
}

Expand Down
5 changes: 3 additions & 2 deletions tests/Blazor.TanStack.VirtualCore.E2E/OptionCoverageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@

// Act - well past virtual-core's default 150ms isScrollingResetDelay
await _fixture.ScrollViewportAsync("directional", 600);
await Task.Delay(1000);

Check warning on line 187 in tests/Blazor.TanStack.VirtualCore.E2E/OptionCoverageTests.cs

View workflow job for this annotation

GitHub Actions / virtualization-tests

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
var buffer = await ReadBufferAsync(canvas);

// Assert
Expand Down Expand Up @@ -324,7 +324,7 @@
// Act
// In a right-to-left container scrollLeft starts at 0 and goes negative as the user moves on.
await _fixture.Page.EvalOnSelectorAsync("[data-testid=\"rtl-viewport\"]", "el => el.scrollLeft -= 4000");
await Task.Delay(1000);

Check warning on line 327 in tests/Blazor.TanStack.VirtualCore.E2E/OptionCoverageTests.cs

View workflow job for this annotation

GitHub Actions / virtualization-tests

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
var scrolled = await ReadRtlRailAsync(targetIndex: 0);

// Assert
Expand All @@ -347,12 +347,13 @@

// Act
await canvas.GetByTestId("rtl-scroll-target").ClickAsync();
await Task.Delay(1000);

Check warning on line 350 in tests/Blazor.TanStack.VirtualCore.E2E/OptionCoverageTests.cs

View workflow job for this annotation

GitHub Actions / virtualization-tests

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
var rail = await ReadRtlRailAsync(targetIndex: 500);

// Assert
// virtual-core reads the offset negated under isRtl but writes it as is, and a positive
// scrollLeft in a right-to-left container clamps to the start - the jump would go nowhere.
// A positive scrollLeft in a right-to-left container clamps to the start, so the write has to
// be negated exactly once - virtual-core does it since 3.19.11, and negating it here as well
// would send the jump back to the start.
Assert.True(rail.TargetVisible, $"Expected item 500 on screen; the rendered window is {rail.FirstIndex}-{rail.LastIndex}.");
_fixture.AssertNoJsErrors();
}
Expand Down
Loading