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
85 changes: 72 additions & 13 deletions src/components/virtualList/variable.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ export default class VariableVirtualList {
this.offsets = [0];
this.offsetsDirty = false;
this.renderedRange = { start: 0, end: 0 };
this.renderedItemCount = 0;
this.frame = null;
this.stickToBottom = true;
this.lastScrollTop = this.container.scrollTop;
this.lastScrollTime = this.now();
this.scrollDirection = 0;
this.touching = false;
this.releaseTimer = null;
this.destroyed = false;
this.observedItems = new Set();

this.topSpacer = document.createElement("div");
this.topSpacer.className = "variable-virtual-spacer";
Expand All @@ -59,7 +62,11 @@ export default class VariableVirtualList {
this.stickToBottom = this.isNearBottom();
const now = this.now();
const elapsed = Math.max(8, now - this.lastScrollTime);
const distance = Math.abs(this.container.scrollTop - this.lastScrollTop);
const delta = this.container.scrollTop - this.lastScrollTop;
const direction = Math.sign(delta) || this.scrollDirection;
const directionChanged = direction !== this.scrollDirection;
this.scrollDirection = direction;
const distance = Math.abs(delta);
const projectedDistance = distance * (32 / elapsed);
this.dynamicOverscan = Math.min(
this.maxOverscan,
Expand All @@ -69,13 +76,17 @@ export default class VariableVirtualList {
this.lastScrollTime = now;
// Scroll rendering is intentionally synchronous. Android WebView can move
// the compositor several rows before the next animation frame.
this.render();
// Refill immediately on reversal, before relying on the smaller rear buffer.
this.render(!directionChanged);
this.scheduleOverscanRelease();
};
this.onTouchStart = () => {
this.touching = true;
this.clearOverscanRelease();
this.dynamicOverscan = this.activeOverscan;
this.scrollDirection = 0;
this.lastScrollTop = this.container.scrollTop;
this.lastScrollTime = this.now();
// Pre-paint the fling guard before compositor scrolling begins.
this.render();
};
Expand All @@ -99,6 +110,7 @@ export default class VariableVirtualList {
? new ResizeObserver((entries) => this.onResize(entries))
: null;
this.resizeObserver?.observe(this.container);
if (this.footer) this.resizeObserver?.observe(this.footer);
}

now() {
Expand All @@ -115,6 +127,7 @@ export default class VariableVirtualList {
this.releaseTimer = null;
if (this.destroyed || this.touching) return;
this.dynamicOverscan = this.overscan;
this.scrollDirection = 0;
this.render();
},
500,
Expand Down Expand Up @@ -144,7 +157,11 @@ export default class VariableVirtualList {
};
this.items.push(item);
this.itemByElement.set(element, item);
this.offsetsDirty = true;
// Existing offsets remain valid on append. Height changes still take the
// rebuild path, including any messages appended before that rebuild.
if (!this.offsetsDirty) {
this.offsets.push(this.offsets[this.offsets.length - 1] + item.height);
}
if (wasNearBottom || this.items.length === 1) this.stickToBottom = true;
this.scheduleRender();
}
Expand All @@ -155,12 +172,16 @@ export default class VariableVirtualList {
this.offsets = [0];
this.offsetsDirty = false;
this.renderedRange = { start: 0, end: 0 };
this.renderedItemCount = 0;
this.stickToBottom = true;
this.dynamicOverscan = this.overscan;
this.scrollDirection = 0;
this.topSpacer.style.height = "0px";
this.bottomSpacer.style.height = "0px";
this.itemContainer.replaceChildren();
this.container.scrollTop = 0;
this.lastScrollTop = 0;
this.lastScrollTime = this.now();
this.footerHeight = this.getFooterHeight();
this.observeResizeTargets();
}
Expand Down Expand Up @@ -216,17 +237,56 @@ export default class VariableVirtualList {

observeResizeTargets() {
if (!this.resizeObserver) return;
this.resizeObserver.disconnect();
this.resizeObserver.observe(this.container);
for (const element of this.itemContainer.children) {
this.resizeObserver.observe(element);
const mountedItems = new Set(this.itemContainer.children);
for (const element of this.observedItems) {
if (!mountedItems.has(element)) this.resizeObserver.unobserve(element);
}
if (this.footer) this.resizeObserver.observe(this.footer);
for (const element of mountedItems) {
if (!this.observedItems.has(element))
this.resizeObserver.observe(element);
}
this.observedItems = mountedItems;
}

getOverscan() {
const ahead = this.dynamicOverscan;
// Keep a rear guard for abrupt reversals, while spending most of the
// active buffer on the direction the reader is moving toward.
const behind = Math.max(this.overscan, ahead / 4);
return {
before: this.scrollDirection > 0 ? behind : ahead,
after: this.scrollDirection < 0 ? behind : ahead,
};
}

render() {
render(scrollOnly = false) {
if (this.destroyed) return;
const overscan = this.getOverscan();
// Keep the mounted window stable while there is still a safety buffer on
// both sides. Touch start and scheduled data/measurement updates always
// render, so this shortcut cannot hide appended or resized content.
if (
scrollOnly &&
!this.offsetsDirty &&
!this.stickToBottom &&
this.renderedItemCount === this.items.length
) {
const { start, end } = this.renderedRange;
const scrollTop = this.container.scrollTop;
if (
end > start &&
this.container.clientHeight === this.viewportHeight &&
(start === 0 ||
scrollTop >= this.offsets[start] + overscan.before / 2) &&
(end === this.items.length ||
scrollTop + this.viewportHeight <=
this.offsets[end] - overscan.after / 2)
) {
return;
}
}
this.rebuildOffsets();
this.renderedItemCount = this.items.length;
if (!this.items.length) {
this.footerHeight = this.getFooterHeight();
this.itemContainer.replaceChildren();
Expand All @@ -248,13 +308,11 @@ export default class VariableVirtualList {
? Math.max(0, totalHeight + this.footerHeight - viewportHeight)
: this.container.scrollTop;
const start = this.findIndexAt(
Math.max(0, targetScrollTop - this.dynamicOverscan),
Math.max(0, targetScrollTop - overscan.before),
);
const end = Math.min(
this.items.length,
this.findIndexAt(
targetScrollTop + viewportHeight + this.dynamicOverscan,
) + 1,
this.findIndexAt(targetScrollTop + viewportHeight + overscan.after) + 1,
);

this.updateMountedRange(start, end);
Expand Down Expand Up @@ -399,6 +457,7 @@ export default class VariableVirtualList {
this.frame = null;
this.clearOverscanRelease();
this.resizeObserver?.disconnect();
this.observedItems.clear();
this.container.removeEventListener("scroll", this.onScroll);
this.container.removeEventListener("touchstart", this.onTouchStart);
this.container.removeEventListener("touchend", this.onTouchEnd);
Expand Down
172 changes: 171 additions & 1 deletion tests/unit/variableVirtualList.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom

import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import VariableVirtualList from "components/virtualList/variable";

function createList() {
Expand All @@ -14,6 +14,176 @@ function createList() {
}

describe("VariableVirtualList", () => {
it.each([
0, 360,
])("covers jumps, reversals, and list edges with %i pixel overscan", (overscan) => {
const container = document.createElement("div");
Object.defineProperties(container, {
clientHeight: { value: 200 },
scrollHeight: { value: 52000 },
});
document.body.append(container);
const list = new VariableVirtualList(container, { overscan });
for (let i = 0; i < 1000; i++) list.append(document.createElement("div"));
for (const offset of [0, 10, 10000, 12000, 11999, 500, 51800, 40000, 0]) {
container.scrollTop = offset;
list.onScroll();
expect(list.offsets[list.renderedRange.start]).toBeLessThanOrEqual(
container.scrollTop,
);
expect(list.offsets[list.renderedRange.end]).toBeGreaterThanOrEqual(
container.scrollTop + 200,
);
}
list.clear();
expect(list.scrollDirection).toBe(0);
list.destroy();
container.remove();
});

it("extends clean offsets without reading previous message heights", () => {
const { container, list } = createList();
for (let i = 0; i < 100; i++) list.append(document.createElement("div"));
const offsets = list.offsets;
const heightRead = vi.fn(() => 52);
for (const item of list.items)
Object.defineProperty(item, "height", { get: heightRead });
for (let i = 0; i < 100; i++) list.append(document.createElement("div"));
list.rebuildOffsets();
expect(heightRead).not.toHaveBeenCalled();
expect(list.offsets).toBe(offsets);
expect(list.offsets).toHaveLength(201);
expect(list.offsets[200]).toBe(10400);
list.destroy();
container.remove();
});

it("reconciles appended offsets after height changes and after clearing", () => {
const { container, list } = createList();
list.append(document.createElement("div"));
list.append(document.createElement("div"));
list.updateHeight(list.items[0], 100);
list.append(document.createElement("div"));
list.append(document.createElement("div"));
list.rebuildOffsets();
expect(list.offsets).toEqual([0, 100, 152, 204, 256]);
list.append(document.createElement("div"));
expect(list.offsets).toEqual([0, 100, 152, 204, 256, 308]);
list.clear();
list.append(document.createElement("div"));
expect(list.offsets).toEqual([0, 52]);
list.destroy();
container.remove();
});

it("moves overscan ahead of scrolling and refills immediately on reversal", () => {
const { container, list } = createList();
Object.defineProperty(container, "scrollHeight", { value: 52000 });
for (let i = 0; i < 1000; i++) list.append(document.createElement("div"));
list.stickToBottom = false;
container.scrollTop = 20000;
list.onTouchStart();
const symmetricCount = list.mountedCount;
vi.spyOn(list, "now").mockReturnValue(list.lastScrollTime + 32);
const margins = () => ({
before: container.scrollTop - list.offsets[list.renderedRange.start],
after:
list.offsets[list.renderedRange.end] -
container.scrollTop -
container.clientHeight,
});
container.scrollTop += 10;
list.onScroll();
expect(margins().after).toBeGreaterThan(margins().before * 2);
expect(list.mountedCount).toBeLessThan(symmetricCount);
const mounted = vi.spyOn(list, "updateMountedRange");
container.scrollTop -= 10;
list.onScroll();
expect(mounted).toHaveBeenCalledOnce();
expect(margins().before).toBeGreaterThan(margins().after * 2);
expect(margins().after).toBeGreaterThanOrEqual(list.overscan);
list.onTouchStart();
expect(Math.abs(margins().before - margins().after)).toBeLessThan(52);
list.destroy();
container.remove();
});

it("retains resize observations for overlapping rows and releases removed rows", () => {
const { container, list } = createList();
const observe = vi.spyOn(list.resizeObserver, "observe");
const unobserve = vi.spyOn(list.resizeObserver, "unobserve");
const disconnect = vi.spyOn(list.resizeObserver, "disconnect");
for (let index = 0; index < 200; index++) {
list.append(document.createElement("div"));
}
list.stickToBottom = false;
container.scrollTop = 1000;
list.render();
const previous = new Set(list.itemContainer.children);
observe.mockClear();

list.render();
expect(observe).not.toHaveBeenCalled();
expect(unobserve).not.toHaveBeenCalled();
expect(disconnect).not.toHaveBeenCalled();

container.scrollTop += 104;
list.render();
const current = new Set(list.itemContainer.children);
expect(observe.mock.calls.map(([element]) => element)).toEqual(
[...current].filter((element) => !previous.has(element)),
);
expect(unobserve.mock.calls.map(([element]) => element)).toEqual(
[...previous].filter((element) => !current.has(element)),
);
unobserve.mockClear();
list.clear();
expect(unobserve).toHaveBeenCalledTimes(current.size);
list.destroy();
container.remove();
});

it("leaves buffered rows untouched until the viewport approaches their edge", () => {
const { container, list } = createList();
Object.defineProperty(container, "scrollHeight", { value: 52000 });
for (let index = 0; index < 1000; index++) {
list.append(document.createElement("div"));
}
list.stickToBottom = false;
container.scrollTop = 10000;
list.onScroll();
list.onTouchStart();
container.scrollTop += 20;
list.onScroll();
const mounted = vi.spyOn(list, "updateMountedRange");
const initialRange = { ...list.renderedRange };
container.scrollTop += 20;
list.onScroll();
expect(mounted).not.toHaveBeenCalled();
expect(list.renderedRange).toEqual(initialRange);

container.scrollTop += 2000;
list.onScroll();
expect(mounted).toHaveBeenCalledOnce();
expect(list.offsets[list.renderedRange.start]).toBeLessThan(
container.scrollTop,
);
expect(list.offsets[list.renderedRange.end]).toBeGreaterThan(
container.scrollTop + container.clientHeight,
);

mounted.mockClear();
list.updateHeight(list.items[list.renderedRange.start], 120);
list.onScroll();
expect(mounted).toHaveBeenCalledOnce();
mounted.mockClear();
list.append(document.createElement("div"));
list.onScroll();
expect(mounted).toHaveBeenCalledOnce();
list.destroy();
container.remove();
});

it("retains all items while mounting only the viewport window", () => {
const { container, list } = createList();
const elements = Array.from({ length: 1000 }, (_, index) => {
Expand Down