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:
@@ -5,7 +5,7 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
let stopped = false;
|
||||
|
||||
async function drain() {
|
||||
while (!stopped && !isScrolling() && (pending || loadedSnapshot)) {
|
||||
while (!stopped && (pending || loadedSnapshot)) {
|
||||
let snapshot = loadedSnapshot;
|
||||
loadedSnapshot = null;
|
||||
|
||||
@@ -29,8 +29,8 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (stopped || isScrolling() || (!pending && !loadedSnapshot)) return inFlight;
|
||||
async function processPending() {
|
||||
if (stopped || (!pending && !loadedSnapshot)) return inFlight;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = drain();
|
||||
try {
|
||||
@@ -38,15 +38,20 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
if ((pending || loadedSnapshot) && !isScrolling()) return flush();
|
||||
if (pending || (loadedSnapshot && !isScrolling())) return processPending();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (stopped || isScrolling()) return inFlight;
|
||||
return processPending();
|
||||
}
|
||||
|
||||
return {
|
||||
request() {
|
||||
if (stopped) return Promise.resolve();
|
||||
pending = true;
|
||||
return flush();
|
||||
return processPending();
|
||||
},
|
||||
flush,
|
||||
stop() {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export function createSessionTimelineScrollPolicy({ isUserScrolling, writeScroll }) {
|
||||
let explicitDepth = 0;
|
||||
let deferredAdjustment = 0;
|
||||
let deferredInstance = null;
|
||||
|
||||
function scrollToFn(offset, options = {}, instance) {
|
||||
if (explicitDepth === 0 && isUserScrolling()) {
|
||||
const adjustment = Number(options.adjustments);
|
||||
if (Number.isFinite(adjustment)) deferredAdjustment += adjustment;
|
||||
deferredInstance = instance;
|
||||
return;
|
||||
}
|
||||
writeScroll(offset, options, instance);
|
||||
}
|
||||
|
||||
function runExplicit(action) {
|
||||
explicitDepth++;
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
explicitDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
function flushDeferredAdjustment(instance = deferredInstance) {
|
||||
if (isUserScrolling() || deferredAdjustment === 0 || !instance?.scrollElement) return false;
|
||||
const adjustment = deferredAdjustment;
|
||||
deferredAdjustment = 0;
|
||||
deferredInstance = null;
|
||||
const offset = Number(instance.scrollElement.scrollTop) || 0;
|
||||
writeScroll(offset, { behavior: 'auto', adjustments: adjustment }, instance);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
scrollToFn,
|
||||
runExplicit,
|
||||
flushDeferredAdjustment,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
||||
|
||||
function estimatedTextHeight(text = '') {
|
||||
return Math.min(560, Math.ceil(String(text).length / 72) * 20);
|
||||
@@ -32,8 +33,13 @@ export function useSessionTimelineViewport({
|
||||
overscan = 6,
|
||||
gap = 14,
|
||||
scrollPaddingEnd = 0,
|
||||
userScroll,
|
||||
}) {
|
||||
const followOnAppend = ref(false);
|
||||
const tailFollowReady = ref(false);
|
||||
const scrollPolicy = createSessionTimelineScrollPolicy({
|
||||
isUserScrolling: () => userScroll?.isActive() ?? false,
|
||||
writeScroll: elementScroll,
|
||||
});
|
||||
const virtualizer = useVirtualizer(computed(() => ({
|
||||
count: items.value.length,
|
||||
getScrollElement: () => scrollElement.value,
|
||||
@@ -44,14 +50,16 @@ export function useSessionTimelineViewport({
|
||||
overscan,
|
||||
gap,
|
||||
anchorTo: 'end',
|
||||
followOnAppend: followOnAppend.value,
|
||||
followOnAppend: false,
|
||||
scrollEndThreshold: 50,
|
||||
isScrollingResetDelay: 450,
|
||||
useScrollendEvent: true,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
scrollToFn: scrollPolicy.scrollToFn,
|
||||
})));
|
||||
|
||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
||||
const isScrolling = computed(() => virtualizer.value.isScrolling);
|
||||
|
||||
function measureElement(element) {
|
||||
if (!element) return;
|
||||
@@ -77,7 +85,9 @@ export function useSessionTimelineViewport({
|
||||
|
||||
function scrollToIndex(index, options = {}) {
|
||||
const scroll = () => {
|
||||
virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options });
|
||||
scrollPolicy.runExplicit(() => {
|
||||
virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options });
|
||||
});
|
||||
};
|
||||
|
||||
// A far jump starts from estimates. Re-align after mounted rows have been
|
||||
@@ -85,20 +95,26 @@ export function useSessionTimelineViewport({
|
||||
runWithMeasurementRetry(scroll);
|
||||
}
|
||||
|
||||
function scrollToEnd() {
|
||||
async function scrollToEnd() {
|
||||
const targetWindow = scrollElement.value?.ownerDocument?.defaultView;
|
||||
if (targetWindow) {
|
||||
await new Promise(resolve => targetWindow.requestAnimationFrame(resolve));
|
||||
}
|
||||
const scroll = () => {
|
||||
const element = scrollElement.value;
|
||||
if (element && 'scrollHeight' in element) {
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: 'auto' });
|
||||
} else {
|
||||
virtualizer.value.scrollToEnd({ behavior: 'auto' });
|
||||
}
|
||||
scrollPolicy.runExplicit(() => {
|
||||
const element = scrollElement.value;
|
||||
if (element && 'scrollHeight' in element) {
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: 'auto' });
|
||||
} else {
|
||||
virtualizer.value.scrollToEnd({ behavior: 'auto' });
|
||||
}
|
||||
});
|
||||
};
|
||||
runWithMeasurementRetry(scroll);
|
||||
scroll();
|
||||
}
|
||||
|
||||
function isFollowingTail() {
|
||||
if (!followOnAppend.value) return false;
|
||||
if (!tailFollowReady.value) return false;
|
||||
const element = scrollElement.value;
|
||||
if (element && 'scrollHeight' in element) {
|
||||
return element.scrollHeight - element.clientHeight - element.scrollTop <= 50;
|
||||
@@ -107,23 +123,29 @@ export function useSessionTimelineViewport({
|
||||
}
|
||||
|
||||
function resetForInitialSnapshot() {
|
||||
followOnAppend.value = false;
|
||||
virtualizer.value.scrollToOffset(0, { behavior: 'auto' });
|
||||
tailFollowReady.value = false;
|
||||
scrollPolicy.runExplicit(() => {
|
||||
virtualizer.value.scrollToOffset(0, { behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
function completeInitialSnapshot() {
|
||||
followOnAppend.value = true;
|
||||
tailFollowReady.value = true;
|
||||
}
|
||||
|
||||
function settleUserScroll() {
|
||||
return scrollPolicy.flushDeferredAdjustment(virtualizer.value);
|
||||
}
|
||||
|
||||
return {
|
||||
virtualRows,
|
||||
totalSize,
|
||||
isScrolling,
|
||||
measureElement,
|
||||
indexAtViewportEnd,
|
||||
scrollToIndex,
|
||||
scrollToEnd,
|
||||
isFollowingTail,
|
||||
settleUserScroll,
|
||||
resetForInitialSnapshot,
|
||||
completeInitialSnapshot,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
export function createSessionUserScroll({
|
||||
quietMs = 450,
|
||||
setTimeout: schedule = globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: cancel = globalThis.clearTimeout.bind(globalThis),
|
||||
onEnd = () => {},
|
||||
} = {}) {
|
||||
let element = null;
|
||||
let active = false;
|
||||
let upwardIntent = false;
|
||||
let quietTimer = null;
|
||||
|
||||
function clearQuietTimer() {
|
||||
if (quietTimer === null) return;
|
||||
cancel(quietTimer);
|
||||
quietTimer = null;
|
||||
}
|
||||
|
||||
function finish({ notify = true } = {}) {
|
||||
clearQuietTimer();
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (notify) onEnd();
|
||||
}
|
||||
|
||||
function scheduleFallback() {
|
||||
clearQuietTimer();
|
||||
quietTimer = schedule(() => {
|
||||
quietTimer = null;
|
||||
finish();
|
||||
}, quietMs);
|
||||
}
|
||||
|
||||
function begin() {
|
||||
active = true;
|
||||
scheduleFallback();
|
||||
}
|
||||
|
||||
function recordDirection(delta) {
|
||||
if (delta < 0) upwardIntent = true;
|
||||
else if (delta > 0) upwardIntent = false;
|
||||
}
|
||||
|
||||
function handleWheel(event) {
|
||||
recordDirection(Number(event.deltaY) || 0);
|
||||
begin();
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!active) return;
|
||||
scheduleFallback();
|
||||
}
|
||||
|
||||
function handleScrollEnd() {
|
||||
finish();
|
||||
}
|
||||
|
||||
function detach() {
|
||||
if (element) {
|
||||
element.removeEventListener('wheel', handleWheel);
|
||||
element.removeEventListener('scroll', handleScroll);
|
||||
element.removeEventListener('scrollend', handleScrollEnd);
|
||||
}
|
||||
finish({ notify: false });
|
||||
element = null;
|
||||
}
|
||||
|
||||
return {
|
||||
attach(nextElement) {
|
||||
if (nextElement === element) return;
|
||||
detach();
|
||||
element = nextElement;
|
||||
if (!element) return;
|
||||
element.addEventListener('wheel', handleWheel, { passive: true });
|
||||
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||
element.addEventListener('scrollend', handleScrollEnd, { passive: true });
|
||||
},
|
||||
detach,
|
||||
isActive() {
|
||||
return active;
|
||||
},
|
||||
hasUpwardIntent() {
|
||||
return upwardIntent;
|
||||
},
|
||||
clearUpwardIntent() {
|
||||
upwardIntent = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { applySnapshot } from '../session-timeline.mjs';
|
||||
import { reconcileTimelineItems } from '../session-timeline-items.mjs';
|
||||
import { createSessionDisclosureState } from '../session-disclosures.mjs';
|
||||
import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs';
|
||||
import { createSessionUserScroll } from '../session-user-scroll.mjs';
|
||||
import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs';
|
||||
import FlapNumber from '../components/FlapNumber.vue';
|
||||
import SessionTimelineRow from '../components/SessionTimelineRow.vue';
|
||||
@@ -45,23 +46,26 @@ const timelineScrollMargin = ref(0);
|
||||
const disclosures = createSessionDisclosureState();
|
||||
let headerResizeObserver = null;
|
||||
const NAV_HEIGHT = 52;
|
||||
const userScroll = createSessionUserScroll({ onEnd: handleUserScrollEnd });
|
||||
|
||||
const timelineViewport = useSessionTimelineViewport({
|
||||
items: timelineItems,
|
||||
scrollElement: wrapRef,
|
||||
scrollMargin: timelineScrollMargin,
|
||||
scrollPaddingEnd: NAV_HEIGHT,
|
||||
userScroll,
|
||||
});
|
||||
const { virtualRows, totalSize, measureElement } = timelineViewport;
|
||||
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||
isScrolling: () => timelineViewport.isScrolling.value,
|
||||
isScrolling: () => userScroll.isActive(),
|
||||
load: loadLiveSnapshot,
|
||||
commit: commitLiveSnapshot,
|
||||
});
|
||||
|
||||
watch(timelineViewport.isScrolling, scrolling => {
|
||||
if (!scrolling && active.value) void liveReloadCoordinator.flush();
|
||||
});
|
||||
function handleUserScrollEnd() {
|
||||
timelineViewport.settleUserScroll();
|
||||
if (active.value) void liveReloadCoordinator.flush();
|
||||
}
|
||||
|
||||
function syncTimelineScrollMargin() {
|
||||
timelineScrollMargin.value = timelineRef.value?.offsetTop || 0;
|
||||
@@ -124,6 +128,7 @@ const showFontHint = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
@@ -145,6 +150,7 @@ onMounted(async () => {
|
||||
|
||||
onActivated(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
@@ -162,6 +168,7 @@ onActivated(async () => {
|
||||
|
||||
onDeactivated(() => {
|
||||
active.value = false;
|
||||
userScroll.detach();
|
||||
detachKeydown();
|
||||
});
|
||||
|
||||
@@ -175,6 +182,7 @@ onUnmounted(() => {
|
||||
focusTimer = null;
|
||||
headerResizeObserver?.disconnect();
|
||||
headerResizeObserver = null;
|
||||
userScroll.detach();
|
||||
liveReloadCoordinator.stop();
|
||||
removeSessionUpdated?.();
|
||||
removeSessionUpdated = null;
|
||||
@@ -183,6 +191,7 @@ onUnmounted(() => {
|
||||
watch(() => props.id, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadRevision++;
|
||||
userScroll.clearUpwardIntent();
|
||||
timelineViewport.resetForInitialSnapshot();
|
||||
messages.value = [];
|
||||
timelineItems.value = [];
|
||||
@@ -264,7 +273,9 @@ async function commitSessionSnapshot(latest) {
|
||||
}
|
||||
: null;
|
||||
const reconciliation = tailPatch || applySnapshot(messages.value, incoming);
|
||||
const restoreTail = reconciliation.tailOnly && timelineViewport.isFollowingTail();
|
||||
const restoreTail = reconciliation.tailOnly
|
||||
&& !userScroll.hasUpwardIntent()
|
||||
&& timelineViewport.isFollowingTail();
|
||||
if (reconciliation.changed) {
|
||||
messages.value = reconciliation.messages;
|
||||
if (tailPatch) {
|
||||
@@ -294,7 +305,7 @@ async function commitSessionSnapshot(latest) {
|
||||
|
||||
await nextTick();
|
||||
timelineViewport.completeInitialSnapshot();
|
||||
if (restoreTail) timelineViewport.scrollToEnd();
|
||||
if (restoreTail) await timelineViewport.scrollToEnd();
|
||||
syncTimelineScrollMargin();
|
||||
if (!state.pendingFocusUuid) onScroll();
|
||||
|
||||
@@ -313,6 +324,7 @@ async function focusPendingMessage() {
|
||||
));
|
||||
if (targetIndex < 0) return;
|
||||
focusedItemKey.value = timelineItems.value[targetIndex].key;
|
||||
userScroll.clearUpwardIntent();
|
||||
timelineViewport.scrollToIndex(targetIndex, { align: 'end' });
|
||||
if (focusTimer !== null) clearTimeout(focusTimer);
|
||||
focusTimer = setTimeout(() => {
|
||||
@@ -363,6 +375,7 @@ function navTo(target) {
|
||||
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
||||
else if (target === 'next') idx = Math.min(count - 1, currentMsgIdx.value + 1);
|
||||
else return;
|
||||
if (target === 'last') userScroll.clearUpwardIntent();
|
||||
setMessagePosition(idx, count);
|
||||
navLock = true;
|
||||
timelineViewport.scrollToIndex(idx, { align: 'end' });
|
||||
|
||||
Reference in New Issue
Block a user