gui: a wheel or touch scroll the page is told of only after the view scrolled stays the reader's, not put back as code's (a busy Mac lost the wheel step; panel-arrange's wheel to the foot went back to 0 in WebKit under load)
WebKit scrolls off the main thread, and under load the view's scroll event fires before the wheel that caused it (probe: scroll at 518ms, wheel dispatched at 521ms with timeStamp 517ms). The scroll guard took the scroll as no one's and put it back. Each put-back is now kept for a second, and a trusted wheel or touchmove made before it was put back, going the same way, gives it back. A scroll by code put back before the input was made stays put back. click-scroll.test.cjs "a wheel the page hears of after its scroll is still the reader's" delays the app's own wheel listener by 60ms (the trusted event kept), with nothing clicked and with a click held, and checks a code scroll before the wheel stays put back. Without the fix it fails in Chromium and WebKit: "the late-heard wheel must scroll the view". Run: click-scroll, context-scroll-end, tray-cell-click, panel-arrange in Chromium and WebKit, all green. panel-arrange 28 parallel WebKit runs (load avg ~150-200): origin/main 20/28 runs failed (:89 wheel undone x4, :147 Done x25); old app.js plus the test fix, 5/28 runs failed (:89 x6); both fixes, 27/28 and 28/28 (the one failure a page.goto load timeout), Chromium 28/28. Full GUI suite, both engines: the remaining failures are login-import, routing-sessions and plugin-market, which fail or pass the same way alone on origin/main. go test -tags nogui ./internal/gui ok.
This commit is contained in:
1 parent
c8001d37da
commit
d9d59435f4
2 files changed
+75
-4
No files matched your search
@@ -19843,8 +19843,39 @@ function keyboardFocus(e) {
|
||||
if ((r.top < b.top || r.bottom > b.bottom) && scrollOnPurpose(e, 400)) focused.scrollIntoView({ block: "nearest" });
|
||||
}));
|
||||
}
|
||||
addEventListener("wheel", () => readerScrolls(250), { capture: true, passive: true });
|
||||
addEventListener("touchmove", () => readerScrolls(250), { capture: true, passive: true });
|
||||
// A wheel or a touch can scroll the view before the page is told of it: the
|
||||
// browser scrolls off the main thread, and a busy page gets the scroll event
|
||||
// first and the wheel after (WebKit, a loaded Mac). That scroll was put back
|
||||
// as no one's, and the reader lost a wheel step. What was put back is kept
|
||||
// for a moment, and a wheel or touch that came in before it was put back, the
|
||||
// same way, gives it back to the reader.
|
||||
let putBack = []; // { v, by: px put back, at }, the last second's
|
||||
function puttingBack(v, from) {
|
||||
const by = from - v.scrollTop, at = performance.now();
|
||||
putBack = putBack.filter((p) => at - p.at < 1000);
|
||||
if (Math.abs(by) >= 1) putBack.push({ v, by, at });
|
||||
}
|
||||
// Given back is what was put back after the reader's input was made (its
|
||||
// timeStamp is when it was made, not when the page was told) and went the
|
||||
// way it goes: not a scroll by code from before it.
|
||||
function givesBack(e, dir) {
|
||||
if (!e.isTrusted || !dir) return;
|
||||
const now = performance.now(), mine = (p) => p.at >= e.timeStamp - 50 && now - p.at < 1000 && Math.sign(p.by) === dir && !p.v.hidden;
|
||||
const back = putBack.filter(mine);
|
||||
putBack = putBack.filter((p) => !mine(p));
|
||||
for (const p of back) p.v.scrollTop += p.by;
|
||||
}
|
||||
let touchY = null;
|
||||
addEventListener("wheel", (e) => { readerScrolls(250); givesBack(e, Math.sign(e.deltaY)); }, { capture: true, passive: true });
|
||||
addEventListener("touchstart", (e) => { touchY = e.touches[0]?.clientY ?? null; }, { capture: true, passive: true });
|
||||
addEventListener("touchmove", (e) => {
|
||||
readerScrolls(250);
|
||||
const y = e.touches[0]?.clientY;
|
||||
if (y == null) return;
|
||||
// a finger going up scrolls the view down
|
||||
if (touchY != null) givesBack(e, Math.sign(touchY - y));
|
||||
touchY = y;
|
||||
}, { capture: true, passive: true });
|
||||
// A flick goes on scrolling after the finger is lifted, with no touch event
|
||||
// to say so: each of its scrolls keeps the next one the reader's, till it
|
||||
// comes to rest. Put back, a phone's page jerked to and fro under the
|
||||
@@ -20036,9 +20067,11 @@ for (const v of document.querySelectorAll(".view")) {
|
||||
v.tabIndex = -1;
|
||||
v.addEventListener("scroll", () => {
|
||||
if (v.hidden) return;
|
||||
if (performance.now() < purposeUntil) { fitRoom(v); readerLeaves(v); }
|
||||
else if (held?.v === v) hold(held);
|
||||
if (performance.now() < purposeUntil) { fitRoom(v); readerLeaves(v); return; }
|
||||
const from = v.scrollTop;
|
||||
if (held?.v === v) hold(held);
|
||||
else backToReader(v);
|
||||
puttingBack(v, from);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,44 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert((await page.locator(view).evaluate((v) => v.scrollTop)) > was + 100, "the wheel must scroll the view");
|
||||
});
|
||||
|
||||
// A busy Mac scrolls the view off the main thread and tells the page of
|
||||
// the wheel only after the scroll (WebKit under load, panel-arrange's
|
||||
// wheel to the foot put back to 0). The app's own wheel listener is told
|
||||
// late here, with the browser's own trusted event, as it is then.
|
||||
await t.test("a wheel the page hears of after its scroll is still the reader's", async () => {
|
||||
await context.addInitScript(() => {
|
||||
const add = EventTarget.prototype.addEventListener;
|
||||
window.addEventListener = function (type, fn, o) {
|
||||
if (type !== "wheel") return add.call(this, type, fn, o);
|
||||
return add.call(this, type, (e) => (window.__lateWheel ? setTimeout(() => fn(e), 60) : fn(e)), o);
|
||||
};
|
||||
});
|
||||
await reset();
|
||||
await page.locator(view).evaluate((v) => { const s = document.createElement("div"); s.style.cssText = "flex:none;height:1600px"; v.append(s); });
|
||||
await page.evaluate(() => { window.__lateWheel = true; });
|
||||
const box = await page.locator(view).boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 60);
|
||||
const scrollTop = () => page.locator(view).evaluate((v) => v.scrollTop);
|
||||
for (const click of [false, true]) {
|
||||
// with nothing clicked, and with a click held
|
||||
if (click) await dayButtons.nth(1).click();
|
||||
const was = await scrollTop();
|
||||
await page.mouse.wheel(0, 300);
|
||||
await page.waitForTimeout(400);
|
||||
assert((await scrollTop()) > was + 100, `the late-heard wheel must scroll the view${click ? " after a click" : ""}`);
|
||||
}
|
||||
// a scroll by code is still put back, the wheel that follows it not
|
||||
// giving it to the reader
|
||||
const was = await scrollTop();
|
||||
await page.locator(view).evaluate((v) => { v.scrollTop -= 200; });
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(await scrollTop(), was, "code's scroll is put back");
|
||||
await page.mouse.wheel(0, -40);
|
||||
await page.waitForTimeout(400);
|
||||
assert(Math.abs((await scrollTop()) - (was - 40)) <= 1, "the wheel moves the view by its own step only");
|
||||
await page.evaluate(() => { window.__lateWheel = false; });
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [], "page runtime errors");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user