fix(app): preserve momentum during live session updates
Track real user scrolling through scrollend with a quiet watchdog, defer timeline commits and virtualizer corrections until settlement, and make SessionDetail the sole tail-follow owner. Add unit and Electron regressions for scroll writes, reader anchoring, near-tail escape, explicit navigation, and flap timing.
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
|
||||
|
||||
test('live snapshots coalesce while scrolling and commit once after scroll end', async () => {
|
||||
test('live snapshots keep loading while scrolling and commit only the latest after scroll end', async () => {
|
||||
let scrolling = true;
|
||||
let loads = 0;
|
||||
const commits = [];
|
||||
@@ -24,16 +24,16 @@ test('live snapshots coalesce while scrolling and commit once after scroll end',
|
||||
await coordinator.request();
|
||||
await coordinator.request();
|
||||
await coordinator.request();
|
||||
assert.equal(loads, 0);
|
||||
assert.equal(loads, 3, 'patches are loaded into the pending snapshot while the timeline is frozen');
|
||||
assert.deepEqual(commits, []);
|
||||
|
||||
scrolling = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1);
|
||||
assert.deepEqual(commits, [1]);
|
||||
assert.equal(loads, 3, 'scroll end reuses the freshest pending snapshot');
|
||||
assert.deepEqual(commits, [3]);
|
||||
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'an idle flush without another update is a no-op');
|
||||
assert.equal(loads, 3, 'an idle flush without another update is a no-op');
|
||||
});
|
||||
|
||||
test('an update arriving during an in-flight load skips the stale snapshot without overlap', async () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createSessionTimelineScrollPolicy } from '../app/src/renderer/src/session-timeline-scroll-policy.mjs';
|
||||
|
||||
test('virtualizer scroll writes are deferred throughout a user scroll', () => {
|
||||
let scrolling = true;
|
||||
const element = { scrollTop: 100 };
|
||||
const writes = [];
|
||||
const instance = { scrollElement: element };
|
||||
const policy = createSessionTimelineScrollPolicy({
|
||||
isUserScrolling: () => scrolling,
|
||||
writeScroll: (offset, options) => {
|
||||
writes.push({ offset, ...options });
|
||||
element.scrollTop = offset + (options.adjustments || 0);
|
||||
},
|
||||
});
|
||||
|
||||
policy.scrollToFn(100, { behavior: 'auto', adjustments: 24 }, instance);
|
||||
policy.scrollToFn(124, { behavior: 'auto' }, instance);
|
||||
assert.deepEqual(writes, [], 'momentum is never interrupted by a programmatic write');
|
||||
|
||||
scrolling = false;
|
||||
policy.flushDeferredAdjustment(instance);
|
||||
assert.deepEqual(writes, [{ offset: 100, behavior: 'auto', adjustments: 24 }]);
|
||||
assert.equal(element.scrollTop, 124, 'the accumulated correction restores the reader anchor once');
|
||||
|
||||
policy.flushDeferredAdjustment(instance);
|
||||
assert.equal(writes.length, 1, 'settlement is idempotent');
|
||||
});
|
||||
|
||||
test('explicit UUID and pagination navigation can bypass the user-scroll guard', () => {
|
||||
const element = { scrollTop: 100 };
|
||||
const writes = [];
|
||||
const instance = { scrollElement: element };
|
||||
const policy = createSessionTimelineScrollPolicy({
|
||||
isUserScrolling: () => true,
|
||||
writeScroll: (offset, options) => { writes.push({ offset, ...options }); },
|
||||
});
|
||||
|
||||
policy.runExplicit(() => {
|
||||
policy.scrollToFn(720, { behavior: 'auto' }, instance);
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ offset: 720, behavior: 'auto' }]);
|
||||
});
|
||||
@@ -38,19 +38,28 @@ test('SessionDetail renders a measured virtual window instead of the complete ti
|
||||
assert.doesNotMatch(sessionDetail, /closest\(['"]\.msg/);
|
||||
});
|
||||
|
||||
test('timeline viewport owns dynamic measurement, overscan, anchoring, and tail-follow', () => {
|
||||
test('timeline viewport owns measurement and anchoring while SessionDetail alone owns tail-follow', () => {
|
||||
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
|
||||
assert.match(viewportModule, /useVirtualizer/);
|
||||
assert.match(viewportModule, /overscan/);
|
||||
assert.match(viewportModule, /anchorTo:\s*'end'/);
|
||||
assert.match(viewportModule, /followOnAppend:\s*followOnAppend\.value/);
|
||||
assert.match(viewportModule, /followOnAppend:\s*false/);
|
||||
assert.match(viewportModule, /resetForInitialSnapshot/);
|
||||
assert.match(viewportModule, /completeInitialSnapshot/);
|
||||
assert.doesNotMatch(viewportModule, /followOnAppend:\s*true/);
|
||||
assert.match(viewportModule, /scrollToFn:\s*scrollPolicy\.scrollToFn/);
|
||||
assert.match(viewportModule, /useScrollendEvent:\s*true/);
|
||||
assert.match(viewportModule, /isScrollingResetDelay:\s*450/);
|
||||
assert.match(viewportModule, /settleUserScroll/);
|
||||
assert.match(viewportModule, /useAnimationFrameWithResizeObserver:\s*true/);
|
||||
assert.match(viewportModule, /scrollPaddingEnd/);
|
||||
assert.match(viewportModule, /scrollToIndex/);
|
||||
assert.match(viewportModule, /if \(!element\) return/);
|
||||
assert.match(sessionDetail, /isScrolling:\s*\(\) => userScroll\.isActive\(\)/);
|
||||
assert.doesNotMatch(sessionDetail, /timelineViewport\.isScrolling/);
|
||||
assert.match(
|
||||
sessionDetail,
|
||||
/!userScroll\.hasUpwardIntent\(\)[\s\S]{0,100}timelineViewport\.isFollowingTail\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createSessionUserScroll } from '../app/src/renderer/src/session-user-scroll.mjs';
|
||||
|
||||
function createScheduler() {
|
||||
let now = 0;
|
||||
let nextId = 1;
|
||||
const tasks = new Map();
|
||||
return {
|
||||
setTimeout(callback, delay) {
|
||||
const id = nextId++;
|
||||
tasks.set(id, { callback, due: now + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
tasks.delete(id);
|
||||
},
|
||||
advance(milliseconds) {
|
||||
now += milliseconds;
|
||||
while (true) {
|
||||
const ready = [...tasks.entries()]
|
||||
.filter(([, task]) => task.due <= now)
|
||||
.sort((left, right) => left[1].due - right[1].due)[0];
|
||||
if (!ready) return;
|
||||
tasks.delete(ready[0]);
|
||||
ready[1].callback();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(target, type, properties = {}) {
|
||||
const event = new Event(type);
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
Object.defineProperty(event, key, { value });
|
||||
}
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', () => {
|
||||
const scheduler = createScheduler();
|
||||
const target = new EventTarget();
|
||||
target.scrollTop = 100;
|
||||
target.onscrollend = null;
|
||||
let ended = 0;
|
||||
const userScroll = createSessionUserScroll({
|
||||
quietMs: 450,
|
||||
setTimeout: scheduler.setTimeout,
|
||||
clearTimeout: scheduler.clearTimeout,
|
||||
onEnd: () => { ended++; },
|
||||
});
|
||||
|
||||
userScroll.attach(target);
|
||||
dispatch(target, 'wheel', { deltaY: -24 });
|
||||
assert.equal(userScroll.isActive(), true);
|
||||
assert.equal(userScroll.hasUpwardIntent(), true);
|
||||
|
||||
scheduler.advance(150);
|
||||
assert.equal(userScroll.isActive(), true, '150ms silence must not end native momentum');
|
||||
assert.equal(ended, 0);
|
||||
|
||||
dispatch(target, 'scrollend');
|
||||
assert.equal(userScroll.isActive(), false);
|
||||
assert.equal(ended, 1);
|
||||
|
||||
userScroll.detach();
|
||||
dispatch(target, 'wheel', { deltaY: 12 });
|
||||
assert.equal(userScroll.isActive(), false, 'detached controllers ignore later DOM events');
|
||||
});
|
||||
|
||||
test('unsupported scrollend falls back to a 450ms quiet window', () => {
|
||||
const scheduler = createScheduler();
|
||||
const target = new EventTarget();
|
||||
target.scrollTop = 0;
|
||||
let ended = 0;
|
||||
const userScroll = createSessionUserScroll({
|
||||
quietMs: 450,
|
||||
setTimeout: scheduler.setTimeout,
|
||||
clearTimeout: scheduler.clearTimeout,
|
||||
onEnd: () => { ended++; },
|
||||
});
|
||||
|
||||
userScroll.attach(target);
|
||||
dispatch(target, 'wheel', { deltaY: 12 });
|
||||
scheduler.advance(449);
|
||||
assert.equal(userScroll.isActive(), true);
|
||||
scheduler.advance(1);
|
||||
assert.equal(userScroll.isActive(), false);
|
||||
assert.equal(ended, 1);
|
||||
});
|
||||
|
||||
test('a missed native scrollend still settles through the quiet watchdog', () => {
|
||||
const scheduler = createScheduler();
|
||||
const target = new EventTarget();
|
||||
target.scrollTop = 0;
|
||||
target.onscrollend = null;
|
||||
let ended = 0;
|
||||
const userScroll = createSessionUserScroll({
|
||||
quietMs: 450,
|
||||
setTimeout: scheduler.setTimeout,
|
||||
clearTimeout: scheduler.clearTimeout,
|
||||
onEnd: () => { ended++; },
|
||||
});
|
||||
|
||||
userScroll.attach(target);
|
||||
dispatch(target, 'wheel', { deltaY: -12 });
|
||||
scheduler.advance(450);
|
||||
|
||||
assert.equal(userScroll.isActive(), false);
|
||||
assert.equal(ended, 1, 'a boundary wheel cannot freeze live commits forever');
|
||||
});
|
||||
|
||||
test('downward wheel intent re-enables tail following after an upward escape', () => {
|
||||
const target = new EventTarget();
|
||||
target.scrollTop = 100;
|
||||
target.onscrollend = null;
|
||||
const userScroll = createSessionUserScroll();
|
||||
userScroll.attach(target);
|
||||
|
||||
dispatch(target, 'wheel', { deltaY: -1 });
|
||||
assert.equal(userScroll.hasUpwardIntent(), true);
|
||||
dispatch(target, 'wheel', { deltaY: 1 });
|
||||
assert.equal(userScroll.hasUpwardIntent(), false);
|
||||
userScroll.detach();
|
||||
});
|
||||
Reference in New Issue
Block a user