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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Setting `min` or `max` with a value before the first render no longer throws a `TypeError`.
- #### Color picker
- The HSL saturation no longer becomes infinite for a very small saturation at full value.
- #### Virtual scroll
- `layoutComplete` now resolves after the rendered items are measured. Before, it could resolve first, so `scrollToIndex` stopped its correction early, and an item with a size other than the estimate landed up to tens of pixels from the requested edge.
- A list larger than the maximum scroll size of the browser now shows its last items at the end of the scroll range, and `scrollToIndex` puts the item at the requested edge. The items also move evenly during a scroll. Before, the last items could not be reached, the item landed tens of pixels off, and the items jumped by some pixels each time the rendered window changed.

## [7.4.1] - 2026-09-25
### Added
Expand Down
79 changes: 61 additions & 18 deletions src/components/virtualization/engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,11 @@ describe('VirtualScrollEngine', () => {

describe('Coordinate compression', () => {
const MAX_SIZE = 10_000;
const ITEMS = 1000; // 50_000px total, a ratio of 5
const ITEMS = 1000; // 50_000px total
const VIEWPORT = 300;
// The virtual scroll range over the DOM one: 49_700 / 9_700.
const RATIO = (50_000 - VIEWPORT) / (MAX_SIZE - VIEWPORT);
const EPSILON = 1e-6;

it('clamps the DOM size to the maximum browser size', () => {
const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS);
Expand All @@ -570,11 +574,50 @@ describe('VirtualScrollEngine', () => {
it('maps DOM scroll positions onto the virtual space', () => {
const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS);

// Halfway down the DOM range is halfway down the virtual range.
expect(engine.getVisibleRange(MAX_SIZE / 2, 300, 0).startIndex).to.equal(
500
// Halfway down the DOM scroll range (4850px) is halfway down the
// virtual one (24_850px), where item 497 starts. One DOM px past it
// stays clear of the rounding at the item boundary.
expect(engine.getVisibleRange(4851, VIEWPORT, 0).startIndex).to.equal(
497
);
expect(engine.getScrollOffsetForIndex(497, VIEWPORT)).to.be.closeTo(
4850,
EPSILON
);
});

it('shows the end of the list at the largest DOM scroll offset', () => {
const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS);
const maxOffset = MAX_SIZE - VIEWPORT;

expect(engine.getVisibleRange(maxOffset, VIEWPORT, 0).endIndex).to.equal(
ITEMS - 1
);
expect(
engine.getAlignedScrollOffset(ITEMS - 1, VIEWPORT, 'end')
).to.be.closeTo(maxOffset, EPSILON);
});

it('places the content so the viewport shows its virtual position', () => {
const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS);

// At DOM offset 4850, the viewport starts at virtual offset 24_850, so
// item 497 is at the top edge, and the items before it are at their
// real size above it.
expect(engine.getContentOffset(497, 4850, VIEWPORT)).to.be.closeTo(
4850,
EPSILON
);
expect(engine.getContentOffset(495, 4850, VIEWPORT)).to.be.closeTo(
4850 - 2 * ESTIMATE,
EPSILON
);

// Without compression, the offset is that of the item at any scroll.
const uncompressed = createEngineWithMaxSize(MAX_SIZE, 100);
expect(uncompressed.getContentOffset(10, 1234, VIEWPORT)).to.equal(
10 * ESTIMATE
);
expect(engine.getScrollOffsetForIndex(500)).to.equal(MAX_SIZE / 2);
});

it('sizes the rendered window by the viewport, not by the ratio', () => {
Expand All @@ -588,26 +631,26 @@ describe('VirtualScrollEngine', () => {

it('converts the alignment slack into DOM space', () => {
const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS);
const start = engine.getAlignedScrollOffset(500, 300, 'start');
const centered = engine.getAlignedScrollOffset(500, 300, 'center');
const start = engine.getAlignedScrollOffset(500, VIEWPORT, 'start');
const centered = engine.getAlignedScrollOffset(500, VIEWPORT, 'center');

// The slack is 125 virtual px, which is 25 DOM px at a ratio of 5.
expect(start).to.equal(MAX_SIZE / 2);
expect(centered).to.equal(MAX_SIZE / 2 - 25);
// The slack is 125 virtual px, which is 125 / RATIO DOM px.
expect(start).to.be.closeTo(25_000 / RATIO, EPSILON);
expect(centered).to.be.closeTo((25_000 - 125) / RATIO, EPSILON);
});

it('resolves nearest in DOM space', () => {
const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS);

// DOM offset 2000 is virtual offset 10_000. Item 300 spans
// 15_000-15_050, after the viewport.
expect(engine.resolveScrollOffset(300, 2000, 300, 'nearest')).to.equal(
(15_050 - 300) / 5
);
// DOM offset 2000 is virtual offset 2000 * RATIO, about 10_247. Item
// 300 spans 15_000-15_050, after the viewport.
expect(
engine.resolveScrollOffset(300, 2000, VIEWPORT, 'nearest')
).to.be.closeTo((15_050 - VIEWPORT) / RATIO, EPSILON);
// Item 100 spans 5000-5050, before the viewport.
expect(engine.resolveScrollOffset(100, 2000, 300, 'nearest')).to.equal(
5000 / 5
);
expect(
engine.resolveScrollOffset(100, 2000, VIEWPORT, 'nearest')
).to.be.closeTo(5000 / RATIO, EPSILON);
});

it('probes a given document only once', () => {
Expand Down
94 changes: 62 additions & 32 deletions src/components/virtualization/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,21 +257,20 @@ class SizeTree {
* Browsers limit how far an element can scroll. When the total item size is
* larger than that limit, the engine compresses the *virtual* space
* (`0…totalSize`) into the *DOM* space the browser can represent
* (`0…domSize`) by the factor `_virtualRatio`. Each offset that crosses that
* (`0…domSize`). The ratio maps the scroll ranges onto each other:
* `(totalSize - viewportSize) / (domSize - viewportSize)`, so the last DOM
* scroll position shows the end of the list. Each offset that crosses that
* boundary is scaled: incoming scroll positions are multiplied by the ratio,
* and outgoing offsets are divided by it. Items render at their real pixel
* size, so item sizes are always virtual.
* and outgoing offsets are divided by it.
*
* Items render at their real pixel size, so item sizes are always virtual.
* The viewport shows the virtual pixels from `scrollPosition * ratio`, so the
* content moves by the ratio for each DOM pixel of scroll. See
* `getContentOffset`.
*/
export class VirtualScrollEngine {
private _maxBrowserSize = Number.POSITIVE_INFINITY;

/**
* Maps a virtual scroll position to a DOM scroll position. The ratio
* `totalSize / maxBrowserSize` if `totalSize` is larger than the maximum DOM
* coordinate of the browser, and `1` in all other cases.
*/
private _virtualRatio = 1;

/** Binary Indexed Tree for O(log N) size queries and updates. */
private _tree: SizeTree | null = null;

Expand All @@ -294,13 +293,17 @@ export class VirtualScrollEngine {

/** Total size in DOM space, clamped to the maximum browser size. */
public get domSize(): number {
return this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize;
return Math.min(this.totalSize, this._maxBrowserSize);
}

/** Whether the virtual space is compressed into a smaller DOM space. */
public get isCompressed(): boolean {
return this.totalSize > this._maxBrowserSize;
}

/** Measures the maximum browser size for the document and rescales. */
/** Measures the maximum browser size for the document. */
public initMaxBrowserSize(doc: Document): void {
this._maxBrowserSize = getMaxBrowserSizeProbePx(doc);
this._updateVirtualRatio();
}

/**
Expand Down Expand Up @@ -330,15 +333,13 @@ export class VirtualScrollEngine {
tree.estimate = estimatedSize;
}
this._tree = tree;
this._updateVirtualRatio();
this.onSizeChange?.();
}

/** Records the measured DOM size for a single item. */
public measureItem(index: number, size: number): void {
if (!this._tree?.update(index, size)) return;

this._updateVirtualRatio();
this.onSizeChange?.();
}

Expand Down Expand Up @@ -373,13 +374,39 @@ export class VirtualScrollEngine {

/**
* Returns the DOM scroll offset in px that puts the item at `index` at the
* leading edge of the viewport.
* leading edge of a `viewportSize` px viewport. Not clamped to the
* reachable scroll range, see `getAlignedScrollOffset`.
*/
public getScrollOffsetForIndex(index: number): number {
public getScrollOffsetForIndex(index: number, viewportSize = 0): number {
if (!this._tree || index <= 0) return 0;

const clamped = Math.min(index, this._tree.length);
return this._tree.prefixSum(clamped) / this._virtualRatio;
return this._tree.prefixSum(clamped) / this._ratio(viewportSize);
}

/**
* Returns the DOM offset of the content wrapper, whose first item is at
* `startIndex`, for the given scroll state. The items then show the virtual
* pixels from `scrollPosition * ratio` at the leading edge of the viewport,
* as `getVisibleRange` and the alignment math expect.
*
* Without compression, the result is the offset of the item and does not
* depend on `scrollPosition`. With it, the result changes on each scroll, so
* the content moves by the ratio for each DOM pixel.
*/
public getContentOffset(
startIndex: number,
scrollPosition: number,
viewportSize: number
): number {
if (!this._tree || this._tree.length === 0) return 0;

const start = this._tree.prefixSum(
clampIndex(startIndex, this._tree.length)
);
return (
start - Math.max(0, scrollPosition) * (this._ratio(viewportSize) - 1)
);
}

/**
Expand All @@ -397,9 +424,8 @@ export class VirtualScrollEngine {
* reachable scroll range.
*
* The slack is computed in virtual space against the item's real size and
* converted to DOM space once, at the end. One DOM pixel equals
* `_virtualRatio` virtual pixels, so mixed coordinates would scale the
* slack.
* converted to DOM space once, at the end. One DOM pixel equals `_ratio`
* virtual pixels, so mixed coordinates would scale the slack.
*/
public getAlignedScrollOffset(
index: number,
Expand All @@ -419,7 +445,7 @@ export class VirtualScrollEngine {
}

return clamp(
offset / this._virtualRatio,
offset / this._ratio(viewportSize),
0,
this._getMaxScrollOffset(viewportSize)
);
Expand Down Expand Up @@ -466,7 +492,7 @@ export class VirtualScrollEngine {
const clamped = clampIndex(index, this._tree.length);
const itemStart = this._tree.prefixSum(clamped);
const itemEnd = this._tree.prefixSum(clamped + 1);
const viewStart = Math.max(0, scrollPosition) * this._virtualRatio;
const viewStart = Math.max(0, scrollPosition) * this._ratio(viewportSize);
const viewEnd = viewStart + viewportSize;

const contained = itemStart >= viewStart && itemEnd <= viewEnd;
Expand All @@ -491,7 +517,7 @@ export class VirtualScrollEngine {
// The virtual ratio does not scale the viewport. Items render at their real
// pixel size, so a `viewportSize` px viewport shows that many virtual pixels
// of items at any compression of the scroll range.
const startOffset = Math.max(0, scrollPosition) * this._virtualRatio;
const startOffset = Math.max(0, scrollPosition) * this._ratio(viewportSize);
const first = this._tree.findIndexAtOffset(startOffset);
const last = this._tree.findIndexAtOffset(startOffset + viewportSize);

Expand All @@ -502,9 +528,7 @@ export class VirtualScrollEngine {
}

/**
* Sum of the actual sizes of the items in [startIndex, endIndex]. The
* render pass uses it to clamp the content translate offset, so rendered
* items do not overflow past `domSize` under coordinate compression.
* Sum of the actual sizes of the items in [startIndex, endIndex].
*/
public getPhysicalRangeSize(startIndex: number, endIndex: number): number {
if (!this._tree) return 0;
Expand Down Expand Up @@ -534,13 +558,19 @@ export class VirtualScrollEngine {
tree.estimate = estimatedSize;
if (tree.totalSize === total) return;

this._updateVirtualRatio();
this.onSizeChange?.();
}

private _updateVirtualRatio(): void {
const totalSize = this._tree?.totalSize ?? 0;
this._virtualRatio =
totalSize <= this._maxBrowserSize ? 1 : totalSize / this._maxBrowserSize;
/**
* The number of virtual pixels in one DOM pixel of scroll for a
* `viewportSize` px viewport: `1` without compression. With it, the ratio of
* the virtual scroll range to the DOM one, so the largest DOM scroll offset
* shows the end of the list.
*/
private _ratio(viewportSize: number): number {
const domRange = this._maxBrowserSize - viewportSize;
return this.isCompressed && domRange > 0
? (this.totalSize - viewportSize) / domRange
: 1;
}
}
Loading
Loading