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
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Shift-mode scroll compensation in the patched Virtua build
* (patches/virtua@0.49.3.patch), driven through the real `VList`.
*
* A head trim (a channel-window refresh dropping older pages) shrinks the
* list from the top. When the reader is within the removed height of the
* floor, the browser clamps `scrollTop` to the new floor before Virtua's
* layout-effect compensation runs. Compensating from that clamped offset
* counts the removal twice and throws the reader toward older history.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { VList } from "virtua";

const ROW = 50;
const VIEWPORT = 500;
const ROWS = Array.from({ length: 100 }, (_, index) => index);

// jsdom has no layout. Model the browser behavior under test: an
// `overflow-y: auto` element's scroll offset is clamped to its scrollable
// extent, which Virtua sets as its inner container's height.
const offsets = new WeakMap();
const isScroller = (element) => element.style?.overflowY === "auto";
const maxOffset = (element) =>
Math.max(
(Number.parseFloat(element.firstElementChild?.style.height) || 0) -
VIEWPORT,
0,
);
const clampOffset = (element, value) =>
Math.min(Math.max(value, 0), maxOffset(element));
Object.defineProperties(window.HTMLElement.prototype, {
clientHeight: {
configurable: true,
get() {
return isScroller(this) ? VIEWPORT : 0;
},
},
scrollHeight: {
configurable: true,
get() {
return isScroller(this) ? maxOffset(this) + VIEWPORT : 0;
},
},
scrollTop: {
configurable: true,
get() {
const offset = clampOffset(this, offsets.get(this) ?? 0);
offsets.set(this, offset);
return offset;
},
set(value) {
offsets.set(this, clampOffset(this, value));
},
},
offsetParent: {
configurable: true,
get() {
return this.parentElement;
},
},
});

const resizeObservers = new Set();
window.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = new Set();
resizeObservers.add(this);
}
observe(target) {
this.targets.add(target);
}
unobserve(target) {
this.targets.delete(target);
}
disconnect() {
resizeObservers.delete(this);
}
};

let root;
let container;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
});

function renderList(rows, shift) {
act(() =>
root.render(
React.createElement(
VList,
{ data: rows, itemSize: () => ROW, shift },
(row) => React.createElement("div", { key: row }, `row ${row}`),
),
),
);
}

/** Mounts the list and returns its scroller, with the viewport measured. */
function mountList(rows) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
renderList(rows, false);
const scroller = container.firstElementChild;
act(() => {
for (const observer of resizeObservers) {
if (observer.targets.has(scroller)) {
observer.callback([
{ target: scroller, contentRect: { width: 300, height: VIEWPORT } },
]);
}
}
});
return scroller;
}

function readerScrollsTo(scroller, offset) {
act(() => {
scroller.scrollTop = offset;
scroller.dispatchEvent(new window.Event("scroll"));
});
}

// 100 rows of 50px in a 500px viewport: the floor is at 4500, and trimming
// 10 rows removes 500px, more than the reader's distance from the floor in
// the last two cases.
for (const { name, start, trim, expected } of [
{ name: "far from the floor", start: 2000, trim: 10, expected: 1500 },
{ name: "near the floor", start: 4400, trim: 10, expected: 3900 },
{ name: "at the floor", start: 4500, trim: 10, expected: 4000 },
]) {
test(`a head trim ${name} keeps the reader's rows in place`, () => {
const scroller = mountList(ROWS);
readerScrollsTo(scroller, start);

renderList(ROWS.slice(trim), true);

assert.equal(scroller.scrollTop, expected);
});
}

test("a head trim keeps a scroll the browser has not reported yet", () => {
// The reader's latest move is still waiting for its scroll event, so the
// live offset, not the last observed one, is where the reader is.
const scroller = mountList(ROWS);
readerScrollsTo(scroller, 3000);
scroller.scrollTop = 2400;

renderList(ROWS.slice(10), true);

assert.equal(scroller.scrollTop, 1900);
});

test("shift compensations before the next scroll event accumulate", () => {
// Successive shift writes land before the browser reports any of them, so
// each must build on the last write, including across a floor clamp.
const scroller = mountList(ROWS);
readerScrollsTo(scroller, 4500);
const older = Array.from({ length: 10 }, (_, index) => -10 + index);

renderList([...older, ...ROWS], true);
assert.equal(scroller.scrollTop, 5000);

renderList(ROWS.slice(10), true);
assert.equal(scroller.scrollTop, 4000);
});
58 changes: 50 additions & 8 deletions patches/virtua@0.49.3.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/lib/index.cjs b/lib/index.cjs
index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1315bb55b 100644
index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c1f84bac4349fa3d0148eac9a7815111b35ce3fb 100644
--- a/lib/index.cjs
+++ b/lib/index.cjs
@@ -35,10 +35,18 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
Expand Down Expand Up @@ -64,16 +64,37 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1
const r = l() - c;
150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0);
}, v = () => {
@@ -266,7 +279,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
@@ -250,23 +264,24 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
r && r();
} ];
}, M = (e, t) => {
- let o, r, n = d(), s = !1;
- const i = t ? "scrollLeft" : "scrollTop", l = t ? "overflowX" : "overflowY", [f, u] = C(e, () => n[0], (e, r) => {
+ let o, r, n = d(), s = !1, kk = 0;
+ const i = t ? "scrollLeft" : "scrollTop", l = t ? "overflowX" : "overflowY", ss = t ? "scrollWidth" : "scrollHeight", cc = t ? "clientWidth" : "clientHeight", [f, u] = C(e, () => n[0], (e, r) => {
e = R(e, s), r ? o.scrollTo({
[t ? "left" : "top"]: e,
behavior: "smooth"
- }) : o[i] = e;
+ }) : (o[i] = e, kk = R(o[i], s));
});
return {
L(c, f) {
- o = f, t && (s = "rtl" === getComputedStyle(f).direction), r = T(e, f, t, () => R(f[i], s), (t, o, r) => {
+ o = f, t && (s = "rtl" === getComputedStyle(f).direction), r = T(e, f, t, () => kk = R(f[i], s), (t, o, r) => {
if (r) {
const e = f.style, t = e[l];
e[l] = "hidden", k(() => {
e[l] = t;
});
}
- f[i] = R(e.C() + t, s), o && u();
+ f[i] = R((o ? R(f[i], s) : e.C()) + t, s), o && u();
+ const dd = R(f[i], s);
+ f[i] = R((o ? dd < kk && dd >= f[ss] - f[cc] - 1 ? kk : dd : e.C()) + t, s), kk = R(f[i], s), o && u();
}), n[1](!0);
},
v() {
@@ -519,11 +532,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
@@ -519,11 +534,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
})(e);
return [ e => t[e], t.length ];
}, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => {
Expand All @@ -89,7 +110,7 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1
const o = $(t);
return e.jsx(Y, {
diff --git a/lib/index.js b/lib/index.js
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed7746287e 100644
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..5ccbaee734c167b2df98e9fe8233dd467fed8ff1 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,7 +1,7 @@
Expand Down Expand Up @@ -162,16 +183,37 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed
const n = i() - l;
150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0);
}, v = () => {
@@ -270,7 +283,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
@@ -254,23 +268,24 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
n && n();
} ];
}, A = (e, t) => {
- let o, n, r = _(), s = !1;
- const i = t ? "scrollLeft" : "scrollTop", l = t ? "overflowX" : "overflowY", [c, f] = J(e, () => r[0], (e, n) => {
+ let o, n, r = _(), s = !1, kk = 0;
+ const i = t ? "scrollLeft" : "scrollTop", l = t ? "overflowX" : "overflowY", ss = t ? "scrollWidth" : "scrollHeight", cc = t ? "clientWidth" : "clientHeight", [c, f] = J(e, () => r[0], (e, n) => {
e = W(e, s), n ? o.scrollTo({
[t ? "left" : "top"]: e,
behavior: "smooth"
- }) : o[i] = e;
+ }) : (o[i] = e, kk = W(o[i], s));
});
return {
N(c, u) {
- o = u, t && (s = "rtl" === getComputedStyle(u).direction), n = B(e, u, t, () => W(u[i], s), (t, o, n) => {
+ o = u, t && (s = "rtl" === getComputedStyle(u).direction), n = B(e, u, t, () => kk = W(u[i], s), (t, o, n) => {
if (n) {
const e = u.style, t = e[l];
e[l] = "hidden", H(() => {
e[l] = t;
});
}
- u[i] = W(e.C() + t, s), o && f();
+ u[i] = W((o ? W(u[i], s) : e.C()) + t, s), o && f();
+ const dd = W(u[i], s);
+ u[i] = W((o ? dd < kk && dd >= u[ss] - u[cc] - 1 ? kk : dd : e.C()) + t, s), kk = W(u[i], s), o && f();
}), r[1](!0);
},
v() {
@@ -523,11 +536,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
@@ -523,11 +538,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
})(e);
return [ e => o[e], o.length ];
}, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => {
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading