aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js15
-rw-r--r--tools/feed-momentum-test.js14
-rw-r--r--tools/feed-wheel-reverse-test.js50
3 files changed, 76 insertions, 3 deletions
diff --git a/src/app.js b/src/app.js
index 5d7d22b..52adce4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6428,9 +6428,18 @@ if (INTENT_URI) {
if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return;
e.preventDefault();
const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
- // reverse turns brake first; same-way turns add to the coast already going
- const keep = Math.sign(v) === Math.sign(px) ? v : 0;
- coast(keep + (px * 3) / TAU);
+ // A reversal catches the old coast immediately. Discard its fractional
+ // step and frame clock too: after a busy frame, applying the NEW velocity
+ // to the OLD elapsed time made the first reverse step jump. Give the
+ // first notch a small response now, then coast the remaining distance.
+ if (v && px && Math.sign(v) !== Math.sign(px)) {
+ stop();
+ const first = Math.sign(px) * Math.min(16, Math.abs(px * 3 / TAU) * 16);
+ window.scrollBy({ top: first, behavior: 'instant' });
+ coast((px * 3 - first) / TAU);
+ } else {
+ coast(v + (px * 3) / TAU);
+ }
}, { passive: false });
// anything else that moves the page takes over from a coast
window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); });
diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js
index 9f61667..29e4934 100644
--- a/tools/feed-momentum-test.js
+++ b/tools/feed-momentum-test.js
@@ -57,6 +57,20 @@ try {
await sleep(60); await notch(-1);
const brake = await settle();
check('a reverse turn brakes the coast', brake < 3 * one, brake + 'px');
+ for (const dir of [1, -1]) {
+ await page.evaluate(() => window.scrollTo(0, 2000));
+ await notch(dir); await sleep(60);
+ const first = await page.evaluate((dir) => {
+ const ev = new WheelEvent('wheel', { deltaY: -dir * 100, deltaMode: 0, bubbles: true, cancelable: true });
+ Object.defineProperty(ev, 'wheelDeltaY', { value: dir * 120 });
+ const before = scrollY;
+ document.querySelector('#stand-in p').dispatchEvent(ev);
+ return scrollY - before;
+ }, dir);
+ check('reversal responds immediately with a small step (' + dir + ')', first * dir < 0 && Math.abs(first) <= 16, first + 'px');
+ await settle();
+ }
+
await top();
check('a trackpad\'s smooth deltas are the browser\'s', !(await notch(1, true)));
check('...and a notch right after them too (still a trackpad)', !(await notch(1)));
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
new file mode 100644
index 0000000..d8cc44a
--- /dev/null
+++ b/tools/feed-wheel-reverse-test.js
@@ -0,0 +1,50 @@
+// Exercise the real input/coast handlers with controlled frame timing.
+import assert from 'node:assert/strict';
+import { runInNewContext } from 'node:vm';
+const source = await Bun.file('src/app.js').text();
+const start = source.indexOf('(() => {', source.indexOf('// The feed scrolls like a phone'));
+const end = source.indexOf("\nwindow.addEventListener('load'", start);
+assert(start > 0 && end > start);
+function fixture() {
+ let now = 100, id = 0;
+ const handlers = new Map(), frames = new Map(), steps = [];
+ const window = { scrollY: 5000,
+ addEventListener: (name, fn) => handlers.set(name, fn),
+ scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; },
+ };
+ const document = { body: {}, addEventListener() {}, removeEventListener() {} };
+ const target = { closest: (selector) => selector.includes('.feed-page') ? target : null,
+ scrollHeight: 100, clientHeight: 100, parentElement: document.body };
+ runInNewContext(source.slice(start, end), { window, document,
+ matchMedia: () => ({ matches: false }), performance: { now: () => now },
+ requestAnimationFrame: (fn) => { frames.set(++id, fn); return id; },
+ cancelAnimationFrame: (id) => frames.delete(id), setTimeout, clearTimeout,
+ });
+ return {
+ get y() { return window.scrollY; }, steps,
+ wheel(dir) { handlers.get('wheel')({ deltaY: dir * 100, wheelDeltaY: -dir * 120,
+ deltaMode: 0, deltaX: 0, target, timeStamp: now, preventDefault() {} }); },
+ advance(ms) { now += ms; },
+ frame(stamp = now) { const callbacks = [...frames.values()]; frames.clear(); for (const fn of callbacks) fn(stamp); },
+ };
+}
+for (const dir of [1, -1]) {
+ const f = fixture();
+ f.wheel(dir); f.advance(17); f.frame();
+ f.wheel(dir); f.advance(17); f.frame();
+ // A busy main thread has missed several frames when the reverse arrives.
+ f.advance(120);
+ const before = f.y;
+ f.wheel(-dir);
+ assert((f.y - before) * dir < 0, 'first reverse notch responds immediately');
+ assert(Math.abs(f.y - before) < 20, 'reverse starts with a small bounded step');
+ const reversed = f.y;
+ f.frame(0); // a queued animation frame can carry an older timestamp
+ assert.equal(f.y, reversed, 'old frame time cannot revive the previous direction');
+ f.advance(17); f.frame();
+ assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction');
+ assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay');
+ for (let i = 0; i < 100; i++) { f.advance(17); f.frame(); }
+ assert(Math.abs(f.y - before) > 280 && Math.abs(f.y - before) < 330, 'one notch retains its coast distance');
+}
+console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, and coast distance');