Skip to content

Commit 1e7043e

Browse files
authored
feat: improve console virtualisation (#2882)
1 parent 3412460 commit 1e7043e

2 files changed

Lines changed: 243 additions & 14 deletions

File tree

‎src/components/virtualList/variable.js‎

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ export default class VariableVirtualList {
3434
this.offsets = [0];
3535
this.offsetsDirty = false;
3636
this.renderedRange = { start: 0, end: 0 };
37+
this.renderedItemCount = 0;
3738
this.frame = null;
3839
this.stickToBottom = true;
3940
this.lastScrollTop = this.container.scrollTop;
4041
this.lastScrollTime = this.now();
42+
this.scrollDirection = 0;
4143
this.touching = false;
4244
this.releaseTimer = null;
4345
this.destroyed = false;
46+
this.observedItems = new Set();
4447

4548
this.topSpacer = document.createElement("div");
4649
this.topSpacer.className = "variable-virtual-spacer";
@@ -59,7 +62,11 @@ export default class VariableVirtualList {
5962
this.stickToBottom = this.isNearBottom();
6063
const now = this.now();
6164
const elapsed = Math.max(8, now - this.lastScrollTime);
62-
const distance = Math.abs(this.container.scrollTop - this.lastScrollTop);
65+
const delta = this.container.scrollTop - this.lastScrollTop;
66+
const direction = Math.sign(delta) || this.scrollDirection;
67+
const directionChanged = direction !== this.scrollDirection;
68+
this.scrollDirection = direction;
69+
const distance = Math.abs(delta);
6370
const projectedDistance = distance * (32 / elapsed);
6471
this.dynamicOverscan = Math.min(
6572
this.maxOverscan,
@@ -69,13 +76,17 @@ export default class VariableVirtualList {
6976
this.lastScrollTime = now;
7077
// Scroll rendering is intentionally synchronous. Android WebView can move
7178
// the compositor several rows before the next animation frame.
72-
this.render();
79+
// Refill immediately on reversal, before relying on the smaller rear buffer.
80+
this.render(!directionChanged);
7381
this.scheduleOverscanRelease();
7482
};
7583
this.onTouchStart = () => {
7684
this.touching = true;
7785
this.clearOverscanRelease();
7886
this.dynamicOverscan = this.activeOverscan;
87+
this.scrollDirection = 0;
88+
this.lastScrollTop = this.container.scrollTop;
89+
this.lastScrollTime = this.now();
7990
// Pre-paint the fling guard before compositor scrolling begins.
8091
this.render();
8192
};
@@ -99,6 +110,7 @@ export default class VariableVirtualList {
99110
? new ResizeObserver((entries) => this.onResize(entries))
100111
: null;
101112
this.resizeObserver?.observe(this.container);
113+
if (this.footer) this.resizeObserver?.observe(this.footer);
102114
}
103115

104116
now() {
@@ -115,6 +127,7 @@ export default class VariableVirtualList {
115127
this.releaseTimer = null;
116128
if (this.destroyed || this.touching) return;
117129
this.dynamicOverscan = this.overscan;
130+
this.scrollDirection = 0;
118131
this.render();
119132
},
120133
500,
@@ -144,7 +157,11 @@ export default class VariableVirtualList {
144157
};
145158
this.items.push(item);
146159
this.itemByElement.set(element, item);
147-
this.offsetsDirty = true;
160+
// Existing offsets remain valid on append. Height changes still take the
161+
// rebuild path, including any messages appended before that rebuild.
162+
if (!this.offsetsDirty) {
163+
this.offsets.push(this.offsets[this.offsets.length - 1] + item.height);
164+
}
148165
if (wasNearBottom || this.items.length === 1) this.stickToBottom = true;
149166
this.scheduleRender();
150167
}
@@ -155,12 +172,16 @@ export default class VariableVirtualList {
155172
this.offsets = [0];
156173
this.offsetsDirty = false;
157174
this.renderedRange = { start: 0, end: 0 };
175+
this.renderedItemCount = 0;
158176
this.stickToBottom = true;
159177
this.dynamicOverscan = this.overscan;
178+
this.scrollDirection = 0;
160179
this.topSpacer.style.height = "0px";
161180
this.bottomSpacer.style.height = "0px";
162181
this.itemContainer.replaceChildren();
163182
this.container.scrollTop = 0;
183+
this.lastScrollTop = 0;
184+
this.lastScrollTime = this.now();
164185
this.footerHeight = this.getFooterHeight();
165186
this.observeResizeTargets();
166187
}
@@ -216,17 +237,56 @@ export default class VariableVirtualList {
216237

217238
observeResizeTargets() {
218239
if (!this.resizeObserver) return;
219-
this.resizeObserver.disconnect();
220-
this.resizeObserver.observe(this.container);
221-
for (const element of this.itemContainer.children) {
222-
this.resizeObserver.observe(element);
240+
const mountedItems = new Set(this.itemContainer.children);
241+
for (const element of this.observedItems) {
242+
if (!mountedItems.has(element)) this.resizeObserver.unobserve(element);
223243
}
224-
if (this.footer) this.resizeObserver.observe(this.footer);
244+
for (const element of mountedItems) {
245+
if (!this.observedItems.has(element))
246+
this.resizeObserver.observe(element);
247+
}
248+
this.observedItems = mountedItems;
249+
}
250+
251+
getOverscan() {
252+
const ahead = this.dynamicOverscan;
253+
// Keep a rear guard for abrupt reversals, while spending most of the
254+
// active buffer on the direction the reader is moving toward.
255+
const behind = Math.max(this.overscan, ahead / 4);
256+
return {
257+
before: this.scrollDirection > 0 ? behind : ahead,
258+
after: this.scrollDirection < 0 ? behind : ahead,
259+
};
225260
}
226261

227-
render() {
262+
render(scrollOnly = false) {
228263
if (this.destroyed) return;
264+
const overscan = this.getOverscan();
265+
// Keep the mounted window stable while there is still a safety buffer on
266+
// both sides. Touch start and scheduled data/measurement updates always
267+
// render, so this shortcut cannot hide appended or resized content.
268+
if (
269+
scrollOnly &&
270+
!this.offsetsDirty &&
271+
!this.stickToBottom &&
272+
this.renderedItemCount === this.items.length
273+
) {
274+
const { start, end } = this.renderedRange;
275+
const scrollTop = this.container.scrollTop;
276+
if (
277+
end > start &&
278+
this.container.clientHeight === this.viewportHeight &&
279+
(start === 0 ||
280+
scrollTop >= this.offsets[start] + overscan.before / 2) &&
281+
(end === this.items.length ||
282+
scrollTop + this.viewportHeight <=
283+
this.offsets[end] - overscan.after / 2)
284+
) {
285+
return;
286+
}
287+
}
229288
this.rebuildOffsets();
289+
this.renderedItemCount = this.items.length;
230290
if (!this.items.length) {
231291
this.footerHeight = this.getFooterHeight();
232292
this.itemContainer.replaceChildren();
@@ -248,13 +308,11 @@ export default class VariableVirtualList {
248308
? Math.max(0, totalHeight + this.footerHeight - viewportHeight)
249309
: this.container.scrollTop;
250310
const start = this.findIndexAt(
251-
Math.max(0, targetScrollTop - this.dynamicOverscan),
311+
Math.max(0, targetScrollTop - overscan.before),
252312
);
253313
const end = Math.min(
254314
this.items.length,
255-
this.findIndexAt(
256-
targetScrollTop + viewportHeight + this.dynamicOverscan,
257-
) + 1,
315+
this.findIndexAt(targetScrollTop + viewportHeight + overscan.after) + 1,
258316
);
259317

260318
this.updateMountedRange(start, end);
@@ -399,6 +457,7 @@ export default class VariableVirtualList {
399457
this.frame = null;
400458
this.clearOverscanRelease();
401459
this.resizeObserver?.disconnect();
460+
this.observedItems.clear();
402461
this.container.removeEventListener("scroll", this.onScroll);
403462
this.container.removeEventListener("touchstart", this.onTouchStart);
404463
this.container.removeEventListener("touchend", this.onTouchEnd);

‎tests/unit/variableVirtualList.test.js‎

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment happy-dom
22

3-
import { describe, expect, it } from "vitest";
3+
import { describe, expect, it, vi } from "vitest";
44
import VariableVirtualList from "components/virtualList/variable";
55

66
function createList() {
@@ -14,6 +14,176 @@ function createList() {
1414
}
1515

1616
describe("VariableVirtualList", () => {
17+
it.each([
18+
0, 360,
19+
])("covers jumps, reversals, and list edges with %i pixel overscan", (overscan) => {
20+
const container = document.createElement("div");
21+
Object.defineProperties(container, {
22+
clientHeight: { value: 200 },
23+
scrollHeight: { value: 52000 },
24+
});
25+
document.body.append(container);
26+
const list = new VariableVirtualList(container, { overscan });
27+
for (let i = 0; i < 1000; i++) list.append(document.createElement("div"));
28+
for (const offset of [0, 10, 10000, 12000, 11999, 500, 51800, 40000, 0]) {
29+
container.scrollTop = offset;
30+
list.onScroll();
31+
expect(list.offsets[list.renderedRange.start]).toBeLessThanOrEqual(
32+
container.scrollTop,
33+
);
34+
expect(list.offsets[list.renderedRange.end]).toBeGreaterThanOrEqual(
35+
container.scrollTop + 200,
36+
);
37+
}
38+
list.clear();
39+
expect(list.scrollDirection).toBe(0);
40+
list.destroy();
41+
container.remove();
42+
});
43+
44+
it("extends clean offsets without reading previous message heights", () => {
45+
const { container, list } = createList();
46+
for (let i = 0; i < 100; i++) list.append(document.createElement("div"));
47+
const offsets = list.offsets;
48+
const heightRead = vi.fn(() => 52);
49+
for (const item of list.items)
50+
Object.defineProperty(item, "height", { get: heightRead });
51+
for (let i = 0; i < 100; i++) list.append(document.createElement("div"));
52+
list.rebuildOffsets();
53+
expect(heightRead).not.toHaveBeenCalled();
54+
expect(list.offsets).toBe(offsets);
55+
expect(list.offsets).toHaveLength(201);
56+
expect(list.offsets[200]).toBe(10400);
57+
list.destroy();
58+
container.remove();
59+
});
60+
61+
it("reconciles appended offsets after height changes and after clearing", () => {
62+
const { container, list } = createList();
63+
list.append(document.createElement("div"));
64+
list.append(document.createElement("div"));
65+
list.updateHeight(list.items[0], 100);
66+
list.append(document.createElement("div"));
67+
list.append(document.createElement("div"));
68+
list.rebuildOffsets();
69+
expect(list.offsets).toEqual([0, 100, 152, 204, 256]);
70+
list.append(document.createElement("div"));
71+
expect(list.offsets).toEqual([0, 100, 152, 204, 256, 308]);
72+
list.clear();
73+
list.append(document.createElement("div"));
74+
expect(list.offsets).toEqual([0, 52]);
75+
list.destroy();
76+
container.remove();
77+
});
78+
79+
it("moves overscan ahead of scrolling and refills immediately on reversal", () => {
80+
const { container, list } = createList();
81+
Object.defineProperty(container, "scrollHeight", { value: 52000 });
82+
for (let i = 0; i < 1000; i++) list.append(document.createElement("div"));
83+
list.stickToBottom = false;
84+
container.scrollTop = 20000;
85+
list.onTouchStart();
86+
const symmetricCount = list.mountedCount;
87+
vi.spyOn(list, "now").mockReturnValue(list.lastScrollTime + 32);
88+
const margins = () => ({
89+
before: container.scrollTop - list.offsets[list.renderedRange.start],
90+
after:
91+
list.offsets[list.renderedRange.end] -
92+
container.scrollTop -
93+
container.clientHeight,
94+
});
95+
container.scrollTop += 10;
96+
list.onScroll();
97+
expect(margins().after).toBeGreaterThan(margins().before * 2);
98+
expect(list.mountedCount).toBeLessThan(symmetricCount);
99+
const mounted = vi.spyOn(list, "updateMountedRange");
100+
container.scrollTop -= 10;
101+
list.onScroll();
102+
expect(mounted).toHaveBeenCalledOnce();
103+
expect(margins().before).toBeGreaterThan(margins().after * 2);
104+
expect(margins().after).toBeGreaterThanOrEqual(list.overscan);
105+
list.onTouchStart();
106+
expect(Math.abs(margins().before - margins().after)).toBeLessThan(52);
107+
list.destroy();
108+
container.remove();
109+
});
110+
111+
it("retains resize observations for overlapping rows and releases removed rows", () => {
112+
const { container, list } = createList();
113+
const observe = vi.spyOn(list.resizeObserver, "observe");
114+
const unobserve = vi.spyOn(list.resizeObserver, "unobserve");
115+
const disconnect = vi.spyOn(list.resizeObserver, "disconnect");
116+
for (let index = 0; index < 200; index++) {
117+
list.append(document.createElement("div"));
118+
}
119+
list.stickToBottom = false;
120+
container.scrollTop = 1000;
121+
list.render();
122+
const previous = new Set(list.itemContainer.children);
123+
observe.mockClear();
124+
125+
list.render();
126+
expect(observe).not.toHaveBeenCalled();
127+
expect(unobserve).not.toHaveBeenCalled();
128+
expect(disconnect).not.toHaveBeenCalled();
129+
130+
container.scrollTop += 104;
131+
list.render();
132+
const current = new Set(list.itemContainer.children);
133+
expect(observe.mock.calls.map(([element]) => element)).toEqual(
134+
[...current].filter((element) => !previous.has(element)),
135+
);
136+
expect(unobserve.mock.calls.map(([element]) => element)).toEqual(
137+
[...previous].filter((element) => !current.has(element)),
138+
);
139+
unobserve.mockClear();
140+
list.clear();
141+
expect(unobserve).toHaveBeenCalledTimes(current.size);
142+
list.destroy();
143+
container.remove();
144+
});
145+
146+
it("leaves buffered rows untouched until the viewport approaches their edge", () => {
147+
const { container, list } = createList();
148+
Object.defineProperty(container, "scrollHeight", { value: 52000 });
149+
for (let index = 0; index < 1000; index++) {
150+
list.append(document.createElement("div"));
151+
}
152+
list.stickToBottom = false;
153+
container.scrollTop = 10000;
154+
list.onScroll();
155+
list.onTouchStart();
156+
container.scrollTop += 20;
157+
list.onScroll();
158+
const mounted = vi.spyOn(list, "updateMountedRange");
159+
const initialRange = { ...list.renderedRange };
160+
container.scrollTop += 20;
161+
list.onScroll();
162+
expect(mounted).not.toHaveBeenCalled();
163+
expect(list.renderedRange).toEqual(initialRange);
164+
165+
container.scrollTop += 2000;
166+
list.onScroll();
167+
expect(mounted).toHaveBeenCalledOnce();
168+
expect(list.offsets[list.renderedRange.start]).toBeLessThan(
169+
container.scrollTop,
170+
);
171+
expect(list.offsets[list.renderedRange.end]).toBeGreaterThan(
172+
container.scrollTop + container.clientHeight,
173+
);
174+
175+
mounted.mockClear();
176+
list.updateHeight(list.items[list.renderedRange.start], 120);
177+
list.onScroll();
178+
expect(mounted).toHaveBeenCalledOnce();
179+
mounted.mockClear();
180+
list.append(document.createElement("div"));
181+
list.onScroll();
182+
expect(mounted).toHaveBeenCalledOnce();
183+
list.destroy();
184+
container.remove();
185+
});
186+
17187
it("retains all items while mounting only the viewport window", () => {
18188
const { container, list } = createList();
19189
const elements = Array.from({ length: 1000 }, (_, index) => {

0 commit comments

Comments
 (0)