test(app): distinguish image resizing from timeline scroll jumps

Virtualized scroll offsets include estimate corrections as well as wheel movement. Locate the entered reader using viewport geometry and account for both image growth and shrinkage before judging movement of the former reader. Keep regression assertions for additional viewport jumps.
This commit is contained in:
Mohamed Boudra
2026-10-15 14:42:00 +02:00
parent 51525e9146
commit b8a8387534
2 changed files with 72 additions and 16 deletions
@@ -133,6 +133,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new
).toHaveLength(1);
});
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
for (const height of [225, 1632]) {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2208,
scrollHeight: 5000,
viewportHeight: 516,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -704, height: 560 },
{ id: "caption", top: -144, height: 100 },
{ id: "old-reader", top: -44, height: 127 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 3,
unmounted: 0,
};
const resize = height - 560;
const after: ScrollFrame = {
...before,
at: 1060,
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
scrollTop: 1938,
scrollHeight: 5000 + resize - 110,
wheelTotal: 160,
lastWheelAt: 1020,
imageLoads: 1,
rows: [
{ id: "image", top: -544, height },
{ id: "caption", top: 16 + resize, height: 100 },
{ id: "old-reader", top: 116 + resize, height: 127 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
// Resizing must not excuse additional viewport movement.
expect(
findScrollJumps([
before,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
}
});
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
@@ -297,11 +297,7 @@ export function findScrollJumps(frames: ScrollFrame[]) {
// Follow that intended row, not text now below the image.
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
const readingLine = 8 - availableScroll;
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
const anchor = previous.anchor;
const before = currentRows.has(anchor)
? previous.rows.find((row) => row.id === anchor)!
@@ -316,25 +312,33 @@ export function findScrollJumps(frames: ScrollFrame[]) {
// A busy main thread can deliver wheel movement hundreds of milliseconds
// after its event. Only assert idle stability after the driver stops input.
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
// Wheel events can precede their scroll update, or include input already
// applied in earlier frames. Locate the reader with this frame's scroll,
// bounded by recent input. Growth below it can move the old anchor without
// moving the reading line.
const readerFollowsInput =
intendedRow &&
currentRows.has(intendedRow.id) &&
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
const enteredRowGrowth = readerFollowsInput
// scrollTop also includes virtualizer estimate corrections. Locate the row
// entered by wheel input from its viewport movement instead. Its old height
// identifies the reader before a simultaneous image resize takes effect.
const intendedRow = previous.rows.find((row) => {
const next = currentRows.get(row.id);
if (!next || idle || wheelBudget <= 0) return false;
const travel = next.top - row.top;
return (
travel >= -8 &&
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
(next.top <= 8 || current.scrollTop === 0) &&
next.top + row.height > 8
);
});
const enteredRowResize = intendedRow
? previous.rows.reduce((growth, row) => {
if (row.top < intendedRow.top || row.top >= before.top) return growth;
const height = currentRows.get(row.id)?.height ?? row.height;
return growth + Math.max(0, height - row.height);
return growth + height - row.height;
}, 0)
: 0;
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
const readingMovement = movement - enteredRowResize;
const excessForward = readingMovement > wheelBudget + 32;
// Upward wheel input moves the same text DOWN the viewport. A negative move
// is a reversal, independent of legitimate scrollTop compensation on prepend.
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
return [];
return [
{
frame: index,